diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..0f5cd75 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,17 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build *)", + "Bash(cargo test *)", + "Bash(cargo check *)", + "Bash(cargo fix *)", + "Bash(grep -A 3 'name = \"eframe\"' /mnt/extra/extra1/00_PROJECTS/hcie-rust-v4/Cargo.lock 2>/dev/null || grep -A 3 'name = \"eframe\"' /mnt/extra/00_PROJECTS/hcie-rust-v4/Cargo.lock 2>/dev/null)", + "Read(//mnt/extra/extra1/00_PROJECTS/hcie-rust-v4/**)", + "Bash(find /home/dev-user/.cargo/registry/src -path \"*/egui-0.34*/src/containers/panel.rs\" 2>/dev/null | head -1)", + "Read(//home/dev-user/.cargo/registry/src/**)", + "Bash(find /home/dev-user/.cargo/registry/src -path \"*/egui-0.3*/src/containers/panel.rs\" 2>/dev/null | head -3)", + "Bash(sed -i 's/\\\\.close_menu\\(\\)/.close\\(\\)/g' /mnt/extra/00_PROJECTS/hcie-rust-v4/hcie-egui-app/crates/hcie-gui-egui/src/app/menus.rs)", + "Bash(sed -i 's/\\\\.close_menu\\(\\)/.close\\(\\)/g' /mnt/extra/00_PROJECTS/hcie-rust-v4/hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs)" + ] + } +} diff --git a/.gitea/workflows/regression-gate.yml b/.gitea/workflows/regression-gate.yml new file mode 100644 index 0000000..ceff5e9 --- /dev/null +++ b/.gitea/workflows/regression-gate.yml @@ -0,0 +1,26 @@ +name: mandatory-regression-gate + +on: + push: + pull_request: + +jobs: + deterministic-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Reject commit whitespace errors + run: git show --check --oneline --no-patch HEAD + - name: Run deterministic workspace tests + run: | + cargo check --locked --workspace --exclude hcie-io --examples + cargo test --locked --workspace --exclude hcie-io --lib --tests -- --skip benchmark_4k_stroke_on_multilayer_document + - name: Run protected visual regression tests + run: cargo test --locked -p hcie-engine-api --test visual_regression + + protected-performance-path: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Verify protected 4K stroke path + run: cargo test --locked -p hcie-engine-api --test performance_stroke_4k -- --nocapture diff --git a/.githooks/pre-commit b/.githooks/pre-commit index f0977fb..d15b8ff 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,21 +1,16 @@ -#!/bin/bash +#!/usr/bin/env bash +set -Eeuo pipefail -CHANGED_FILES=$(git diff --cached --name-only) -if [ -z "$CHANGED_FILES" ]; then - exit 0 +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +echo "Running mandatory staged-change regression gate..." +"$REPO_ROOT/scripts/precommit-test-gate.sh" + +CHANGED_FILES="$(git diff --cached --name-only --diff-filter=ACMRDTUXB)" +if [[ -n "$CHANGED_FILES" ]]; then + echo "Running semantic impact analysis..." + python3 "$REPO_ROOT/.githooks/semantic_analyzer.py" "$CHANGED_FILES" fi -echo "🔍 Semantik etki analizi başlatılıyor..." -echo "Değişen dosyalar:" -echo "$CHANGED_FILES" -echo "--------------------------------------------------" - -python3 .githooks/semantic_analyzer.py "$CHANGED_FILES" -ANALYSIS_RESULT=$? - -if [ $ANALYSIS_RESULT -ne 0 ]; then - echo "❌ COMMIT ENGELLENDİ: Kritik bir modül risk altında veya testler başarısız." - exit 1 -fi - -exit 0 \ No newline at end of file +echo "Pre-commit checks passed." diff --git a/.githooks/semantic_analyzer.py b/.githooks/semantic_analyzer.py index c853d89..cde1b33 100755 --- a/.githooks/semantic_analyzer.py +++ b/.githooks/semantic_analyzer.py @@ -1,7 +1,6 @@ #!/usr/bin/env python3 import sys import json -import subprocess import os def load_manifest(): @@ -42,13 +41,9 @@ def main(): log.write(report_line + "\n") if high_risk_change: - print("🔄 Yüksek riskli alan değiştiği için Rust entegrasyon testleri zorunlu olarak çalıştırılıyor...") - result = subprocess.run("cargo test --all-features", shell=True) - if result.returncode != 0: - print("❌ Entegrasyon testleri başarısız oldu! Özellik kaybı var.") - sys.exit(1) + print("High-risk change detected; the mandatory pre-commit gate already ran the deterministic workspace tests.") sys.exit(0) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/Cargo.lock b/Cargo.lock index 2d93635..1e4cbd4 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2605,6 +2605,13 @@ dependencies = [ "rstest", ] +[[package]] +name = "hcie-digital-brushes" +version = "0.1.0" +dependencies = [ + "hcie-engine-api", +] + [[package]] name = "hcie-document" version = "0.1.0" @@ -2632,6 +2639,13 @@ dependencies = [ "rstest", ] +[[package]] +name = "hcie-dry-media-brushes" +version = "0.1.0" +dependencies = [ + "hcie-engine-api", +] + [[package]] name = "hcie-engine-api" version = "0.1.0" @@ -2755,7 +2769,11 @@ dependencies = [ "evdev", "futures-util", "hcie-build-info", + "hcie-digital-brushes", + "hcie-dry-media-brushes", "hcie-engine-api", + "hcie-ink-brushes", + "hcie-paint-brushes", "hcie-watercolor-brushes", "iced", "iced-panel-adapter", @@ -2770,6 +2788,13 @@ dependencies = [ "zip", ] +[[package]] +name = "hcie-ink-brushes" +version = "0.1.0" +dependencies = [ + "hcie-engine-api", +] + [[package]] name = "hcie-io" version = "0.1.0" @@ -2829,6 +2854,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "hcie-paint-brushes" +version = "0.1.0" +dependencies = [ + "hcie-engine-api", +] + [[package]] name = "hcie-protocol" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c28b84..d8ad13a 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ members = [ "hcie-iced-app/crates/iced-panel-script", "hcie-iced-app/crates/iced-panel-ai-chat", "hcie-iced-app/crates/hcie-watercolor-brushes", + "hcie-iced-app/crates/hcie-dry-media-brushes", + "hcie-iced-app/crates/hcie-ink-brushes", + "hcie-iced-app/crates/hcie-paint-brushes", + "hcie-iced-app/crates/hcie-digital-brushes", "tools/screenshot-diff", "tools/panel-tuner", ] diff --git a/hcie-brush-engine/examples/brush_test.rs b/hcie-brush-engine/examples/brush_test.rs index 16ebf31..03f1e0d 100644 --- a/hcie-brush-engine/examples/brush_test.rs +++ b/hcie-brush-engine/examples/brush_test.rs @@ -581,6 +581,13 @@ impl eframe::App for App { self.preset.tip.color_variant, self.preset.tip.variant_amount, self.preset.tip.density, + self.preset.tip.jitter_amount, + self.preset.tip.scatter_amount, + self.preset.tip.angle, + self.preset.tip.roundness, + self.preset.tip.rotation_random, + self.preset.tip.drawing_angle, + true, ); self.stroke_count += 1; diff --git a/hcie-brush-engine/src/lib.rs b/hcie-brush-engine/src/lib.rs index 30837c4..08d66e0 100644 --- a/hcie-brush-engine/src/lib.rs +++ b/hcie-brush-engine/src/lib.rs @@ -308,6 +308,10 @@ fn draw_brush_style_dab( rotation_random: f32, drawing_angle: bool, ) { + let pressure = pressure.clamp(0.0, 1.0); + if pressure <= 0.0 || size <= 0.0 || opacity <= 0.0 || width == 0 || height == 0 { + return; + } let color = if color_variant && variant_amount > 0.0 { let mut rng = rand::thread_rng(); vary_color_hsl(color, variant_amount, &mut rng) @@ -315,7 +319,7 @@ fn draw_brush_style_dab( color }; match brush_style { - BrushStyle::Oil => draw_oil_brush( + BrushStyle::Noise => draw_grain_brush( pixels, width, height, @@ -328,6 +332,61 @@ fn draw_brush_style_dab( brush_hardness, is_eraser, mask, + density, + false, + ), + BrushStyle::Texture => draw_grain_brush( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity, + brush_hardness, + is_eraser, + mask, + density, + true, + ), + BrushStyle::Pen | BrushStyle::InkPen => draw_pen_brush( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity, + if brush_style == BrushStyle::InkPen { + roundness.min(0.38) + } else { + roundness + }, + angle, + is_eraser, + mask, + stroke_mask, + bg_pixels, + max_stroke_opacity, + ), + BrushStyle::Oil => draw_oil_brush_oriented( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity, + brush_hardness, + angle, + is_eraser, + mask, ), BrushStyle::Charcoal => draw_charcoal_brush( pixels, @@ -343,7 +402,7 @@ fn draw_brush_style_dab( is_eraser, mask, ), - BrushStyle::Watercolor => draw_watercolor_brush( + BrushStyle::Watercolor => draw_watercolor_brush_media( pixels, width, height, @@ -356,6 +415,7 @@ fn draw_brush_style_dab( brush_hardness, is_eraser, mask, + density, ), BrushStyle::Calligraphy => draw_calligraphy_brush( pixels, @@ -368,6 +428,8 @@ fn draw_brush_style_dab( color, opacity, brush_hardness, + angle, + roundness, is_eraser, mask, ), @@ -382,6 +444,8 @@ fn draw_brush_style_dab( color, opacity, brush_hardness, + angle, + roundness, is_eraser, mask, ), @@ -502,6 +566,9 @@ fn draw_brush_style_dab( is_eraser, mask, ), + BrushStyle::Blender => { + draw_blender_brush(pixels, width, height, cx, cy, size, pressure, opacity, mask) + } BrushStyle::Crayon => draw_crayon_brush( pixels, width, @@ -564,7 +631,7 @@ fn draw_brush_style_dab( variant_amount, density, ), - BrushStyle::Star => draw_stipple_brush( + BrushStyle::Star => draw_star_brush( pixels, width, height, @@ -578,7 +645,7 @@ fn draw_brush_style_dab( is_eraser, mask, ), - BrushStyle::Bristle => draw_bristle_brush( + BrushStyle::Rock => draw_rock_brush( pixels, width, height, @@ -592,6 +659,35 @@ fn draw_brush_style_dab( is_eraser, mask, ), + BrushStyle::Clouds => draw_clouds_brush( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity, + brush_hardness, + is_eraser, + mask, + ), + BrushStyle::Bristle => draw_bristle_brush_oriented( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity, + brush_hardness, + angle, + is_eraser, + mask, + ), BrushStyle::Wood => draw_wood_brush( pixels, width, @@ -688,7 +784,7 @@ fn draw_brush_style_dab( // into the stroke mask if requested so eraser strokes stay // consistent with the capped path. if let Some(ref mut sm) = stroke_mask { - draw_dab_rotated( + accumulate_rotated_stroke_mask( sm, width, height, @@ -698,9 +794,7 @@ fn draw_brush_style_dab( brush_hardness, rot, effective_roundness, - [255, 255, 255, (max_stroke_opacity * 255.0).round() as u8], - 1.0, - false, + max_stroke_opacity, mask, ); } @@ -755,11 +849,74 @@ pub fn draw_dab( is_eraser: bool, mask: Option<&[u8]>, ) { + if width == 0 || height == 0 || pixels.len() < width as usize * height as usize * 4 { + return; + } draw_dab_with_stamp( pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, None, ); } +/// Accumulates a rotated dab into the one-byte-per-pixel stroke opacity mask. +#[allow(clippy::too_many_arguments)] +fn accumulate_rotated_stroke_mask( + stroke_mask: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + hardness: f32, + angle: f32, + roundness: f32, + opacity: f32, + selection_mask: Option<&[u8]>, +) { + if width == 0 || height == 0 || stroke_mask.len() < width as usize * height as usize { + return; + } + let radius = (size * 0.5).max(0.5); + let short_radius = radius * roundness.clamp(0.01, 1.0); + let x_min = ((cx - radius).floor() as i32).max(0).min(width as i32 - 1); + let x_max = ((cx + radius).ceil() as i32).max(0).min(width as i32 - 1); + let y_min = ((cy - radius).floor() as i32).max(0).min(height as i32 - 1); + let y_max = ((cy + radius).ceil() as i32).max(0).min(height as i32 - 1); + let cos_angle = angle.cos(); + let sin_angle = angle.sin(); + let hard_squared = (radius * hardness.clamp(0.0, 1.0)).powi(2); + let radius_squared = radius * radius; + let soft_range_inverse = 1.0 / (radius_squared - hard_squared + 1e-6); + for y in y_min..=y_max { + for x in x_min..=x_max { + let dx = x as f32 - cx; + let dy = y as f32 - cy; + let local_x = dx * cos_angle + dy * sin_angle; + let local_y = -dx * sin_angle + dy * cos_angle; + let normalized_squared = + (local_x / radius).powi(2) + (local_y / short_radius.max(1e-3)).powi(2); + if normalized_squared > 1.0 { + continue; + } + let index = y as usize * width as usize + x as usize; + let selection = selection_mask + .and_then(|mask| mask.get(index)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + let distance_squared = normalized_squared * radius_squared; + let falloff = if distance_squared <= hard_squared { + 1.0 + } else { + (1.0 - (distance_squared - hard_squared) * soft_range_inverse).clamp(0.0, 1.0) + }; + let alpha = (falloff * opacity * selection * 255.0) + .round() + .clamp(0.0, 255.0) as u8; + stroke_mask[index] = stroke_mask[index].max(alpha); + } + } +} + /// Apply a single brush dab, optionally using a precomputed bitmap stamp. /// When `stamp` is provided it overrides the procedural circle/square shape. /// `stamp_diameter` is the width/height of the square stamp buffer. @@ -891,6 +1048,9 @@ pub fn draw_dab_rotated( is_eraser: bool, mask: Option<&[u8]>, ) { + if width == 0 || height == 0 || pixels.len() < width as usize * height as usize * 4 { + return; + } // Unrotated radii: roundness scales the radius perpendicular to the angle. let r = (size / 2.0).max(0.5); let r_long = r; @@ -1122,7 +1282,354 @@ pub fn draw_dab_capped_with_stamp( } } -/// Apply oil paint brush - multiple bristle-like dabs +/// Mixes two opaque pigment colors in optical-density space. +/// +/// **Arguments:** `base` is sampled canvas pigment, `paint` is loaded brush pigment, and `amount` +/// is the paint fraction. **Returns:** A subtractive-looking RGB mixture. **Side Effects:** None. +pub fn mix_pigments(base: [u8; 3], paint: [u8; 3], amount: f32) -> [u8; 3] { + let amount = amount.clamp(0.0, 1.0); + if amount <= 0.0 { + return base; + } + if amount >= 1.0 { + return paint; + } + let mix_channel = |a: u8, b: u8| { + let reflect_a = (a as f32 / 255.0).powf(2.2).max(0.003); + let reflect_b = (b as f32 / 255.0).powf(2.2).max(0.003); + let density = -reflect_a.ln() * (1.0 - amount) - reflect_b.ln() * amount; + ((-density).exp().powf(1.0 / 2.2) * 255.0) + .round() + .clamp(0.0, 255.0) as u8 + }; + [ + mix_channel(base[0], paint[0]), + mix_channel(base[1], paint[1]), + mix_channel(base[2], paint[2]), + ] +} + +/// Samples a premultiplied-alpha neighborhood without allowing one outlier to dominate. +fn sample_neighborhood_color( + pixels: &[u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + radius: i32, +) -> Option<[u8; 3]> { + let mut rgb = [0.0f32; 3]; + let mut weight = 0.0f32; + let center_x = cx.round() as i32; + let center_y = cy.round() as i32; + for y in center_y - radius..=center_y + radius { + for x in center_x - radius..=center_x + radius { + if x < 0 || y < 0 || x >= width as i32 || y >= height as i32 { + continue; + } + let index = (y as usize * width as usize + x as usize) * 4; + let alpha = pixels[index + 3] as f32 / 255.0; + if alpha <= 0.01 { + continue; + } + for channel in 0..3 { + rgb[channel] += pixels[index + channel] as f32 * alpha; + } + weight += alpha; + } + } + (weight > 0.0).then(|| { + [ + (rgb[0] / weight).round() as u8, + (rgb[1] / weight).round() as u8, + (rgb[2] / weight).round() as u8, + ] + }) +} + +/// Applies a deterministic paper-grain or woven-texture footprint. +#[allow(clippy::too_many_arguments)] +fn draw_grain_brush( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + hardness: f32, + is_eraser: bool, + mask: Option<&[u8]>, + density: f32, + woven: bool, +) { + let radius = size * pressure * 0.5; + if radius < 0.5 { + return; + } + let threshold = (0.72 - density.clamp(0.2, 2.0) * 0.24).clamp(0.12, 0.68); + let x0 = (cx - radius).floor().max(0.0) as u32; + let x1 = (cx + radius).ceil().min(width.saturating_sub(1) as f32) as u32; + let y0 = (cy - radius).floor().max(0.0) as u32; + let y1 = (cy + radius).ceil().min(height.saturating_sub(1) as f32) as u32; + for y in y0..=y1 { + for x in x0..=x1 { + let dx = x as f32 - cx; + let dy = y as f32 - cy; + let distance = (dx * dx + dy * dy).sqrt() / radius; + if distance > 1.0 { + continue; + } + let hash = + ((x.wrapping_mul(73_856_093) ^ y.wrapping_mul(19_349_663)) & 1023) as f32 / 1023.0; + let texture = if woven { + 0.55 + 0.45 * ((x as f32 * 0.42).sin() * (y as f32 * 0.31).cos()).abs() + } else { + hash + }; + if texture < threshold { + continue; + } + let falloff = if distance <= hardness { + 1.0 + } else { + ((1.0 - distance) / (1.0 - hardness.clamp(0.0, 0.99))).clamp(0.0, 1.0) + }; + let index = (y * width + x) as usize; + let mask_alpha = mask + .and_then(|selection| selection.get(index)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + let alpha = opacity + * pressure + * falloff + * mask_alpha + * (0.55 + 0.45 * texture) + * (color[3] as f32 / 255.0); + let offset = index * 4; + if is_eraser { + pixels[offset + 3] = (pixels[offset + 3] as f32 * (1.0 - alpha)).round() as u8; + } else { + let out = alpha_blend( + [ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ], + [color[0], color[1], color[2], (alpha * 255.0).round() as u8], + ); + pixels[offset..offset + 4].copy_from_slice(&out); + } + } + } +} + +/// Renders a hard technical or narrow ink nib. +#[allow(clippy::too_many_arguments)] +fn draw_pen_brush( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + roundness: f32, + angle: f32, + is_eraser: bool, + mask: Option<&[u8]>, + stroke_mask: Option<&mut [u8]>, + bg_pixels: Option<&[u8]>, + max_stroke_opacity: f32, +) { + if let Some(stroke_mask) = stroke_mask { + draw_dab_rotated_capped( + pixels, + width, + height, + cx, + cy, + size * pressure, + 0.98, + angle, + roundness.clamp(0.08, 1.0), + color, + opacity * pressure, + is_eraser, + mask, + stroke_mask, + bg_pixels, + max_stroke_opacity, + ); + } else { + draw_dab_rotated( + pixels, + width, + height, + cx, + cy, + size * pressure, + 0.98, + angle, + roundness.clamp(0.08, 1.0), + color, + opacity * pressure, + is_eraser, + mask, + ); + } +} + +/// Draws a rotated nib against the stroke-start buffer while capping cumulative opacity. +#[allow(clippy::too_many_arguments)] +fn draw_dab_rotated_capped( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + hardness: f32, + angle: f32, + roundness: f32, + color: [u8; 4], + opacity: f32, + is_eraser: bool, + selection_mask: Option<&[u8]>, + stroke_mask: &mut [u8], + background: Option<&[u8]>, + max_stroke_opacity: f32, +) { + let pixel_count = width as usize * height as usize; + if width == 0 + || height == 0 + || pixels.len() < pixel_count * 4 + || stroke_mask.len() < pixel_count + { + return; + } + let radius = (size * 0.5).max(0.5); + let short_radius = radius * roundness.clamp(0.01, 1.0); + let x_min = ((cx - radius).floor() as i32).max(0).min(width as i32 - 1); + let x_max = ((cx + radius).ceil() as i32).max(0).min(width as i32 - 1); + let y_min = ((cy - radius).floor() as i32).max(0).min(height as i32 - 1); + let y_max = ((cy + radius).ceil() as i32).max(0).min(height as i32 - 1); + let cos_angle = angle.cos(); + let sin_angle = angle.sin(); + let radius_squared = radius * radius; + let hard_squared = (radius * hardness.clamp(0.0, 1.0)).powi(2); + let soft_range_inverse = 1.0 / (radius_squared - hard_squared + 1e-6); + let opacity_cap = max_stroke_opacity.clamp(0.0, 1.0); + for y in y_min..=y_max { + for x in x_min..=x_max { + let dx = x as f32 - cx; + let dy = y as f32 - cy; + let local_x = dx * cos_angle + dy * sin_angle; + let local_y = -dx * sin_angle + dy * cos_angle; + let normalized_squared = + (local_x / radius).powi(2) + (local_y / short_radius.max(1e-3)).powi(2); + if normalized_squared > 1.0 { + continue; + } + let index = y as usize * width as usize + x as usize; + let selection = selection_mask + .and_then(|mask| mask.get(index)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + if selection <= 0.0 { + continue; + } + let distance_squared = normalized_squared * radius_squared; + let falloff = if distance_squared <= hard_squared { + 1.0 + } else { + (1.0 - (distance_squared - hard_squared) * soft_range_inverse).clamp(0.0, 1.0) + }; + let dab_alpha = falloff * opacity * selection * (color[3] as f32 / 255.0); + let previous = stroke_mask[index] as f32 / 255.0; + let cumulative = (previous + dab_alpha * (1.0 - previous)).min(opacity_cap); + stroke_mask[index] = (cumulative * 255.0).round() as u8; + let offset = index * 4; + let base = background + .and_then(|source| source.get(offset..offset + 4)) + .map(|source| [source[0], source[1], source[2], source[3]]) + .unwrap_or([ + pixels[offset], + pixels[offset + 1], + pixels[offset + 2], + pixels[offset + 3], + ]); + let output = if is_eraser { + [ + base[0], + base[1], + base[2], + (base[3] as f32 * (1.0 - cumulative)).round() as u8, + ] + } else { + alpha_blend( + base, + [ + color[0], + color[1], + color[2], + (cumulative * 255.0).round() as u8, + ], + ) + }; + pixels[offset..offset + 4].copy_from_slice(&output); + } + } +} + +/// Renders one five-point star matching the generated stamp preview. +#[allow(clippy::too_many_arguments)] +fn draw_star_brush( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + hardness: f32, + is_eraser: bool, + mask: Option<&[u8]>, +) { + let effective_size = size * pressure; + let tip = BrushTip { + style: BrushStyle::Star, + size: effective_size * 0.5, + hardness, + ..BrushTip::default() + }; + let stamp = generate_brush_stamp(&tip); + draw_dab_with_stamp( + pixels, + width, + height, + cx, + cy, + effective_size, + hardness, + color, + opacity * pressure, + is_eraser, + mask, + Some(&stamp), + ); +} + +/// Apply oil paint brush - coherent, directional bristle tracks with pigment pickup. #[allow(clippy::too_many_arguments)] pub fn draw_oil_brush( pixels: &mut [u8], @@ -1137,25 +1644,62 @@ pub fn draw_oil_brush( hardness: f32, is_eraser: bool, mask: Option<&[u8]>, +) { + draw_oil_brush_oriented( + pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 0.0, is_eraser, + mask, + ); +} + +/// Direction-aware oil renderer used by the stroke dispatcher. +#[allow(clippy::too_many_arguments)] +fn draw_oil_brush_oriented( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + _hardness: f32, + angle: f32, + is_eraser: bool, + mask: Option<&[u8]>, ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; - let bristle_count = (effective_size / 3.0).max(5.0) as u32; - - for _ in 0..bristle_count { - let off_x = (rng.gen::() - 0.5) * effective_size; - let off_y = (rng.gen::() - 0.5) * effective_size; - let bristle_r = (effective_size / 10.0).max(1.0); - draw_dab( + if effective_size < 0.5 { + return; + } + let sampled = sample_neighborhood_color(pixels, width, height, cx, cy, 2); + let mixed = sampled.map_or([color[0], color[1], color[2]], |base| { + mix_pigments(base, [color[0], color[1], color[2]], 0.72) + }); + let mixed_color = [mixed[0], mixed[1], mixed[2], color[3]]; + let bristle_count = ((effective_size / 4.0).round() as usize).clamp(4, 16); + let tangent = (angle.cos(), angle.sin()); + let normal = (-tangent.1, tangent.0); + for index in 0..bristle_count { + let lane = (index as f32 + 0.5) / bristle_count as f32 - 0.5; + let offset = lane * effective_size * 0.82 + rng.gen_range(-0.4..0.4); + let length = effective_size * rng.gen_range(0.24..0.48); + let center_x = cx + normal.0 * offset; + let center_y = cy + normal.1 * offset; + let thickness = (effective_size / bristle_count as f32 * 0.55).max(0.45); + draw_line_segment( pixels, width, height, - cx + off_x, - cy + off_y, - bristle_r, - hardness, - color, - opacity, + center_x - tangent.0 * length * 0.5, + center_y - tangent.1 * length * 0.5, + center_x + tangent.0 * length * 0.5, + center_y + tangent.1 * length * 0.5, + thickness, + thickness * rng.gen_range(0.45..0.9), + mixed_color, + opacity * pressure * rng.gen_range(0.65..1.0), is_eraser, mask, ); @@ -1180,9 +1724,28 @@ pub fn draw_charcoal_brush( ) { let mut rng = rand::thread_rng(); let effective_size = size * pressure; - let particle_count = (effective_size / 2.0).max(10.0) as u32; + if effective_size < 0.5 { + return; + } + draw_grain_brush( + pixels, + width, + height, + cx, + cy, + size, + pressure, + color, + opacity * 0.72, + hardness, + is_eraser, + mask, + 0.75 + hardness * 0.65, + false, + ); + let particle_count = ((effective_size / 10.0).round() as u32).clamp(2, 8); let radius = effective_size / 2.0; - let particle_opacity = opacity * 0.3; + let particle_opacity = opacity * pressure * 0.22; for _ in 0..particle_count { let r = rng.gen::().sqrt() * radius; @@ -1221,11 +1784,34 @@ pub fn draw_watercolor_brush( hardness: f32, is_eraser: bool, mask: Option<&[u8]>, +) { + draw_watercolor_brush_media( + pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, + 1.0, + ); +} + +/// Renders watercolor with preset-controlled pigment density. +#[allow(clippy::too_many_arguments)] +fn draw_watercolor_brush_media( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + hardness: f32, + is_eraser: bool, + mask: Option<&[u8]>, + density: f32, ) { let mut rng = rand::thread_rng(); draw_watercolor_brush_with_rng( pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, - &mut rng, + density, &mut rng, ); } @@ -1240,10 +1826,12 @@ const WATERCOLOR_MAX_DABS_PER_SAMPLE: usize = 8; /// eligible blooms. fn watercolor_detail_counts( effective_size: f32, + density: f32, rng: &mut R, ) -> (usize, usize, usize) { - let satellites = ((effective_size / 24.0).ceil() as usize).clamp(1, 2); - let splatters = ((effective_size / 16.0).ceil() as usize).clamp(1, 4); + let density = density.clamp(0.35, 1.75); + let satellites = ((effective_size / 24.0 * density).ceil() as usize).clamp(1, 2); + let splatters = ((effective_size / 16.0 * density).ceil() as usize).clamp(1, 4); let bloom = usize::from(effective_size > 25.0 && rng.gen_bool(0.3)); debug_assert!(1 + satellites + splatters + bloom <= WATERCOLOR_MAX_DABS_PER_SAMPLE); (satellites, splatters, bloom) @@ -1269,16 +1857,20 @@ fn draw_watercolor_brush_with_rng( hardness: f32, is_eraser: bool, mask: Option<&[u8]>, + density: f32, rng: &mut R, ) { let effective_size = size * pressure; if effective_size < 0.5 { return; } - let base_opacity = opacity * 0.3 * pressure; - let center_color = color; + let base_opacity = opacity * 0.3 * pressure * (0.82 + density.clamp(0.35, 1.75) * 0.18); + let mixed_rgb = sample_neighborhood_color(pixels, width, height, cx, cy, 2) + .map(|base| mix_pigments(base, [color[0], color[1], color[2]], 0.58)) + .unwrap_or([color[0], color[1], color[2]]); + let center_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; let (satellite_count, splatter_count, bloom_count) = - watercolor_detail_counts(effective_size, rng); + watercolor_detail_counts(effective_size, density, rng); // ── Central irregular core ────────────────────────────────────────────── let core_angle = rng.gen_range(0.0..std::f32::consts::TAU); @@ -1385,27 +1977,27 @@ pub fn draw_calligraphy_brush( color: [u8; 4], opacity: f32, hardness: f32, + angle: f32, + roundness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; - let particle_opacity = opacity * pressure; - for i in 0..3 { - let off = (i as f32 - 1.0) * (effective_size / 4.0); - draw_dab( - pixels, - width, - height, - cx, - cy + off, - effective_size, - hardness, - color, - particle_opacity, - is_eraser, - mask, - ); - } + draw_dab_rotated( + pixels, + width, + height, + cx, + cy, + effective_size, + hardness.max(0.85), + angle, + roundness.clamp(0.08, 0.45), + color, + opacity * pressure, + is_eraser, + mask, + ); } /// Apply marker brush - solid coverage @@ -1421,19 +2013,23 @@ pub fn draw_marker_brush( color: [u8; 4], opacity: f32, hardness: f32, + angle: f32, + roundness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { let effective_size = size * pressure; - let particle_opacity = opacity * pressure * 0.7; - draw_dab( + let particle_opacity = opacity * pressure * 0.72; + draw_dab_rotated( pixels, width, height, cx, cy, effective_size, - hardness, + hardness.max(0.65), + angle, + roundness.clamp(0.25, 1.0), color, particle_opacity, is_eraser, @@ -1574,7 +2170,7 @@ pub fn draw_spray_brush( is_eraser: bool, mask: Option<&[u8]>, ) { - if size <= 0.0 || density == 0 { + if size <= 0.0 || density == 0 || pressure <= 0.0 { return; } let mut rng = rand::thread_rng(); @@ -1582,7 +2178,7 @@ pub fn draw_spray_brush( let [fr, fg, fb, _] = color; let base_opacity = opacity * pressure; - for _ in 0..density { + for _ in 0..density.min(512) { let dx = normal.sample(&mut rng); let dy = normal.sample(&mut rng); @@ -1609,7 +2205,16 @@ pub fn draw_spray_brush( if dot_size <= 3.0 { let hardness_alpha = 0.3 + hardness * 0.7; - let brush_alpha = (base_opacity * 255.0 * hardness_alpha) as u8; + let selection_alpha = mask + .and_then(|selection| selection.get((uy * width + ux) as usize)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + let brush_alpha = (base_opacity + * (color[3] as f32 / 255.0) + * selection_alpha + * 255.0 + * hardness_alpha) as u8; let i = (uy * width + ux) as usize * 4; if is_eraser { @@ -1689,7 +2294,16 @@ pub fn draw_spray_brush( } else { (1.0 - (dist - edge) * falloff).clamp(0.0, 1.0) }; - let fa = (base_opacity * alpha_factor * 255.0) as u8; + let selection_alpha = mask + .and_then(|selection| selection.get(nidx)) + .copied() + .unwrap_or(255) as f32 + / 255.0; + let fa = (base_opacity + * (color[3] as f32 / 255.0) + * selection_alpha + * alpha_factor + * 255.0) as u8; let ni = nidx * 4; if is_eraser { @@ -1726,19 +2340,21 @@ pub fn draw_pencil_brush( is_eraser: bool, mask: Option<&[u8]>, ) { - let effective_size = size * pressure; - draw_dab( + draw_grain_brush( pixels, width, height, cx, cy, - effective_size, - hardness, + size, + pressure, color, opacity, + hardness, is_eraser, mask, + 0.75 + pressure * 0.8, + false, ); } @@ -1758,6 +2374,9 @@ pub fn draw_square_brush( is_eraser: bool, mask: Option<&[u8]>, ) { + if pressure <= 0.0 || w == 0 || h == 0 { + return; + } let r = (size * pressure / 2.0).max(0.5); let x_min = ((cx - r).floor() as i32).max(0).min(w as i32 - 1); @@ -1794,8 +2413,13 @@ pub fn draw_square_brush( (1.0 - (max_dist - r * hardness) / (r - r * hardness + 1e-6)).clamp(0.0, 1.0) }; - let brush_alpha = - (alpha_factor * opacity * (mask_val as f32 / 255.0) * 255.0).round() as u8; + let brush_alpha = (alpha_factor + * opacity + * pressure + * (color[3] as f32 / 255.0) + * (mask_val as f32 / 255.0) + * 255.0) + .round() as u8; let i = idx * 4; if is_eraser { @@ -1831,24 +2455,50 @@ pub fn draw_wetpaint_brush( mask: Option<&[u8]>, ) { let effective_size = size * pressure; + if effective_size < 0.5 { + return; + } let mut rng = rand::thread_rng(); - // Create a more organic "liquid" look by overlapping several slightly offset dabs - let dab_count = rng.gen_range(3..=6); - for _ in 0..dab_count { - let ox = rng.gen_range(-effective_size * 0.2..effective_size * 0.2); - let oy = rng.gen_range(-effective_size * 0.2..effective_size * 0.2); - let s = effective_size * rng.gen_range(0.8..1.1); - draw_dab( + let mixed_rgb = sample_neighborhood_color(pixels, width, height, cx, cy, 2) + .map(|base| mix_pigments(base, [color[0], color[1], color[2]], 0.68)) + .unwrap_or([color[0], color[1], color[2]]); + let wet_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; + draw_dab( + pixels, + width, + height, + cx, + cy, + effective_size, + hardness * 0.65, + wet_color, + opacity * pressure * 0.75, + is_eraser, + mask, + ); + + let drip_count = if effective_size > 8.0 { + rng.gen_range(0..=2) + } else { + 0 + }; + for _ in 0..drip_count { + let x = cx + rng.gen_range(-effective_size * 0.35..effective_size * 0.35); + let length = effective_size * rng.gen_range(0.35..0.9); + let thickness = (effective_size * rng.gen_range(0.04..0.1)).max(0.6); + draw_line_segment( pixels, width, height, - cx + ox, - cy + oy, - s, - hardness, - color, - opacity * 0.8, + x, + cy + effective_size * 0.2, + x + rng.gen_range(-thickness..thickness), + cy + effective_size * 0.2 + length, + thickness, + thickness * 0.3, + wet_color, + opacity * pressure * 0.55, is_eraser, mask, ); @@ -1871,21 +2521,14 @@ pub fn draw_mixer_brush( is_eraser: bool, mask: Option<&[u8]>, ) { - let sample_x = cx.round() as i32; - let sample_y = cy.round() as i32; - let mut mixed_color = color; - - // Sample pixel color directly from the active canvas underneath the brush center - if sample_x >= 0 && sample_x < w as i32 && sample_y >= 0 && sample_y < h as i32 { - let idx = ((sample_y as usize * w as usize) + sample_x as usize) * 4; - let canvas_a = pixels[idx + 3] as f32 / 255.0; - if canvas_a > 0.1 { - // Mix 60% of the canvas paint with 40% of the active brush color - mixed_color[0] = ((color[0] as f32 * 0.40) + (pixels[idx] as f32 * 0.60)) as u8; - mixed_color[1] = ((color[1] as f32 * 0.40) + (pixels[idx + 1] as f32 * 0.60)) as u8; - mixed_color[2] = ((color[2] as f32 * 0.40) + (pixels[idx + 2] as f32 * 0.60)) as u8; - } + if pressure <= 0.0 { + return; } + let sampled = sample_neighborhood_color(pixels, w, h, cx, cy, 2); + let mixed_rgb = sampled.map_or([color[0], color[1], color[2]], |base| { + mix_pigments(base, [color[0], color[1], color[2]], 0.38) + }); + let mixed_color = [mixed_rgb[0], mixed_rgb[1], mixed_rgb[2], color[3]]; // Draw the mixed paint stamp draw_dab( @@ -1897,12 +2540,93 @@ pub fn draw_mixer_brush( size * pressure, hardness, mixed_color, - opacity, + opacity * pressure, is_eraser, mask, ); } +/// Blends existing pixels locally without introducing the active foreground color. +#[allow(clippy::too_many_arguments)] +pub fn draw_blender_brush( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + opacity: f32, + mask: Option<&[u8]>, +) { + let radius = size * pressure * 0.5; + if radius < 0.5 || width == 0 || height == 0 { + return; + } + let max_x = width.saturating_sub(1) as f32; + let max_y = height.saturating_sub(1) as f32; + if cx + radius + 1.0 < 0.0 + || cy + radius + 1.0 < 0.0 + || cx - radius - 1.0 > max_x + || cy - radius - 1.0 > max_y + { + return; + } + let x0 = (cx - radius - 1.0).floor().clamp(0.0, max_x) as u32; + let x1 = (cx + radius + 1.0).ceil().clamp(0.0, max_x) as u32; + let y0 = (cy - radius - 1.0).floor().clamp(0.0, max_y) as u32; + let y1 = (cy + radius + 1.0).ceil().clamp(0.0, max_y) as u32; + if x1 <= x0 || y1 <= y0 { + return; + } + let region_width = (x1 - x0 + 1) as usize; + let mut source = vec![0u8; region_width * (y1 - y0 + 1) as usize * 4]; + for y in y0..=y1 { + let source_offset = ((y - y0) as usize * region_width) * 4; + let canvas_offset = (y as usize * width as usize + x0 as usize) * 4; + source[source_offset..source_offset + region_width * 4] + .copy_from_slice(&pixels[canvas_offset..canvas_offset + region_width * 4]); + } + for y in y0.saturating_add(1)..y1 { + for x in x0.saturating_add(1)..x1 { + let dx = x as f32 - cx; + let dy = y as f32 - cy; + let distance = (dx * dx + dy * dy).sqrt() / radius; + if distance > 1.0 { + continue; + } + let canvas_index = (y * width + x) as usize; + if mask + .and_then(|selection| selection.get(canvas_index)) + .copied() + .unwrap_or(255) + == 0 + { + continue; + } + let mut sum = [0u32; 4]; + for oy in -1i32..=1 { + for ox in -1i32..=1 { + let local_x = (x as i32 + ox - x0 as i32) as usize; + let local_y = (y as i32 + oy - y0 as i32) as usize; + let index = (local_y * region_width + local_x) * 4; + for channel in 0..4 { + sum[channel] += source[index + channel] as u32; + } + } + } + let strength = opacity * pressure * (1.0 - distance) * 0.55; + let offset = canvas_index * 4; + for channel in 0..4 { + let average = sum[channel] as f32 / 9.0; + pixels[offset + channel] = (pixels[offset + channel] as f32 * (1.0 - strength) + + average * strength) + .round() as u8; + } + } + } +} + /// Apply leaf scatter brush - clustered multi-oval leaf dabs #[allow(clippy::too_many_arguments)] /// Apply leaf brush - clusters of small leaf silhouettes. @@ -2266,14 +2990,23 @@ pub fn draw_crayon_brush( (1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0) }; - // Deterministic pseudo-random coordinate noise for dry crayon/chalk wax texture - let noise = ((px as f32 * 12.9898 + py as f32 * 78.233).sin() * 43758.545) - .fract() - .abs(); - let grain = 0.35 + 0.65 * noise; + // Stable canvas-space paper tooth keeps pores aligned across consecutive dabs. + let noise = ((px as u32).wrapping_mul(73_856_093) + ^ (py as u32).wrapping_mul(19_349_663)) + & 1023; + let grain = noise as f32 / 1023.0; + if grain < 0.18 * (1.0 - pressure) + 0.08 { + continue; + } - let brush_alpha = - (alpha_factor * opacity * grain * (mask_val as f32 / 255.0) * 255.0).round() as u8; + let brush_alpha = (alpha_factor + * opacity + * pressure + * (color[3] as f32 / 255.0) + * (0.35 + 0.65 * grain) + * (mask_val as f32 / 255.0) + * 255.0) + .round() as u8; let i = idx * 4; if is_eraser { @@ -2561,15 +3294,8 @@ fn quadratic_bezier(p0: f32, p1: f32, p2: f32, t: f32) -> f32 { one_minus_t * one_minus_t * p0 + 2.0 * one_minus_t * t * p1 + t * t * p2 } -/// Vary an RGBA color in HSL space by up to `amount` spread. -/// `amount` is expected in [0, 1]. Hue is shifted by ±(amount*60°), -/// lightness by ±(amount*0.4). Saturation is preserved to keep the -/// color within the same family (green stays green, purple stays purple). -/// Per-thread remembered HSL walk state for one base color. -/// -/// Keeps the last hue/lightness/saturation offsets so successive dabs move smoothly -/// instead of jumping to independent random values. The state is keyed by the -/// base color so unrelated colors do not interfere. +// Per-thread remembered HSL walk state for one base color. The remembered offsets keep +// successive color-variant dabs smooth while keys prevent unrelated colors from interacting. thread_local! { static LAST_HSL_WALK: std::cell::RefCell> = std::cell::RefCell::new(std::collections::HashMap::new()); @@ -2605,8 +3331,8 @@ pub fn vary_color_hsl(color: [u8; 4], amount: f32, rng: &mut R) -> [u8; let (base_h, base_s, base_l) = rgb_to_hsl(color[0], color[1], color[2]); let key = color_hash(color); - let (mut hue_off, mut light_off, mut sat_off) = LAST_HSL_WALK - .with_borrow(|m| m.get(&key).copied().unwrap_or((0.0, 0.0, 0.0))); + let (mut hue_off, mut light_off, mut sat_off) = + LAST_HSL_WALK.with_borrow(|m| m.get(&key).copied().unwrap_or((0.0, 0.0, 0.0))); // Maximum per-dab step: keeps transitions smooth. let max_hue_step = clamped * 18.0; // ±18° per dab at amount=1 @@ -2725,7 +3451,10 @@ pub fn draw_rock_brush( let effective_size = size * pressure; let radius = effective_size / 2.0; let particle_opacity = opacity * pressure * 0.8; - let count = (effective_size / 4.0).max(3.0) as u32; + if effective_size < 0.5 { + return; + } + let count = ((effective_size / 12.0).round() as u32).clamp(3, 6); for _ in 0..count { let r_dist = rng.gen::().sqrt() * radius; @@ -2733,14 +3462,16 @@ pub fn draw_rock_brush( let px = cx + r_dist * theta.cos(); let py = cy + r_dist * theta.sin(); let p_size = rng.gen_range(effective_size * 0.2..=effective_size * 0.5); - draw_dab( + draw_dab_rotated( pixels, width, height, px, py, p_size, - hardness, + hardness.max(0.75), + rng.gen_range(0.0..std::f32::consts::TAU), + rng.gen_range(0.45..0.78), color, particle_opacity, is_eraser, @@ -2765,9 +3496,31 @@ pub fn draw_clouds_brush( is_eraser: bool, mask: Option<&[u8]>, ) { - draw_airbrush( - pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, - ); + let effective_size = size * pressure; + if effective_size < 0.5 { + return; + } + let lobes = [ + (-0.28, 0.08, 0.58), + (-0.08, -0.12, 0.72), + (0.18, -0.08, 0.64), + (0.34, 0.1, 0.48), + ]; + for (ox, oy, scale) in lobes { + draw_dab( + pixels, + width, + height, + cx + ox * effective_size, + cy + oy * effective_size, + effective_size * scale, + hardness.min(0.18), + color, + opacity * pressure * 0.24, + is_eraser, + mask, + ); + } } /// Apply dirt brush - now a textured "stamp floor" surface. @@ -2826,7 +3579,8 @@ pub fn draw_dirt_brush( color }; - let thick = rng.gen_range(0.35..effective_size * 0.07).max(0.3); + let max_thickness = (effective_size * 0.07).max(0.36); + let thick = rng.gen_range(0.35..max_thickness).max(0.3); let stroke_opacity = patch_opacity * rng.gen_range(0.35..0.9); draw_line_segment( pixels, @@ -2984,7 +3738,7 @@ pub fn draw_stipple_brush( } } -/// Apply bristle brush - based on oil +/// Apply bristle brush with a fixed comb of parallel filaments. #[allow(clippy::too_many_arguments)] pub fn draw_bristle_brush( pixels: &mut [u8], @@ -3000,11 +3754,59 @@ pub fn draw_bristle_brush( is_eraser: bool, mask: Option<&[u8]>, ) { - draw_oil_brush( - pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, is_eraser, mask, + draw_bristle_brush_oriented( + pixels, width, height, cx, cy, size, pressure, color, opacity, hardness, 0.0, is_eraser, + mask, ); } +/// Direction-aware bristle renderer used by paint presets. +#[allow(clippy::too_many_arguments)] +fn draw_bristle_brush_oriented( + pixels: &mut [u8], + width: u32, + height: u32, + cx: f32, + cy: f32, + size: f32, + pressure: f32, + color: [u8; 4], + opacity: f32, + _hardness: f32, + angle: f32, + is_eraser: bool, + mask: Option<&[u8]>, +) { + let effective_size = size * pressure; + if effective_size < 0.5 { + return; + } + let tangent = (angle.cos(), angle.sin()); + let normal = (-tangent.1, tangent.0); + let count = ((effective_size / 2.5).round() as usize).clamp(4, 18); + for index in 0..count { + let lane = (index as f32 + 0.5) / count as f32 - 0.5; + let offset = lane * effective_size * 0.88; + let start_x = cx + normal.0 * offset - tangent.0 * effective_size * 0.28; + let start_y = cy + normal.1 * offset - tangent.1 * effective_size * 0.28; + draw_line_segment( + pixels, + width, + height, + start_x, + start_y, + start_x + tangent.0 * effective_size * 0.56, + start_y + tangent.1 * effective_size * 0.56, + (effective_size / count as f32 * 0.42).max(0.4), + 0.35, + color, + opacity * pressure * (0.55 + (index % 3) as f32 * 0.12), + is_eraser, + mask, + ); + } +} + /// Apply wood brush - lined dabs #[allow(clippy::too_many_arguments)] pub fn draw_wood_brush( @@ -3017,29 +3819,30 @@ pub fn draw_wood_brush( pressure: f32, color: [u8; 4], opacity: f32, - hardness: f32, + _hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { - let mut rng = rand::thread_rng(); let effective_size = size * pressure; let particle_opacity = opacity * pressure; - let w = effective_size * 1.5; - let h = effective_size * 0.4; - - for i in 0..4 { - let off_x = (i as f32 - 1.5) * (w / 4.0); - let off_y = (rng.gen::() - 0.5) * (h / 2.0); - draw_dab( + if effective_size < 0.5 { + return; + } + for band in -3i32..=3 { + let y = cy + band as f32 * effective_size * 0.09; + let phase = (cy * 0.07 + band as f32).sin() * effective_size * 0.05; + draw_line_segment( pixels, width, height, - cx + off_x, - cy + off_y, - effective_size, - hardness, + cx - effective_size * 0.72, + y + phase, + cx + effective_size * 0.72, + y - phase, + (effective_size * 0.035).max(0.45), + (effective_size * 0.02).max(0.3), color, - particle_opacity, + particle_opacity * (0.45 + (band.unsigned_abs() % 3) as f32 * 0.15), is_eraser, mask, ); @@ -3064,47 +3867,52 @@ pub fn draw_sketch_brush( mask: Option<&[u8]>, ) { let effective_size = size * pressure; + if effective_size < 0.5 { + return; + } + let previous = history.last().copied(); history.push((cx, cy)); - if history.len() > 30 { + if history.len() > 2 { history.remove(0); } - let particle_opacity = opacity * pressure * 2.0; - let threshold = effective_size * 3.0; - draw_dab( + draw_grain_brush( pixels, width, height, cx, cy, - effective_size * 0.2, - hardness, + effective_size.max(1.0), + 1.0, color, - particle_opacity, + opacity * pressure * 0.45, + hardness, is_eraser, mask, + 0.7, + false, ); - for &(hx, hy) in history.iter().rev().take(12) { + if let Some((hx, hy)) = previous { let dx = cx - hx; let dy = cy - hy; - let dist = (dx * dx + dy * dy).sqrt(); - if dist > 0.0 && dist < threshold { - let steps = (dist / 3.0).max(1.0) as u32; - for s in 0..=steps { - let t = s as f32 / steps as f32; - let px = cx + (hx - cx) * t; - let py = cy + (hy - cy) * t; - draw_dab( + let distance = (dx * dx + dy * dy).sqrt(); + if distance > f32::EPSILON { + let normal = (-dy / distance, dx / distance); + for lane in -1i32..=1 { + let offset = lane as f32 * effective_size * 0.12; + draw_line_segment( pixels, width, height, - px, - py, - 1.0, - hardness, + hx + normal.0 * offset, + hy + normal.1 * offset, + cx + normal.0 * offset, + cy + normal.1 * offset, + (effective_size * 0.12).max(0.45), + (effective_size * 0.08).max(0.35), color, - particle_opacity * (1.0 - dist / threshold), + opacity * pressure * (0.24 + (lane + 1) as f32 * 0.08), is_eraser, mask, ); @@ -3122,17 +3930,21 @@ pub fn draw_hatch_brush( cx: f32, cy: f32, size: f32, - _pressure: f32, + pressure: f32, color: [u8; 4], opacity: f32, hardness: f32, is_eraser: bool, mask: Option<&[u8]>, ) { - let r = size / 2.0; + if pressure <= 0.0 { + return; + } + let effective_size = size * pressure; + let r = effective_size / 2.0; let count = 5; for i in 0..count { - let off = (i as f32 - (count as f32 / 2.0)) * (size / count as f32); + let off = (i as f32 - (count as f32 / 2.0)) * (effective_size / count as f32); let x1 = cx - r + off; let y1 = cy - r - off; let x2 = cx + r + off; @@ -3147,7 +3959,17 @@ pub fn draw_hatch_brush( let d_dab = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt(); if d_dab < r { draw_dab( - pixels, width, height, px, py, 1.0, hardness, color, opacity, is_eraser, mask, + pixels, + width, + height, + px, + py, + 1.0, + hardness, + color, + opacity * pressure, + is_eraser, + mask, ); } } @@ -3178,6 +4000,13 @@ pub fn draw_specialized_stroke( color_variant: bool, variant_amount: f32, density: f32, + jitter_amount: f32, + scatter_amount: f32, + angle: f32, + roundness: f32, + rotation_random: f32, + drawing_angle: bool, + include_first_dab: bool, ) { if points.is_empty() { return; @@ -3189,40 +4018,26 @@ pub fn draw_specialized_stroke( reset_color_variant_walk(); } - let spacing = brush_spacing_pixels(size, spacing_ratio); + let spacing = brush_spacing_pixels(size, spacing_ratio).max(0.1); + let angle_tip = BrushTip { + angle, + roundness, + rotation_random, + drawing_angle, + ..Default::default() + }; - for i in 0..points.len() { - let (cx, cy, pressure) = points[i]; - let dx = if i < points.len() - 1 { - points[i + 1].0 - cx - } else if i > 0 { - cx - points[i - 1].0 - } else { - 0.0 - }; - let dy = if i < points.len() - 1 { - points[i + 1].1 - cy - } else if i > 0 { - cy - points[i - 1].1 - } else { - 0.0 - }; - let seg_angle = effective_dab_angle( - &BrushTip { - angle: 0.0, - drawing_angle: true, - rotation_random: 0.0, - ..Default::default() - }, - dx, - dy, - ); + if points.len() == 1 { + let (cx, cy, pressure) = points[0]; + let dab_angle = effective_dab_angle(&angle_tip, 0.0, 0.0); + let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); + let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); draw_brush_style_dab( pixels, width, height, - cx, - cy, + cx + jitter_x + scatter_x, + cy + jitter_y + scatter_y, size, pressure, color, @@ -3240,56 +4055,90 @@ pub fn draw_specialized_stroke( color_variant, variant_amount, density, - seg_angle, - 1.0, + dab_angle, + roundness, 0.0, false, ); + return; + } - if i < points.len() - 1 { - let (nx, ny, np) = points[i + 1]; - let dx = nx - cx; - let dy = ny - cy; - let dist = (dx * dx + dy * dy).sqrt(); - if dist == 0.0 { - continue; - } - let steps = (dist / spacing).max(1.0) as u32; - - for s in 1..=steps { - let t = s as f32 / steps as f32; - let ix = cx + dx * t; - let iy = cy + dy * t; - let ip = pressure + (np - pressure) * t; - draw_brush_style_dab( - pixels, - width, - height, - ix, - iy, - size, - ip, - color, - opacity, - brush_style, - is_eraser, - &mut sketch_history, - spray_particle_size, - spray_density, - hardness, - mask, - stroke_mask.as_deref_mut(), - bg_pixels, - opacity, - color_variant, - variant_amount, - density, - seg_angle, - 1.0, - 0.0, - false, - ); - } + for index in 0..points.len() - 1 { + let (cx, cy, pressure) = points[index]; + let (nx, ny, next_pressure) = points[index + 1]; + let dx = nx - cx; + let dy = ny - cy; + let distance = (dx * dx + dy * dy).sqrt(); + if index == 0 && (include_first_dab || distance <= f32::EPSILON) { + let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); + let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); + let dab_angle = effective_dab_angle(&angle_tip, dx, dy); + draw_brush_style_dab( + pixels, + width, + height, + cx + jitter_x + scatter_x, + cy + jitter_y + scatter_y, + size, + pressure, + color, + opacity, + brush_style, + is_eraser, + &mut sketch_history, + spray_particle_size, + spray_density, + hardness, + mask, + stroke_mask.as_deref_mut(), + bg_pixels, + opacity, + color_variant, + variant_amount, + density, + dab_angle, + roundness, + 0.0, + false, + ); + } + if distance <= f32::EPSILON { + continue; + } + let steps = (distance / spacing).ceil().max(1.0) as u32; + for step in 1..=steps { + let t = step as f32 / steps as f32; + let (jitter_x, jitter_y) = jitter_offset(jitter_amount, size); + let (scatter_x, scatter_y) = scatter_offset(scatter_amount, size); + let dab_angle = effective_dab_angle(&angle_tip, dx, dy); + draw_brush_style_dab( + pixels, + width, + height, + cx + dx * t + jitter_x + scatter_x, + cy + dy * t + jitter_y + scatter_y, + size, + pressure + (next_pressure - pressure) * t, + color, + opacity, + brush_style, + is_eraser, + &mut sketch_history, + spray_particle_size, + spray_density, + hardness, + mask, + stroke_mask.as_deref_mut(), + bg_pixels, + opacity, + color_variant, + variant_amount, + density, + dab_angle, + roundness, + 0.0, + false, + ); } } } @@ -3383,8 +4232,8 @@ fn alpha_blend(dst: [u8; 4], src: [u8; 4]) -> [u8; 4] { #[cfg(test)] mod watercolor_performance_tests { use super::{ - draw_dab, draw_watercolor_brush_with_rng, watercolor_detail_counts, - WATERCOLOR_MAX_DABS_PER_SAMPLE, + draw_blender_brush, draw_dab, draw_specialized_stroke, draw_watercolor_brush_with_rng, + mix_pigments, watercolor_detail_counts, BrushStyle, WATERCOLOR_MAX_DABS_PER_SAMPLE, }; use rand::{rngs::StdRng, SeedableRng}; @@ -3394,7 +4243,7 @@ mod watercolor_performance_tests { let mut rng = StdRng::seed_from_u64(0x0057_4154_4552); for size in [0.5, 1.0, 8.0, 24.0, 64.0, 128.0, 1024.0] { for _ in 0..1000 { - let (satellites, splatters, blooms) = watercolor_detail_counts(size, &mut rng); + let (satellites, splatters, blooms) = watercolor_detail_counts(size, 1.0, &mut rng); let total = 1 + satellites + splatters + blooms; assert!(satellites <= 2); assert!(splatters <= 4); @@ -3404,6 +4253,195 @@ mod watercolor_performance_tests { } } + /// Ensures no named specialized medium deposits paint at zero pressure. + #[test] + fn every_specialized_style_is_noop_at_zero_pressure() { + let styles = [ + BrushStyle::Noise, + BrushStyle::Texture, + BrushStyle::Spray, + BrushStyle::Pencil, + BrushStyle::Pen, + BrushStyle::InkPen, + BrushStyle::Calligraphy, + BrushStyle::Oil, + BrushStyle::Charcoal, + BrushStyle::Leaf, + BrushStyle::Rock, + BrushStyle::Meadow, + BrushStyle::Wood, + BrushStyle::Watercolor, + BrushStyle::Marker, + BrushStyle::Sketch, + BrushStyle::Hatch, + BrushStyle::Glow, + BrushStyle::Airbrush, + BrushStyle::Crayon, + BrushStyle::WetPaint, + BrushStyle::Clouds, + BrushStyle::Dirt, + BrushStyle::Tree, + BrushStyle::Bristle, + BrushStyle::Mixer, + BrushStyle::Blender, + BrushStyle::Star, + ]; + for style in styles { + let mut pixels = vec![0u8; 64 * 64 * 4]; + draw_specialized_stroke( + &mut pixels, + 64, + 64, + &[(32.0, 32.0, 0.0)], + style, + 24.0, + 0.6, + [200, 80, 20, 255], + 0.8, + 0.1, + false, + None, + 2.0, + 100, + None, + None, + None, + false, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + false, + true, + ); + assert!( + pixels.iter().all(|value| *value == 0), + "{style:?} painted at zero pressure" + ); + } + } + + /// Confirms the Blender redistributes existing color without changing a uniform field. + #[test] + fn blender_preserves_uniform_color_and_transparency() { + let mut uniform = [90, 120, 150, 255].repeat(32 * 32); + let original = uniform.clone(); + draw_blender_brush(&mut uniform, 32, 32, 16.0, 16.0, 18.0, 1.0, 1.0, None); + assert_eq!(uniform, original); + + let mut transparent = vec![0u8; 32 * 32 * 4]; + draw_blender_brush(&mut transparent, 32, 32, 16.0, 16.0, 18.0, 1.0, 1.0, None); + assert!(transparent.iter().all(|value| *value == 0)); + draw_blender_brush( + &mut transparent, + 32, + 32, + 200.0, + -100.0, + 18.0, + 1.0, + 1.0, + None, + ); + } + + /// Ensures elliptical dabs accumulate into the one-byte stroke mask without RGBA indexing. + #[test] + fn rotated_dab_uses_single_channel_stroke_mask() { + let mut pixels = vec![0u8; 32 * 32 * 4]; + let mut stroke_mask = vec![0u8; 32 * 32]; + draw_specialized_stroke( + &mut pixels, + 32, + 32, + &[(16.0, 16.0, 1.0)], + BrushStyle::Round, + 12.0, + 0.8, + [200, 80, 20, 255], + 0.7, + 0.1, + false, + None, + 2.0, + 100, + None, + Some(&mut stroke_mask), + None, + false, + 0.0, + 1.0, + 0.0, + 0.0, + 0.4, + 0.35, + 0.0, + false, + true, + ); + assert!(stroke_mask.iter().any(|alpha| *alpha > 0)); + } + + /// Ensures overlapping rotated pen dabs cannot exceed the configured stroke opacity. + #[test] + fn rotated_pen_respects_stroke_opacity_cap() { + let background = vec![0u8; 32 * 32 * 4]; + let mut pixels = background.clone(); + let mut stroke_mask = vec![0u8; 32 * 32]; + for _ in 0..3 { + draw_specialized_stroke( + &mut pixels, + 32, + 32, + &[(16.0, 16.0, 1.0)], + BrushStyle::Pen, + 12.0, + 1.0, + [200, 80, 20, 255], + 0.5, + 0.1, + false, + None, + 2.0, + 100, + None, + Some(&mut stroke_mask), + Some(&background), + false, + 0.0, + 1.0, + 0.0, + 0.0, + 0.25, + 0.3, + 0.0, + false, + true, + ); + } + let center = (16 * 32 + 16) * 4; + assert!((126..=128).contains(&pixels[center + 3])); + } + + /// Verifies optical-density mixing yields a dark subtractive secondary instead of RGB averaging. + #[test] + fn pigment_mix_is_subtractive_and_bounded() { + let mixed = mix_pigments([255, 0, 0], [0, 0, 255], 0.5); + assert!(mixed[0] > mixed[1] && mixed[2] > mixed[1]); + assert!(mixed[0] < 128 && mixed[2] < 128); + assert_eq!( + mix_pigments([10, 20, 30], [200, 210, 220], 0.0), + [10, 20, 30] + ); + assert_eq!( + mix_pigments([10, 20, 30], [200, 210, 220], 1.0), + [200, 210, 220] + ); + } + /// Reports p50/p95/max raster time for one round dab and the bounded watercolor sample. #[test] #[ignore = "diagnostic benchmark; run with --ignored --nocapture"] @@ -3443,6 +4481,7 @@ mod watercolor_performance_tests { 0.5, false, None, + 1.0, &mut rng, ); let watercolor = started.elapsed().as_secs_f64() * 1000.0; diff --git a/hcie-engine-api/src/dynamic_loader.rs b/hcie-engine-api/src/dynamic_loader.rs index 7d11b7e..1f39ab6 100644 --- a/hcie-engine-api/src/dynamic_loader.rs +++ b/hcie-engine-api/src/dynamic_loader.rs @@ -162,8 +162,8 @@ pub fn draw_brush_stroke( color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density, - drawing_angle: false, - rotation_random: 0.0, + drawing_angle: tip.drawing_angle, + rotation_random: tip.rotation_random, }; hcie_draw::draw_brush_stroke( layer, @@ -198,6 +198,13 @@ pub fn draw_specialized_stroke( color_variant: bool, variant_amount: f32, density: f32, + jitter_amount: f32, + scatter_amount: f32, + angle: f32, + roundness: f32, + rotation_random: f32, + drawing_angle: bool, + include_first_dab: bool, ) { let style = map_brush_style(brush_style); hcie_brush_engine::draw_specialized_stroke( @@ -221,6 +228,13 @@ pub fn draw_specialized_stroke( color_variant, variant_amount, density, + jitter_amount, + scatter_amount, + angle, + roundness, + rotation_random, + drawing_angle, + include_first_dab, ) } diff --git a/hcie-engine-api/src/stroke_brush.rs b/hcie-engine-api/src/stroke_brush.rs index b42bafd..71ec96d 100644 --- a/hcie-engine-api/src/stroke_brush.rs +++ b/hcie-engine-api/src/stroke_brush.rs @@ -24,6 +24,20 @@ use crate::Engine; use hcie_protocol::{BrushStyle, BrushTip}; impl Engine { + /// Returns a conservative dirty radius including procedural and preset offsets. + fn brush_dirty_radius(tip: &BrushTip) -> f32 { + let procedural = match tip.style { + BrushStyle::Star | BrushStyle::Spray => 1.8, + BrushStyle::Meadow | BrushStyle::Leaf => 3.0, + BrushStyle::Clouds | BrushStyle::Tree => 2.5, + BrushStyle::Watercolor => 1.4, + BrushStyle::WetPaint => 1.2, + _ => 1.0, + }; + let offset = tip.jitter_amount.max(0.0) * 0.5 + tip.scatter_amount.max(0.0) * 0.3; + (tip.size * (procedural + offset)).max(2.0) + } + /// Interpolate a brush property along the stroke using accumulated distance. /// /// `t` is the normalized stroke progress (0.0 at start, 1.0 at end). If the @@ -137,20 +151,19 @@ impl Engine { tip.color_variant, tip.variant_amount, tip.density, + tip.jitter_amount, + tip.scatter_amount, + tip.angle, + tip.roundness, + tip.rotation_random, + tip.drawing_angle, + false, ); layer.dirty = true; if !layer.effects.is_empty() || !layer.styles.is_empty() { *layer.effects_cache.lock().unwrap() = None; } - let dirty_r = match tip.style { - // Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint. - BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8, - // Meadow/Leaf draw blades/leaves upward from center — asymmetric extent. - BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0, - // Clouds/Tree scatter particles in a wide area. - BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5, - _ => tip.size.max(2.0), - }; + let dirty_r = Self::brush_dirty_radius(&tip); self.document.expand_dirty(lx as u32, ly as u32, dirty_r); self.document.expand_dirty(x as u32, y as u32, dirty_r); self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r); @@ -276,6 +289,13 @@ impl Engine { tip.color_variant, tip.variant_amount, tip.density, + tip.jitter_amount, + tip.scatter_amount, + tip.angle, + tip.roundness, + tip.rotation_random, + tip.drawing_angle, + true, ); layer.dirty = true; } else { @@ -297,15 +317,7 @@ impl Engine { } self.document.composite_dirty = true; self.document.modified = true; - let dirty_r = match tip.style { - // Star/Spray: spread was reduced to 0.65× so 1.8× covers the footprint. - BrushStyle::Star | BrushStyle::Spray => tip.size * 1.8, - // Meadow/Leaf draw blades/leaves upward from center — asymmetric extent. - BrushStyle::Meadow | BrushStyle::Leaf => tip.size * 3.0, - // Clouds/Tree scatter particles in a wide area. - BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.5, - _ => tip.size.max(2.0), - }; + let dirty_r = Self::brush_dirty_radius(&tip); for &(px, py, _) in points { if px >= 0.0 && py >= 0.0 @@ -322,7 +334,8 @@ impl Engine { "Brush Stroke ({})", crate::stroke_brush::brush_style_label(tip.style) ); - self.document.push_draw_snapshot(layer_idx, before, None, desc); + self.document + .push_draw_snapshot(layer_idx, before, None, desc); } } @@ -474,6 +487,11 @@ impl Engine { | BrushStyle::WetPaint | BrushStyle::Leaf | BrushStyle::Mixer + | BrushStyle::Blender + | BrushStyle::Noise + | BrushStyle::Texture + | BrushStyle::Pen + | BrushStyle::InkPen ) } } @@ -519,3 +537,21 @@ pub fn brush_style_label(style: BrushStyle) -> &'static str { BrushStyle::Bitmap => "Bitmap", } } + +#[cfg(test)] +mod dirty_radius_tests { + use super::*; + + /// Ensures partial uploads include procedural watercolor spread and preset offsets. + #[test] + fn watercolor_dirty_radius_covers_jitter_and_scatter() { + let tip = BrushTip { + style: BrushStyle::Watercolor, + size: 40.0, + jitter_amount: 0.4, + scatter_amount: 0.9, + ..BrushTip::default() + }; + assert!((Engine::brush_dirty_radius(&tip) - 74.8).abs() < 0.001); + } +} diff --git a/hcie-engine-api/tests/vector_creation_history.rs b/hcie-engine-api/tests/vector_creation_history.rs index cea3a59..626fa40 100644 --- a/hcie-engine-api/tests/vector_creation_history.rs +++ b/hcie-engine-api/tests/vector_creation_history.rs @@ -43,7 +43,7 @@ fn first_vector_shape_is_one_atomic_layer_transaction() { assert_eq!(engine.history_len(), history_before + 1); assert_eq!( engine.history_description(history_before).as_deref(), - Some("Add Vector Shape") + Some("Add Vector Shape (Rect)") ); assert_eq!( engine diff --git a/hcie-iced-app/crates/hcie-digital-brushes/Cargo.toml b/hcie-iced-app/crates/hcie-digital-brushes/Cargo.toml new file mode 100644 index 0000000..98e60af --- /dev/null +++ b/hcie-iced-app/crates/hcie-digital-brushes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hcie-digital-brushes" +version.workspace = true +edition.workspace = true + +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-iced-app/crates/hcie-digital-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-digital-brushes/src/lib.rs new file mode 100644 index 0000000..ea330ca --- /dev/null +++ b/hcie-iced-app/crates/hcie-digital-brushes/src/lib.rs @@ -0,0 +1,151 @@ +//! Digital-native raster, blend, texture, FX, and vector brush descriptors. +//! +//! **Purpose:** Separates digital-only tools from traditional-media catalogs. **Logic & Workflow:** +//! Raster tools return engine `BrushPreset` values; vector tools use a distinct descriptor so they +//! are never misrouted through the raster brush engine. **Side Effects / Dependencies:** None. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; + +/// Vector-path brush metadata consumed by GUI vector tooling rather than raster drawing. +#[derive(Debug, Clone, PartialEq)] +pub struct VectorBrushPreset { + /// Stable preset identifier. + pub id: &'static str, + /// User-facing preset name. + pub name: &'static str, + /// Default path stroke width. + pub width: f32, + /// Whether pressure changes path width. + pub pressure_width: bool, + /// Input-path smoothing strength in `0..=1`. + pub smoothing: f32, +} + +/// Builds one digital raster preset. +fn preset(id: &str, name: &str, category: &str, tip: BrushTip) -> BrushPreset { + BrushPreset { + id: id.into(), + name: name.into(), + category: category.into(), + style: tip.style, + tip, + pressure_size: true, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + } +} + +/// Returns digital raster, blend, texture, and FX presets. +pub fn digital_presets() -> Vec { + vec![ + preset( + "digital_pixel", + "Pixel / Raster Brush", + "Digital Raster", + BrushTip { + style: BrushStyle::HardRound, + size: 8.0, + opacity: 1.0, + hardness: 1.0, + spacing: 0.05, + ..BrushTip::default() + }, + ), + preset( + "digital_smudge", + "Smudge / Blender", + "Digital Blend", + BrushTip { + style: BrushStyle::Blender, + size: 40.0, + opacity: 0.65, + hardness: 0.0, + spacing: 0.05, + ..BrushTip::default() + }, + ), + preset( + "digital_stamp", + "Stamp / Texture", + "Digital Texture", + BrushTip { + style: BrushStyle::Texture, + size: 48.0, + opacity: 0.9, + hardness: 0.8, + spacing: 0.35, + jitter_amount: 0.3, + scatter_amount: 0.6, + rotation_random: 0.5, + density: 0.7, + ..BrushTip::default() + }, + ), + preset( + "digital_fx_glow", + "FX Glow", + "Digital FX", + BrushTip { + style: BrushStyle::Glow, + size: 30.0, + opacity: 0.8, + hardness: 0.0, + spacing: 0.04, + ..BrushTip::default() + }, + ), + preset( + "digital_fx_particles", + "FX Particles", + "Digital FX", + BrushTip { + style: BrushStyle::Spray, + size: 60.0, + opacity: 0.7, + hardness: 0.5, + spacing: 0.2, + spray_particle_size: 2.0, + spray_density: 120, + ..BrushTip::default() + }, + ), + ] +} + +/// Returns vector brush descriptors for the vector path subsystem. +pub fn vector_brush_presets() -> Vec { + vec![ + VectorBrushPreset { + id: "digital_vector_clean", + name: "Clean Vector Brush", + width: 4.0, + pressure_width: true, + smoothing: 0.65, + }, + VectorBrushPreset { + id: "digital_vector_ink", + name: "Vector Ink Brush", + width: 7.0, + pressure_width: true, + smoothing: 0.35, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::{digital_presets, vector_brush_presets}; + + /// Ensures vector tools remain separate from raster brush presets. + #[test] + fn digital_catalog_separates_raster_and_vector_tools() { + assert_eq!(digital_presets().len(), 5); + assert_eq!(vector_brush_presets().len(), 2); + assert!(digital_presets() + .iter() + .all(|preset| preset.style == preset.tip.style)); + } +} diff --git a/hcie-iced-app/crates/hcie-dry-media-brushes/Cargo.toml b/hcie-iced-app/crates/hcie-dry-media-brushes/Cargo.toml new file mode 100644 index 0000000..e555eb6 --- /dev/null +++ b/hcie-iced-app/crates/hcie-dry-media-brushes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hcie-dry-media-brushes" +version.workspace = true +edition.workspace = true + +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs new file mode 100644 index 0000000..65075de --- /dev/null +++ b/hcie-iced-app/crates/hcie-dry-media-brushes/src/lib.rs @@ -0,0 +1,261 @@ +//! Traditional dry-media presets for graphite, charcoal, pastel, chalk, and colored pencil. +//! +//! **Purpose:** Keeps named traditional-media catalog data outside GUI and engine crates. +//! **Logic & Workflow:** Each preset maps a physical medium to an engine brush style plus tip and +//! pressure dynamics. **Side Effects / Dependencies:** Depends only on the public engine API. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; + +/// Builds one engine brush tip with shared safe defaults. +fn tip(style: BrushStyle, size: f32, opacity: f32, hardness: f32, spacing: f32) -> BrushTip { + BrushTip { + style, + size, + opacity, + hardness, + spacing, + ..BrushTip::default() + } +} + +/// Builds one named dry-media preset. +#[allow(clippy::too_many_arguments)] +fn preset( + id: &str, + name: &str, + category: &str, + brush_tip: BrushTip, + pressure_size: bool, + pressure_opacity: bool, +) -> BrushPreset { + BrushPreset { + id: id.into(), + name: name.into(), + category: category.into(), + style: brush_tip.style, + tip: brush_tip, + pressure_size, + pressure_opacity, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + } +} + +/// Returns all dry and graphic media from the traditional-media reference. +pub fn dry_media_presets() -> Vec { + vec![ + preset( + "dry_graphite_9h", + "Graphite Pencil 9H", + "Graphite", + tip(BrushStyle::Pencil, 2.0, 0.48, 1.0, 0.03), + true, + true, + ), + preset( + "dry_graphite_hb", + "Graphite Pencil HB", + "Graphite", + tip(BrushStyle::Pencil, 4.0, 0.68, 0.82, 0.04), + true, + true, + ), + preset( + "dry_graphite_9b", + "Graphite Pencil 9B", + "Graphite", + tip(BrushStyle::Pencil, 8.0, 0.86, 0.5, 0.05), + true, + true, + ), + preset( + "dry_carpenter", + "Carpenter Pencil", + "Graphite", + BrushTip { + angle: 0.15, + roundness: 0.22, + drawing_angle: true, + ..tip(BrushStyle::Pencil, 18.0, 0.88, 0.95, 0.04) + }, + true, + true, + ), + preset( + "dry_powdered_graphite", + "Powdered Graphite", + "Graphite", + BrushTip { + density: 0.55, + ..tip(BrushStyle::Airbrush, 90.0, 0.2, 0.0, 0.03) + }, + false, + true, + ), + preset( + "dry_compressed_charcoal", + "Compressed Charcoal", + "Charcoal", + BrushTip { + density: 1.25, + ..tip(BrushStyle::Charcoal, 20.0, 0.95, 0.9, 0.08) + }, + true, + true, + ), + preset( + "dry_vine_charcoal", + "Willow / Vine Charcoal", + "Charcoal", + BrushTip { + density: 0.7, + ..tip(BrushStyle::Charcoal, 28.0, 0.65, 0.25, 0.1) + }, + true, + true, + ), + preset( + "dry_charcoal_pencil", + "Charcoal Pencil", + "Charcoal", + BrushTip { + density: 0.9, + ..tip(BrushStyle::Charcoal, 6.0, 0.85, 0.8, 0.04) + }, + true, + true, + ), + preset( + "dry_soft_pastel", + "Soft Pastel", + "Pastels & Chalks", + BrushTip { + density: 0.75, + ..tip(BrushStyle::Crayon, 30.0, 0.8, 0.3, 0.08) + }, + true, + true, + ), + preset( + "dry_hard_pastel", + "Hard Pastel / Conte", + "Pastels & Chalks", + BrushTip { + density: 1.2, + ..tip(BrushStyle::Crayon, 12.0, 0.9, 0.8, 0.06) + }, + true, + true, + ), + preset( + "dry_oil_pastel", + "Oil Pastel", + "Pastels & Chalks", + BrushTip { + density: 1.4, + ..tip(BrushStyle::Crayon, 24.0, 1.0, 0.65, 0.04) + }, + true, + true, + ), + preset( + "dry_water_pastel", + "Water-Soluble Pastel", + "Pastels & Chalks", + tip(BrushStyle::Crayon, 20.0, 0.8, 0.5, 0.07), + true, + true, + ), + preset( + "dry_water_pastel_wet", + "Water-Soluble Pastel Wash", + "Pastels & Chalks", + tip(BrushStyle::Watercolor, 35.0, 0.35, 0.0, 0.06), + false, + true, + ), + preset( + "dry_pan_pastel", + "PanPastel Sponge", + "Pastels & Chalks", + BrushTip { + density: 0.65, + ..tip(BrushStyle::Airbrush, 70.0, 0.3, 0.0, 0.03) + }, + false, + true, + ), + preset( + "dry_chalk_sanguine", + "White Chalk / Sanguine", + "Pastels & Chalks", + tip(BrushStyle::Crayon, 16.0, 0.85, 0.7, 0.08), + true, + true, + ), + preset( + "dry_wax_pencil", + "Wax-Based Colored Pencil", + "Colored Pencils", + BrushTip { + density: 1.2, + ..tip(BrushStyle::Pencil, 7.0, 0.85, 0.65, 0.04) + }, + true, + true, + ), + preset( + "dry_oil_pencil", + "Oil-Based Colored Pencil", + "Colored Pencils", + BrushTip { + density: 1.0, + ..tip(BrushStyle::Pencil, 4.0, 0.95, 0.9, 0.03) + }, + true, + true, + ), + preset( + "dry_watercolor_pencil", + "Watercolor Pencil", + "Colored Pencils", + tip(BrushStyle::Pencil, 6.0, 0.8, 0.7, 0.04), + true, + true, + ), + preset( + "dry_watercolor_pencil_wet", + "Watercolor Pencil Wash", + "Colored Pencils", + tip(BrushStyle::Watercolor, 24.0, 0.3, 0.05, 0.06), + false, + true, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::dry_media_presets; + use std::collections::HashSet; + + /// Ensures the reference inventory remains complete and uniquely addressable. + #[test] + fn dry_media_catalog_is_complete_and_unique() { + let presets = dry_media_presets(); + assert_eq!(presets.len(), 19); + assert_eq!( + presets + .iter() + .map(|preset| &preset.id) + .collect::>() + .len(), + 19 + ); + assert!(presets + .iter() + .all(|preset| preset.style == preset.tip.style)); + } +} diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index 28c46df..84651e8 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -19,6 +19,10 @@ path = "src/main.rs" hcie-build-info = { workspace = true } hcie-engine-api = { path = "../../../hcie-engine-api" } hcie-watercolor-brushes = { path = "../hcie-watercolor-brushes" } +hcie-dry-media-brushes = { path = "../hcie-dry-media-brushes" } +hcie-ink-brushes = { path = "../hcie-ink-brushes" } +hcie-paint-brushes = { path = "../hcie-paint-brushes" } +hcie-digital-brushes = { path = "../hcie-digital-brushes" } iced-panel-adapter = { path = "../iced-panel-adapter" } iced = { workspace = true } env_logger = { workspace = true } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs index ad3cc72..4dbb668 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs @@ -13,9 +13,12 @@ use crate::panels::styles; use crate::theme::ThemeColors; use crate::widgets::plain_slider::plain_slider; use hcie_engine_api::BrushStyle; -use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text}; +use iced::widget::{ + button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text, +}; use iced::{Element, Length}; -use std::sync::OnceLock; +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; /// One built-in brush style exposed by the engine. struct BrushStyleEntry { @@ -209,8 +212,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[ }, ]; -/// Cached brush preview images: (style_index, pixels). -static BRUSH_PREVIEW_CACHE: OnceLock)>> = OnceLock::new(); +/// Cached brush preview handles keyed by engine style index. +static BRUSH_PREVIEW_CACHE: OnceLock>> = + OnceLock::new(); /// Thumbnail size in pixels. const THUMB_SIZE: u32 = 64; @@ -274,6 +278,18 @@ fn preset_style(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle { } } +/// Maps a named media preset category into the panel's compact tab model. +/// +/// **Arguments:** `category` is supplied by a media preset crate. **Returns:** The matching panel +/// filter. **Side Effects / Dependencies:** None. +fn media_preset_category(category: &str) -> BrushCategory { + match category { + "Opaque Watermedia" | "Oils" | "Acrylics" | "Tempera" => BrushCategory::Painting, + "Digital Blend" | "Digital Texture" | "Digital FX" => BrushCategory::Effects, + _ => BrushCategory::Drawing, + } +} + /// Generate a brush stroke preview as RGBA pixels. /// /// Draws a sine-wave stroke with varying thickness to show the brush characteristics. @@ -384,26 +400,20 @@ fn generate_brush_preview(style: BrushStyle) -> Vec { /// Get or generate brush preview for a style. fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle { let style_idx = style as usize; - - // Check cache - if let Some(cache) = BRUSH_PREVIEW_CACHE.get() { - if let Some((_, pixels)) = cache.iter().find(|(idx, _)| *idx == style_idx) { - return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels.clone()); - } + let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(handle) = cache.get(&style_idx) { + return handle.clone(); } - - // Generate preview - let pixels = generate_brush_preview(style); - - // Store in cache (ignore error if already set) - if let Some(cache) = BRUSH_PREVIEW_CACHE.get() { - let mut new_cache = cache.clone(); - new_cache.push((style_idx, pixels.clone())); - // Can't replace OnceLock, so we just use the local copy - return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels); - } - - iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels) + let handle = iced::widget::image::Handle::from_rgba( + THUMB_SIZE, + THUMB_SIZE, + generate_brush_preview(style), + ); + cache.insert(style_idx, handle.clone()); + handle } /// Build the brushes panel with visual thumbnails. @@ -424,7 +434,11 @@ pub fn view( (BrushCategory::All, "All brushes", "icons/panel-brush.svg"), (BrushCategory::Drawing, "Drawing", "icons/pen.svg"), (BrushCategory::Painting, "Painting", "icons/brush.svg"), - (BrushCategory::Watercolor, "Watercolor", "icons/watercolor.svg"), + ( + BrushCategory::Watercolor, + "Watercolor", + "icons/watercolor.svg", + ), (BrushCategory::Effects, "Effects", "icons/glow.svg"), (BrushCategory::Custom, "Custom", "icons/star.svg"), (BrushCategory::Imported, "Imported", "icons/import_abr.svg"), @@ -556,7 +570,9 @@ pub fn view( let mut wc_row = row![].spacing(4); for (idx, preset) in wc_presets.iter().enumerate() { let is_selected = preset_style(preset.style) == current_style - && active_brush_preset.as_ref().map_or(false, |p| p.id == preset.id); + && active_brush_preset + .as_ref() + .map_or(false, |p| p.id == preset.id); let c = colors; let wc_color: [u8; 3] = match preset.id.as_str() { "wc_wash" => [100, 150, 200], @@ -571,11 +587,8 @@ pub fn view( }; let splat_pixels = hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color); - let preview = iced::widget::image::Handle::from_rgba( - THUMB_SIZE, - THUMB_SIZE, - splat_pixels, - ); + let preview = + iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, splat_pixels); let thumb = container(iced::widget::image(preview).width(48).height(48)) .width(56) .height(56) @@ -623,6 +636,64 @@ pub fn view( } } + // Traditional and digital media catalogs live in independent crates and expose only public + // engine-API preset data. This keeps catalog growth out of the GUI and engine internals. + let mut media_presets = hcie_dry_media_brushes::dry_media_presets(); + media_presets.extend(hcie_ink_brushes::ink_presets()); + media_presets.extend(hcie_paint_brushes::paint_presets()); + media_presets.extend(hcie_digital_brushes::digital_presets()); + let filtered_media: Vec<_> = media_presets + .into_iter() + .filter(|preset| { + active_category == BrushCategory::All + || active_category == media_preset_category(&preset.category) + }) + .collect(); + let mut media_rows = column![].spacing(4); + let mut media_row = row![].spacing(4); + for (idx, preset) in filtered_media.iter().enumerate() { + let style = preset_style(preset.style); + let selected = style == current_style + && active_brush_preset.is_some_and(|active| active.id == preset.id); + let preview = get_brush_preview(style); + let preset_message = preset.clone(); + let c = colors; + let cell = column![ + container(iced::widget::image(preview).width(48).height(48)) + .width(56) + .height(56) + .center_x(48) + .center_y(48) + .style(move |_theme| styles::raised_card(c, selected)), + container(text(preset.name.clone()).size(9)) + .width(Length::Fill) + .center_x(Length::Fill), + ] + .spacing(2) + .align_x(iced::Alignment::Center); + media_row = media_row.push( + button(cell) + .on_press(Message::BrushPresetSelected(preset_message)) + .padding(2) + .style(move |_theme, status| iced::widget::button::Style { + background: Some(iced::Background::Color( + if status == iced::widget::button::Status::Hovered { + c.bg_hover + } else { + iced::Color::TRANSPARENT + }, + )), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }), + ); + if (idx + 1) % 2 == 0 || idx == filtered_media.len() - 1 { + media_rows = media_rows.push(media_row); + media_row = row![].spacing(4); + } + } + let mut imported_rows = column![].spacing(3); if matches!( active_category, @@ -751,7 +822,8 @@ pub fn view( let panel = column![ tabs, horizontal_rule(1), - scrollable(column![grid_rows, wc_rows, imported_rows].spacing(6)).height(Length::Fill), + scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(6)) + .height(Length::Fill), horizontal_rule(1), row![button(text("Import ABR").size(10)) .on_press(Message::BrushImportAbr) @@ -796,6 +868,7 @@ pub fn view( #[cfg(test)] mod tests { use super::{category_matches_style, BrushCategory, BRUSH_STYLES}; + use std::collections::HashSet; #[test] fn catalog_contains_every_engine_brush_style() { @@ -821,4 +894,23 @@ mod tests { BrushCategory::Imported )); } + + /// Locks the reference-derived crate inventory and prevents preset ID collisions. + #[test] + fn media_crates_expose_complete_unique_catalog() { + let mut presets = hcie_dry_media_brushes::dry_media_presets(); + presets.extend(hcie_ink_brushes::ink_presets()); + presets.extend(hcie_paint_brushes::paint_presets()); + presets.extend(hcie_digital_brushes::digital_presets()); + presets.extend(hcie_watercolor_brushes::watercolor_presets()); + assert_eq!(presets.len(), 47); + assert_eq!( + presets + .iter() + .map(|preset| preset.id.as_str()) + .collect::>() + .len(), + presets.len() + ); + } } diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs index aa6edd1..fb2e00d 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/menus.rs @@ -342,11 +342,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec { MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste), MenuItem::command("Clear", MenuCommand::ClearPixels), MenuItem::separator(), - MenuItem::command_with_shortcut( - "Paste Special", - "", - MenuCommand::PasteSpecial, - ), + MenuItem::command_with_shortcut("Paste Special", "", MenuCommand::PasteSpecial), MenuItem::command_with_shortcut( "Paste as New Layer", "Ctrl+Shift+V", @@ -1281,6 +1277,7 @@ mod tests { "Edit/Paste", "Edit/Clear", "Edit/Paste Special", + "Edit/Paste as New Layer", "Edit/Fill...", "Edit/Stroke...", "Edit/Free Transform", diff --git a/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs b/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs index 092dc3d..956b725 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/tests/feature_scorecard.rs @@ -92,15 +92,19 @@ fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() { let app = source("src/app.rs"); let canvas = source("src/canvas/mod.rs"); let shader = source("src/canvas/shader_canvas.rs"); + let canvas_shader = source("src/canvas/canvas.wgsl"); let state = source("src/selection/state.rs"); let clipboard = source("src/selection/clipboard.rs"); let crop = source("src/selection/crop.rs"); let raster = source("src/raster.rs"); assert!( - state.contains("pub fn selected_spans") - && canvas.contains("selection_fill_spans") - && canvas.contains("Fill exact mask runs"), + state.contains("pub fn encode_selection_texture") + && shader.contains("selection_overlay_uses_one_texture_sample") + && canvas_shader + .matches("textureSample(selection_texture") + .count() + == 1, "{SELECTION_IRREGULAR_EDGES}" ); assert!( diff --git a/hcie-iced-app/crates/hcie-ink-brushes/Cargo.toml b/hcie-iced-app/crates/hcie-ink-brushes/Cargo.toml new file mode 100644 index 0000000..5db9294 --- /dev/null +++ b/hcie-iced-app/crates/hcie-ink-brushes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hcie-ink-brushes" +version.workspace = true +edition.workspace = true + +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-iced-app/crates/hcie-ink-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-ink-brushes/src/lib.rs new file mode 100644 index 0000000..5064fdc --- /dev/null +++ b/hcie-iced-app/crates/hcie-ink-brushes/src/lib.rs @@ -0,0 +1,156 @@ +//! Marker and inking-instrument presets. +//! +//! **Purpose:** Models felt, brush, nib, technical, and pigment ink tools as engine presets. +//! **Logic & Workflow:** Nib geometry is represented by angle, roundness, hardness, and pressure +//! dynamics while liquid markers use controlled opacity buildup. **Side Effects:** None. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; + +/// Builds one configured ink tip. +fn tip(style: BrushStyle, size: f32, opacity: f32, hardness: f32, spacing: f32) -> BrushTip { + BrushTip { + style, + size, + opacity, + hardness, + spacing, + ..BrushTip::default() + } +} + +/// Builds one ink preset with matching style metadata. +fn preset( + id: &str, + name: &str, + category: &str, + tip: BrushTip, + size: bool, + opacity: bool, +) -> BrushPreset { + BrushPreset { + id: id.into(), + name: name.into(), + category: category.into(), + style: tip.style, + tip, + pressure_size: size, + pressure_opacity: opacity, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + } +} + +/// Returns all marker and inking instruments from the media reference. +pub fn ink_presets() -> Vec { + vec![ + preset( + "ink_alcohol_marker", + "Alcohol Marker", + "Markers", + BrushTip { + angle: 0.2, + roundness: 0.38, + ..tip(BrushStyle::Marker, 24.0, 0.55, 0.75, 0.04) + }, + false, + true, + ), + preset( + "ink_water_marker", + "Water-Based Marker", + "Markers", + BrushTip { + roundness: 0.55, + ..tip(BrushStyle::Marker, 18.0, 0.45, 0.55, 0.05) + }, + false, + true, + ), + preset( + "ink_acrylic_marker", + "Acrylic Paint Marker", + "Markers", + BrushTip { + roundness: 0.7, + ..tip(BrushStyle::Marker, 16.0, 1.0, 0.95, 0.03) + }, + true, + false, + ), + preset( + "ink_brush_pen", + "Brush Pen", + "Markers", + BrushTip { + angle: std::f32::consts::FRAC_PI_4, + roundness: 0.2, + drawing_angle: true, + ..tip(BrushStyle::Calligraphy, 12.0, 1.0, 0.9, 0.03) + }, + true, + true, + ), + preset( + "ink_indian", + "Indian / Drawing Ink", + "Inking Instruments", + tip(BrushStyle::InkPen, 8.0, 1.0, 0.98, 0.03), + true, + true, + ), + preset( + "ink_dip_nib", + "Dip / Nib Pen", + "Inking Instruments", + BrushTip { + angle: std::f32::consts::FRAC_PI_4, + roundness: 0.12, + ..tip(BrushStyle::Calligraphy, 7.0, 1.0, 0.95, 0.02) + }, + true, + true, + ), + preset( + "ink_technical", + "Technical Pen", + "Inking Instruments", + tip(BrushStyle::Pen, 2.0, 1.0, 1.0, 0.02), + false, + false, + ), + preset( + "ink_fineliner", + "Fineliner", + "Inking Instruments", + tip(BrushStyle::InkPen, 3.0, 1.0, 0.95, 0.03), + false, + false, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::ink_presets; + use std::collections::HashSet; + + /// Ensures every referenced ink medium has one stable unique preset. + #[test] + fn ink_catalog_is_complete_and_unique() { + let presets = ink_presets(); + assert_eq!(presets.len(), 8); + assert_eq!( + presets + .iter() + .map(|preset| &preset.id) + .collect::>() + .len(), + 8 + ); + assert!(presets + .iter() + .all(|preset| preset.style == preset.tip.style)); + } +} diff --git a/hcie-iced-app/crates/hcie-paint-brushes/Cargo.toml b/hcie-iced-app/crates/hcie-paint-brushes/Cargo.toml new file mode 100644 index 0000000..85ba3d0 --- /dev/null +++ b/hcie-iced-app/crates/hcie-paint-brushes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hcie-paint-brushes" +version.workspace = true +edition.workspace = true + +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-iced-app/crates/hcie-paint-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-paint-brushes/src/lib.rs new file mode 100644 index 0000000..18621cd --- /dev/null +++ b/hcie-iced-app/crates/hcie-paint-brushes/src/lib.rs @@ -0,0 +1,144 @@ +//! Opaque watermedia, oil, acrylic, and tempera presets. +//! +//! **Purpose:** Provides named wet-media tools not covered by the dedicated watercolor crate. +//! **Logic & Workflow:** Viscosity and body are approximated through style, hardness, density, +//! spacing, and pressure behavior consumed by the public engine API. **Side Effects:** None. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; + +/// Builds one wet-media tip with safe defaults. +fn tip(style: BrushStyle, size: f32, opacity: f32, hardness: f32, spacing: f32) -> BrushTip { + BrushTip { + style, + size, + opacity, + hardness, + spacing, + ..BrushTip::default() + } +} + +/// Builds one paint preset. +fn preset(id: &str, name: &str, category: &str, tip: BrushTip, opacity: bool) -> BrushPreset { + BrushPreset { + id: id.into(), + name: name.into(), + category: category.into(), + style: tip.style, + tip, + pressure_size: true, + pressure_opacity: opacity, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + } +} + +/// Returns opaque watermedia, oil, acrylic, and tempera tools. +pub fn paint_presets() -> Vec { + vec![ + preset( + "paint_gouache", + "Gouache", + "Opaque Watermedia", + BrushTip { + density: 1.25, + roundness: 0.75, + ..tip(BrushStyle::Marker, 36.0, 0.95, 0.75, 0.03) + }, + false, + ), + preset( + "paint_acryla_gouache", + "Acryla Gouache", + "Opaque Watermedia", + BrushTip { + density: 1.4, + roundness: 0.7, + ..tip(BrushStyle::Marker, 30.0, 1.0, 0.9, 0.03) + }, + false, + ), + preset( + "paint_traditional_oil", + "Traditional Oil", + "Oils", + BrushTip { + density: 1.35, + drawing_angle: true, + ..tip(BrushStyle::Oil, 38.0, 0.95, 0.85, 0.04) + }, + false, + ), + preset( + "paint_water_mixable_oil", + "Water-Mixable Oil", + "Oils", + BrushTip { + density: 0.9, + drawing_angle: true, + ..tip(BrushStyle::Oil, 42.0, 0.85, 0.65, 0.05) + }, + true, + ), + preset( + "paint_heavy_acrylic", + "Heavy Body Acrylic", + "Acrylics", + BrushTip { + density: 1.5, + drawing_angle: true, + roundness: 0.65, + ..tip(BrushStyle::Bristle, 34.0, 1.0, 0.9, 0.03) + }, + false, + ), + preset( + "paint_high_flow_acrylic", + "Fluid / High-Flow Acrylic", + "Acrylics", + BrushTip { + density: 0.8, + ..tip(BrushStyle::WetPaint, 30.0, 0.65, 0.45, 0.03) + }, + true, + ), + preset( + "paint_egg_tempera", + "Egg Tempera", + "Tempera", + BrushTip { + density: 1.15, + drawing_angle: true, + roundness: 0.55, + ..tip(BrushStyle::Bristle, 14.0, 0.9, 0.8, 0.04) + }, + true, + ), + ] +} + +#[cfg(test)] +mod tests { + use super::paint_presets; + use std::collections::HashSet; + + /// Ensures all non-watercolor wet media are present once. + #[test] + fn paint_catalog_is_complete_and_unique() { + let presets = paint_presets(); + assert_eq!(presets.len(), 7); + assert_eq!( + presets + .iter() + .map(|preset| &preset.id) + .collect::>() + .len(), + 7 + ); + assert!(presets + .iter() + .all(|preset| preset.style == preset.tip.style)); + } +} diff --git a/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs index d994237..f23ea5b 100644 --- a/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs +++ b/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs @@ -16,7 +16,9 @@ use hcie_engine_api::BrushPreset; /// Hash-based pseudo-random value noise. Returns a value in `0.0..=1.0`. fn hash_noise(x: i32, y: i32) -> f32 { - let mut h = x.wrapping_mul(374761393).wrapping_add(y.wrapping_mul(668265263)); + let mut h = x + .wrapping_mul(374761393) + .wrapping_add(y.wrapping_mul(668265263)); h = (h ^ (h.wrapping_shr(13))).wrapping_mul(1274126177); h = h ^ h.wrapping_shr(16); (h & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32 @@ -93,7 +95,11 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { return (0.0, 0.0, l); } let d = max - min; - let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) }; + let s = if l > 0.5 { + d / (2.0 - max - min) + } else { + d / (max + min) + }; let h = if max == rf { ((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) * 60.0 } else if max == gf { @@ -152,11 +158,7 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec displaced { @@ -176,7 +178,11 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec 0.25 { - let gran = fbm(px as f32 * 0.55 + sx * 100.0, py as f32 * 0.55 + sy * 100.0, 3); + let gran = fbm( + px as f32 * 0.55 + sx * 100.0, + py as f32 * 0.55 + sy * 100.0, + 3, + ); let gran_mask = ((t - 0.25) / 0.75).min(1.0); alpha *= 0.65 + 0.35 * gran * gran_mask; } @@ -296,7 +302,11 @@ pub fn watercolor_presets() -> Vec { id: "wc_wash".into(), name: "Watercolor Wash".into(), category: "Watercolor".into(), - tip: wc_tip(80.0, 0.25, 0.0, 0.08), + tip: BrushTip { + density: 0.75, + roundness: 0.82, + ..wc_tip(80.0, 0.25, 0.0, 0.08) + }, style: BrushStyle::Watercolor, pressure_size: false, pressure_opacity: true, @@ -308,7 +318,11 @@ pub fn watercolor_presets() -> Vec { id: "wc_wet".into(), name: "Wet Watercolor".into(), category: "Watercolor".into(), - tip: wc_tip(60.0, 0.35, 0.0, 0.06), + tip: BrushTip { + density: 1.0, + jitter_amount: 0.03, + ..wc_tip(60.0, 0.35, 0.0, 0.06) + }, style: BrushStyle::Watercolor, pressure_size: false, pressure_opacity: true, @@ -320,7 +334,12 @@ pub fn watercolor_presets() -> Vec { id: "wc_dry".into(), name: "Dry Brush".into(), category: "Watercolor".into(), - tip: wc_tip(30.0, 0.70, 0.6, 0.12), + tip: BrushTip { + density: 0.55, + roundness: 0.42, + drawing_angle: true, + ..wc_tip(30.0, 0.70, 0.6, 0.12) + }, style: BrushStyle::Watercolor, pressure_size: true, pressure_opacity: true, @@ -332,7 +351,10 @@ pub fn watercolor_presets() -> Vec { id: "wc_glaze".into(), name: "Glazing".into(), category: "Watercolor".into(), - tip: wc_tip(50.0, 0.15, 0.0, 0.10), + tip: BrushTip { + density: 0.85, + ..wc_tip(50.0, 0.15, 0.0, 0.10) + }, style: BrushStyle::Watercolor, pressure_size: false, pressure_opacity: true, @@ -344,7 +366,13 @@ pub fn watercolor_presets() -> Vec { id: "wc_splat".into(), name: "Splatter".into(), category: "Watercolor".into(), - tip: wc_tip(40.0, 0.50, 0.2, 0.30), + tip: BrushTip { + density: 0.5, + jitter_amount: 0.4, + scatter_amount: 0.9, + rotation_random: 1.0, + ..wc_tip(40.0, 0.50, 0.2, 0.30) + }, style: BrushStyle::Watercolor, pressure_size: true, pressure_opacity: false, @@ -356,7 +384,11 @@ pub fn watercolor_presets() -> Vec { id: "wc_bleed".into(), name: "Color Bleed".into(), category: "Watercolor".into(), - tip: wc_tip(70.0, 0.30, 0.0, 0.07), + tip: BrushTip { + density: 1.1, + scatter_amount: 0.15, + ..wc_tip(70.0, 0.30, 0.0, 0.07) + }, style: BrushStyle::Watercolor, pressure_size: false, pressure_opacity: true, @@ -368,7 +400,10 @@ pub fn watercolor_presets() -> Vec { id: "wc_grain".into(), name: "Granulation".into(), category: "Watercolor".into(), - tip: wc_tip(45.0, 0.40, 0.3, 0.09), + tip: BrushTip { + density: 1.5, + ..wc_tip(45.0, 0.40, 0.3, 0.09) + }, style: BrushStyle::Watercolor, pressure_size: true, pressure_opacity: true, @@ -380,7 +415,11 @@ pub fn watercolor_presets() -> Vec { id: "wc_bloom".into(), name: "Bloom".into(), category: "Watercolor".into(), - tip: wc_tip(90.0, 0.20, 0.0, 0.05), + tip: BrushTip { + density: 0.65, + scatter_amount: 0.25, + ..wc_tip(90.0, 0.20, 0.0, 0.05) + }, style: BrushStyle::Watercolor, pressure_size: false, pressure_opacity: true, diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..8c1aa18 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +git config --local core.hooksPath .githooks +chmod +x .githooks/pre-commit .githooks/post-commit scripts/precommit-test-gate.sh + +configured="$(git config --local --get core.hooksPath)" +if [[ "$configured" != ".githooks" ]]; then + echo "Failed to configure repository Git hooks." >&2 + exit 1 +fi + +echo "Repository hooks installed: core.hooksPath=.githooks" diff --git a/scripts/precommit-test-gate.sh b/scripts/precommit-test-gate.sh new file mode 100755 index 0000000..a68a10e --- /dev/null +++ b/scripts/precommit-test-gate.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +cd "$REPO_ROOT" + +mapfile -d '' staged_files < <( + git diff --cached --name-only --diff-filter=ACMRDTUXB -z +) + +if (( ${#staged_files[@]} == 0 )); then + echo "No staged files; commit regression tests are not required." + exit 0 +fi + +staged_rust_sources=() +for path in "${staged_files[@]}"; do + printf ' staged input: %s\n' "$path" + if [[ "$path" == *.rs && -f "$path" ]]; then + staged_rust_sources+=("$path") + fi +done + +dirty_inputs=() +while IFS= read -r -d '' path; do + dirty_inputs+=("$path") +done < <(git diff --name-only --diff-filter=ACMRDTUXB -z) +while IFS= read -r -d '' path; do + dirty_inputs+=("$path") +done < <(git ls-files --others --exclude-standard -z) + +if (( ${#dirty_inputs[@]} > 0 )); then + echo "Commit blocked: unstaged or untracked files would make staged test results ambiguous:" >&2 + printf ' %s\n' "${dirty_inputs[@]}" >&2 + echo "Stage or remove those files, then retry the commit." >&2 + exit 1 +fi + +command -v cargo >/dev/null 2>&1 || { + echo "Commit blocked: cargo is unavailable." >&2 + exit 1 +} + +if (( ${#staged_rust_sources[@]} > 0 )); then + echo "Checking staged Rust source formatting..." + rustfmt --edition 2021 --check "${staged_rust_sources[@]}" +fi + +echo "Running deterministic workspace tests..." +cargo check --locked --workspace --exclude hcie-io --examples +cargo test --locked --workspace --exclude hcie-io --lib --tests -- \ + --skip benchmark_4k_stroke_on_multilayer_document + +echo "Mandatory Rust regression gate passed."