From e5d899d72d4b76d69a684873650a2a28c0366099 Mon Sep 17 00:00:00 2001 From: Halit Can Date: Tue, 21 Jul 2026 06:07:12 +0300 Subject: [PATCH] feat: add watercolor brush category with presets and splat renderer - Implemented a new crate `hcie-watercolor-brushes` containing watercolor brush presets and a procedural splat preview renderer. - Integrated watercolor brushes into the iced GUI's Brushes panel, adding a new category for watercolor brushes. - Updated the `BrushCategory` enum to include `Watercolor` and modified the rendering logic to display watercolor presets. - Added functionality to generate watercolor splat thumbnails using a noise-based algorithm. - Ensured that existing brush styles and engine crates remain unmodified, adhering to project constraints. --- ...84597466971-vector-state-management-fix.md | 269 ++++++++----- Cargo.lock | 8 + Cargo.toml | 1 + gemini-code-1784601749638.md | 71 ++++ hcie-iced-app/crates/hcie-iced-gui/Cargo.toml | 1 + .../crates/hcie-iced-gui/src/dock/view.rs | 1 + .../hcie-iced-gui/src/panels/brushes.rs | 91 ++++- .../crates/hcie-watercolor-brushes/Cargo.toml | 7 + .../crates/hcie-watercolor-brushes/src/lib.rs | 372 ++++++++++++++++++ 9 files changed, 707 insertions(+), 114 deletions(-) create mode 100644 gemini-code-1784601749638.md create mode 100644 hcie-iced-app/crates/hcie-watercolor-brushes/Cargo.toml create mode 100644 hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs diff --git a/.kilo/plans/1784597466971-vector-state-management-fix.md b/.kilo/plans/1784597466971-vector-state-management-fix.md index ee4a04d..3809050 100644 --- a/.kilo/plans/1784597466971-vector-state-management-fix.md +++ b/.kilo/plans/1784597466971-vector-state-management-fix.md @@ -1,129 +1,180 @@ -# Plan: Build Increment Script + Vector Editing Improvements +# Watercolor Brush Category — Implementation Plan -## Task 1: File Increment Script +## Goal +Add a new **Watercolor** brush category to the iced GUI's Brushes panel. Each brush has a watercolor-splat preview (not the standard sine-wave stroke). The brushes simulate realistic watercolor effects. Implemented as a new crate with zero modification to existing brush/engine crates. -**File:** `/mnt/extra/00_PROJECTS/hcie-rust-v3.05/build.id` (currently contains `990`) +## Architecture Overview -A single robust bash command: +### Existing brush flow +1. `hcie-brush-engine` defines `BrushStyle` enum (33 variants including `Watercolor`) and `BrushPreset` struct +2. `hcie-engine-api` re-exports `BrushPreset` and provides `draw_specialized_stroke` → dispatches to `draw_watercolor_brush` for `BrushStyle::Watercolor` +3. `hcie-iced-gui/src/panels/brushes.rs` defines `BrushCategory` enum, `BRUSH_STYLES` static array, and `generate_brush_preview()` for thumbnails +4. When a preset is selected → `Message::BrushPresetSelected(preset)` → sets `tool_state.brush_style`, `brush_size`, `brush_opacity`, `brush_hardness`, `brush_spacing`, `active_brush_preset` -```bash -echo $(( $(cat /mnt/extra/00_PROJECTS/hcie-rust-v3.05/build.id) + 1 )) > /mnt/extra/00_PROJECTS/hcie-rust-v3.05/build.id -``` +### Key constraint +Engine crates (`hcie-brush-engine`, `hcie-protocol`, `hcie-engine-api`, `hcie-draw`) are **locked** — agents must not modify them. The new crate lives in the GUI layer and reuses the engine's existing `BrushStyle::Watercolor` rendering path. -Or as a reusable script at the project root: - -```bash -#!/usr/bin/env bash -# increment_build.sh — atomically increments the integer in build.id -set -euo pipefail -FILE="/mnt/extra/00_PROJECTS/hcie-rust-v3.05/build.id" -CURRENT=$(cat "$FILE") -NEXT=$(( CURRENT + 1 )) -echo "$NEXT" > "$FILE" -echo "build.id: $CURRENT -> $NEXT" -``` - -**Edge cases handled:** -- `set -euo pipefail` catches missing file, non-integer content, write failures -- The `$(( ))` arithmetic is safe for unsigned integers -- No temp file race: single echo redirect is atomic on Linux ext4 for small writes +### New crate strategy +- Define watercolor presets using existing `BrushPreset` (from `hcie-engine-api`) with `style: BrushStyle::Watercolor` +- Provide a **watercolor splat preview renderer** that generates RGBA pixels for thumbnails +- The brushes panel integrates the new crate's presets under a "Watercolor" category tab +- Actual drawing uses the engine's existing watercolor brush rendering — no engine changes needed --- -## Task 2: Vector Editing Improvements +## Step 1: Create the new crate `hcie-watercolor-brushes` -### Sub-task 2.1: Angular Snapping Visual Feedback +**Path:** `hcie-iced-app/crates/hcie-watercolor-brushes/` -**Problem:** When Shift is held during rotation, the angle snaps to 15° increments but there is no visual confirmation — it looks like freehand drawing. +### `Cargo.toml` +```toml +[package] +name = "hcie-watercolor-brushes" +version.workspace = true +edition.workspace = true -**Solution:** Add an angle indicator arc + snapped-angle label in the canvas overlay during active rotation drags. - -**Files:** -- `hcie-iced-app/crates/hcie-iced-gui/src/canvas/mod.rs` — overlay rendering (around line 1331 where edit handles are drawn) -- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — store the current snapped angle and shift state during drag - -**Implementation:** - -1. **New state fields on `HcieIcedApp`** (in `app.rs`): - ```rust - /// True while Shift is held during a rotation drag. - pub rotation_snap_active: bool, - /// The snapped angle (radians) shown in the overlay label. - pub rotation_snap_angle: f32, - ``` - -2. **Update in `VectorSelectDragMove` handler** (in `app.rs`): - After `transform_shape` returns, set: - ```rust - self.rotation_snap_active = self.modifiers.shift(); - self.rotation_snap_angle = shape.angle(); - ``` - -3. **Clear on drag end** (in `VectorSelectDragEnd`): - ```rust - self.rotation_snap_active = false; - ``` - -4. **Render in overlay** (in `canvas/mod.rs` after the edit-handles block ~line 1355): - When `rotation_snap_active` is true, draw: - - A thin dashed arc from the shape center at the snapped angle (radius ~40px in screen space) - - A small text label showing the snapped angle in degrees (e.g., "45°") - - Use a distinctive color (e.g., cyan `iced::Color::from_rgba(0.0, 0.8, 1.0, 0.9)`) - -5. **Same for egui GUI:** Apply equivalent changes to `hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs` (around the edit-handles overlay). - -### Sub-task 2.2: Enhanced Transformation Constraints - -**Problem:** Angle snapping during vector path editing and aspect-ratio lock during resize are not fully wired. - -**Current state:** -- Rotation snapping: ✅ Already implemented via `snap_to_15deg` in `vector_edit.rs` and applied in `transform_shape` when `snap_angle=true`. -- Resize aspect lock: ✅ Already implemented via `lock_aspect` in `resize_bounds`. -- Path editing snap: ❌ Not implemented — when editing vector paths (node editing), there is no angle snap. - -**Solution for path editing snap:** In the `hcie-iced-gui` canvas `VectorSelect` handler (canvas/mod.rs ~line 994), when a non-rotate, non-move handle drag is active and Shift is held, snap the resulting angle of any created edge to 15° increments. This applies to the `VectorSelectDragMove` handler's `transform_shape` call — already wired via the `snap_angle` parameter. - -**Verification:** The `transform_shape` function already accepts `snap_angle: bool` and the caller passes `self.modifiers.shift()`. For the Rotate handle, this snaps the angle. For resize handles, the aspect lock is via `lock_aspect` (also shift). The constraint is that `lock_aspect` and `snap_angle` share the same Shift key — this is standard UX (Shift = constrain). - -**No code changes needed for sub-task 2.2** — it's already wired. Just verify both work: -- Rotation: Shift + rotate handle → snaps to 15° ✅ -- Resize: Shift + corner handle → locks aspect ✅ -- Path editing: Shift is passed to `transform_shape` as both `lock_aspect` and `snap_angle` — resize constraints work, rotation snaps work ✅ - -### Sub-task 2.3: Color Logic Refinement - -**Problem:** In the iced GUI, `fill_color` defaults to `color` (primary/fg) instead of secondary/bg. - -**Evidence:** -- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs:5772`: `let fill_color = color;` where `color = self.fg_color` -- `hcie-egui-app/crates/hcie-gui-egui/src/canvas/mod.rs:2184`: `let fill_color = state.secondary_color;` ← correct - -**Fix:** In `app.rs` line 5772, change: -```rust -let fill_color = color; -``` -to: -```rust -let fill_color = self.bg_color; +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } ``` -**Files:** -- `hcie-iced-app/crates/hcie-iced-gui/src/app.rs` — line 5772 +### `src/lib.rs` — Presets + Preview Renderer + +Exports: +- `pub fn watercolor_presets() -> Vec` — returns 8 watercolor brush presets +- `pub fn render_watercolor_splat(style: BrushStyle, base_color: [u8; 3]) -> Vec` — generates 64×64 RGBA watercolor splat preview + +#### Presets (8 brushes) +| ID | Name | Size | Opacity | Hardness | Spacing | Description | +|----|------|------|---------|----------|---------|-------------| +| `wc_wash` | Watercolor Wash | 80 | 0.25 | 0.0 | 0.08 | Large, transparent wash | +| `wc_wet` | Wet Watercolor | 60 | 0.35 | 0.0 | 0.06 | Medium, wet-on-wet | +| `wc_dry` | Dry Brush | 30 | 0.70 | 0.6 | 0.12 | Small, textured, high opacity | +| `wc_glaze` | Glazing | 50 | 0.15 | 0.0 | 0.10 | Very transparent, layered | +| `wc_splat` | Splatter | 40 | 0.50 | 0.2 | 0.30 | Scattered droplets | +| `wc_bleed` | Color Bleed | 70 | 0.30 | 0.0 | 0.07 | Heavy diffusion edges | +| `wc_grain` | Granulation | 45 | 0.40 | 0.3 | 0.09 | Visible paper grain texture | +| `wc_bloom` | Bloom | 90 | 0.20 | 0.0 | 0.05 | Soft, blooming edges | + +All use `BrushStyle::Watercolor` as the engine style — the engine's existing `draw_watercolor_brush` handles the actual stroke rendering. + +#### Watercolor Splat Preview Algorithm (64×64 RGBA) + +The splat is generated procedurally using multi-octave value noise: + +1. **Base shape:** Radial gradient from center, `alpha = (1 - dist/radius)^1.5` +2. **Edge displacement:** Displace the radius at each angle using 3-octave value noise → irregular organic boundary +3. **Multiple layers:** 3-5 overlapping semi-transparent blobs at slightly offset positions +4. **Color variation:** Shift hue slightly per layer (±10°) for color separation +5. **Granulation:** High-frequency noise modulates alpha in the outer 40% of the radius +6. **Wet edge:** Slightly darker/more saturated ring near the boundary (wet-edge accumulation effect) + +The noise function is a simple hash-based value noise (no external deps): +```rust +fn value_noise(x: f32, y: f32) -> f32 { + // hash-based pseudo-random, returns 0.0..1.0 +} +fn fbm(x: f32, y: f32, octaves: u32) -> f32 { + // fractal Brownian motion using value_noise +} +``` --- -## Changes Summary +## Step 2: Register the crate in the workspace -| # | File | Change | -|---|------|--------| -| 1 | Project root | Add `increment_build.sh` script | -| 2 | `hcie-iced-app/.../app.rs` | Add `rotation_snap_active` and `rotation_snap_angle` fields, set/clear them in drag handlers | -| 3 | `hcie-iced-app/.../canvas/mod.rs` | Render angle indicator arc + label when `rotation_snap_active` | -| 4 | `hcie-egui-app/.../canvas/mod.rs` | Same overlay for egui (if desired, otherwise skip) | -| 5 | `hcie-iced-app/.../app.rs` | Change `fill_color = color` to `fill_color = self.bg_color` | +**File:** `Cargo.toml` (workspace root) + +Add to `[workspace] members`: +```toml +"hcie-iced-app/crates/hcie-watercolor-brushes", +``` + +--- + +## Step 3: Add `hcie-watercolor-brushes` dependency to `hcie-iced-gui` + +**File:** `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml` + +Add: +```toml +hcie-watercolor-brushes = { path = "../hcie-watercolor-brushes" } +``` + +--- + +## Step 4: Update the Brushes panel + +**File:** `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` + +### 4a. Add `Watercolor` variant to `BrushCategory` +```rust +pub enum BrushCategory { + All, + Drawing, + Painting, + Watercolor, // ← NEW + Effects, + Custom, + Imported, +} +``` + +### 4b. Update `category_matches_style` for the new variant +```rust +BrushCategory::Watercolor => style_category == BrushCategory::Watercolor, +``` + +### 4c. Add Watercolor tab to the category tabs array +```rust +(BrushCategory::Watercolor, "Watercolor", "icons/brush.svg"), +``` + +### 4d. Render watercolor presets when Watercolor category is active +When `active_category == BrushCategory::Watercolor`: +- Call `hcie_watercolor_brushes::watercolor_presets()` to get the preset list +- For each preset, call `hcie_watercolor_brushes::render_watercolor_splat(...)` for the thumbnail +- Render as a grid (same 2-column layout as existing brushes) +- Each cell sends `Message::BrushPresetSelected(preset)` on click + +### 4e. Update `view` function signature +The function already accepts `imported_presets: &[BrushPreset]`. No signature change needed — the watercolor presets are fetched internally from the new crate. + +### 4f. Update test `catalog_contains_every_engine_brush_style` +The test asserts `BRUSH_STYLES.len() == 34`. This doesn't change (watercolor presets are separate from `BRUSH_STYLES`). The test remains valid. + +--- + +## Step 5: Add "Watercolor" to the egui GUI category filter (optional, for parity) + +**File:** `hcie-egui-app/crates/hcie-gui-egui/src/app/tools/brushes_panel.rs` + +Add "Watercolor" to the categories array and update `matches_style_category` to map it to `BrushStyle::Watercolor`. This is a minimal change for parity — the watercolor presets themselves would need separate integration in the egui panel if desired. + +**Note:** This step is optional for the initial implementation. The user's request focuses on the iced GUI. + +--- + +## Files Changed + +| File | Change | +|------|--------| +| `hcie-iced-app/crates/hcie-watercolor-brushes/Cargo.toml` | **New** — crate manifest | +| `hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs` | **New** — presets + splat renderer | +| `Cargo.toml` (workspace root) | Add member `hcie-iced-app/crates/hcie-watercolor-brushes` | +| `hcie-iced-app/crates/hcie-iced-gui/Cargo.toml` | Add dependency `hcie-watercolor-brushes` | +| `hcie-iced-app/crates/hcie-iced-gui/src/panels/brushes.rs` | Add `Watercolor` category, tab, and grid rendering | + +**Files NOT modified:** All engine crates (`hcie-brush-engine`, `hcie-protocol`, `hcie-engine-api`, `hcie-draw`), existing brush definitions, existing `BRUSH_STYLES` array. + +--- ## Validation -1. `cargo check -p hcie-iced-gui && cargo check -p hcie-gui-egui` -2. `cargo test -p hcie-iced-gui` -3. Manual: run `bash increment_build.sh` and verify `build.id` increments from 990 → 991 +1. `cargo check -p hcie-watercolor-brushes` — new crate compiles +2. `cargo check -p hcie-iced-gui` — GUI compiles with new dependency +3. `cargo test -p hcie-iced-gui` — all existing tests pass +4. `cargo test -p hcie-watercolor-brushes` — new crate tests pass +5. `cargo test --test feature_scorecard` — scorecard passes +6. Visual: launch the app, open Brushes panel, click "Watercolor" tab → 8 watercolor presets with splat previews appear +7. Visual: select a watercolor brush → draw on canvas → realistic watercolor stroke rendered +8. Regression: existing Drawing/Painting/Effects brushes unchanged diff --git a/Cargo.lock b/Cargo.lock index cc79088..2d93635 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -2756,6 +2756,7 @@ dependencies = [ "futures-util", "hcie-build-info", "hcie-engine-api", + "hcie-watercolor-brushes", "iced", "iced-panel-adapter", "image 0.25.10", @@ -2921,6 +2922,13 @@ dependencies = [ "tokio", ] +[[package]] +name = "hcie-watercolor-brushes" +version = "0.1.0" +dependencies = [ + "hcie-engine-api", +] + [[package]] name = "heck" version = "0.4.1" diff --git a/Cargo.toml b/Cargo.toml index 558238b..4c28b84 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ members = [ "hcie-iced-app/crates/iced-panel-adapter", "hcie-iced-app/crates/iced-panel-script", "hcie-iced-app/crates/iced-panel-ai-chat", + "hcie-iced-app/crates/hcie-watercolor-brushes", "tools/screenshot-diff", "tools/panel-tuner", ] diff --git a/gemini-code-1784601749638.md b/gemini-code-1784601749638.md new file mode 100644 index 0000000..4fcfc89 --- /dev/null +++ b/gemini-code-1784601749638.md @@ -0,0 +1,71 @@ +# Comprehensive Drawing & Painting Media Reference Guide +*A Structured Classification System for Traditional Tools and Digital Brush Architecture* + +--- + +## 1. Dry & Graphic Media + +### Graphite & Charcoal +* **Graphite Pencils:** Graded by hardness and darkness from 9H (hardest) to 9B (softest). +* **Carpenter Pencil:** Broad, flat-graphite core for wide strokes and varied line weights. +* **Powdered Graphite:** Fine graphite dust used for large-area shading and smooth tonal blending. +* **Compressed Charcoal:** Dense charcoal bound with gum, producing deep, rich blacks and hard lines. +* **Willow / Vine Charcoal:** Burnt natural twigs; soft, powdery, and easy to erase or smudge. +* **Charcoal Pencil:** Encased in wood for clean handling and precise detail work. + +### Pastels & Chalks +* **Soft Pastels:** High pigment content with minimal binder; offers powdery, easily blended colors. +* **Hard Pastels (Conté Crayon):** Higher binder content; ideal for sharp edges, outlines, and details. +* **Oil Pastels:** Bound with non-drying oil and wax; buttery texture with strong opacity. +* **Water-Soluble Pastels:** Can be used dry or dissolved with water to create watercolor-like washes. +* **PanPastel:** Cake-format pastel applied using specialized sponges and applicators. +* **White Chalk & Sanguine:** Traditional earthy monochrome media used for highlights and anatomical studies. + +### Colored Pencils +* **Wax-Based Colored Pencils:** Soft core, ideal for rich color layering and blending. +* **Oil-Based Colored Pencils:** Harder core, break-resistant, well-suited for fine detail work. +* **Watercolor Pencils:** Water-soluble pigment cores that react with wet brushes. + +--- + +## 2. Inks & Markers + +### Markers & Felt-Tip Pens +* **Alcohol-Based Markers:** Fast-drying, streak-free blending ink (e.g., Copic). +* **Water-Based Markers:** Water-soluble ink; ideal for general illustration and light wash effects. +* **Acrylic Paint Markers:** Opaque, water-based acrylic paint for multi-surface application (e.g., Posca). +* **Brush Pens:** Flexible nibs (felt or synthetic hair) for variable stroke width in ink illustration. + +### Inking Instruments +* **Indian / Drawing Ink:** Waterproof, highly opaque shellac- or acrylic-based liquid ink. +* **Dip Pen / Nib Pen:** Traditional metal nibs dipped into ink for calligraphic or cross-hatching work. +* **Technical Pens (Rapido):** Precision engineering pens delivering exact, consistent line widths. +* **Fineliners / Graphic Liners:** Pigment-based, lightfast, fine plastic-nibbed pens for line art. + +--- + +## 3. Wet & Painting Media + +### Watercolors & Water-Based Paints +* **Tube & Pan Watercolors:** Highly transparent pigments activated by water for delicate layering. +* **Liquid Watercolors:** Concentrated fluid pigments popular in graphic arts and illustration. +* **Gouache:** Opaque watercolor drying to a smooth, matte finish. +* **Acryla Gouache:** Water-based paint combining gouache opacity with acrylic water-resistance when dry. + +### Oils & Acrylics +* **Traditional Oil Paint:** Linseed oil binder; slow-drying, allowing extensive blending and impasto. +* **Water-Mixable Oil Paint:** Modified oils that thin and clean up with water without solvents. +* **Heavy Body Acrylic:** Thick, buttery consistency that retains brush and palette knife marks. +* **Fluid & High-Flow Acrylic:** Low-viscosity acrylics for smooth coverage, glazing, or airbrushing. +* **Egg Tempera:** Fast-drying traditional medium using egg yolk as a binder. + +--- + +## 4. Digital-Specific Brush Classifications + +### Digital Engine Types +* **Pixel / Raster Brushes:** Simulates traditional media textures using bitmapped stamps and opacity dynamics. +* **Vector Brushes:** Creates resolution-independent paths with editable stroke profiles. +* **Smudge & Blending Tools:** Manipulates existing canvas color without applying new pigment. +* **Stamp & Texture Brushes:** Applies pre-rendered patterns or shapes (e.g., foliage, skin texture, clouds). +* **FX & Dynamic Brushes:** Particle- or physics-based brushes generating glows, sparks, or fluid effects. \ No newline at end of file diff --git a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml index d90154e..28c46df 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml +++ b/hcie-iced-app/crates/hcie-iced-gui/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] hcie-build-info = { workspace = true } hcie-engine-api = { path = "../../../hcie-engine-api" } +hcie-watercolor-brushes = { path = "../hcie-watercolor-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/dock/view.rs b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs index 935ca01..985d080 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/dock/view.rs @@ -184,6 +184,7 @@ pub fn panel_body<'a>( app.tool_state.brush_hardness, app.brush_category, &app.tool_state.imported_brushes, + app.tool_state.active_brush_preset.as_ref(), colors, ), PaneType::Filters => crate::panels::filters::view( 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 4d4026f..560517c 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 @@ -1,7 +1,7 @@ //! Brushes panel — brush preset browser with visual thumbnails and live preview. //! //! Matches the egui version's layout: -//! - Category tabs (All, Drawing, Painting, Effects) +//! - Category tabs (All, Drawing, Painting, Watercolor, Effects) //! - Grid of brush tip thumbnails with visual stroke preview //! - Selected brush highlighted with accent border //! - Size/Opacity/Hardness sliders at bottom @@ -30,6 +30,7 @@ pub enum BrushCategory { All, Drawing, Painting, + Watercolor, Effects, Custom, Imported, @@ -221,9 +222,10 @@ const THUMB_SIZE: u32 = 64; fn category_matches_style(active: BrushCategory, style_category: BrushCategory) -> bool { match active { BrushCategory::All => true, - BrushCategory::Drawing | BrushCategory::Painting | BrushCategory::Effects => { - active == style_category - } + BrushCategory::Drawing + | BrushCategory::Painting + | BrushCategory::Watercolor + | BrushCategory::Effects => active == style_category, BrushCategory::Custom => false, BrushCategory::Imported => style_category == BrushCategory::Imported, } @@ -412,6 +414,7 @@ pub fn view( current_hardness: f32, active_category: BrushCategory, imported_presets: &[hcie_engine_api::BrushPreset], + active_brush_preset: Option<&hcie_engine_api::BrushPreset>, colors: ThemeColors, ) -> Element<'static, Message> { // Category tabs @@ -419,6 +422,7 @@ 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/brush.svg"), (BrushCategory::Effects, "Effects", "icons/glow.svg"), (BrushCategory::Custom, "Custom", "icons/star.svg"), (BrushCategory::Imported, "Imported", "icons/import_abr.svg"), @@ -540,6 +544,83 @@ pub fn view( } } + // ── Watercolor presets (from hcie-watercolor-brushes crate) ─────────── + let mut wc_rows = column![].spacing(4); + if matches!( + active_category, + BrushCategory::All | BrushCategory::Watercolor + ) { + let wc_presets = hcie_watercolor_brushes::watercolor_presets(); + 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); + let c = colors; + let wc_color: [u8; 3] = match preset.id.as_str() { + "wc_wash" => [100, 150, 200], + "wc_wet" => [80, 130, 190], + "wc_dry" => [140, 110, 80], + "wc_glaze" => [120, 160, 210], + "wc_splat" => [90, 140, 180], + "wc_bleed" => [110, 80, 150], + "wc_grain" => [130, 120, 100], + "wc_bloom" => [90, 170, 200], + _ => [100, 150, 200], + }; + 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 thumb = 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, is_selected)); + + let label = container(text(preset.name.clone()).size(9)) + .width(Length::Fill) + .center_x(Length::Fill); + + let cell = column![thumb, label] + .spacing(2) + .align_x(iced::Alignment::Center); + + let preset_msg = preset.clone(); + let clickable = button(cell) + .on_press(Message::BrushPresetSelected(preset_msg)) + .padding(2) + .style( + move |_theme: &iced::Theme, status: iced::widget::button::Status| match status { + iced::widget::button::Status::Hovered => iced::widget::button::Style { + background: Some(iced::Background::Color(c.bg_hover)), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + _ => iced::widget::button::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba( + 0.0, 0.0, 0.0, 0.0, + ))), + text_color: c.text_primary, + border: iced::Border::default(), + ..Default::default() + }, + }, + ); + + wc_row = wc_row.push(clickable); + + if (idx + 1) % 2 == 0 || idx == wc_presets.len() - 1 { + wc_rows = wc_rows.push(wc_row); + wc_row = row![].spacing(4); + } + } + } + let mut imported_rows = column![].spacing(3); if matches!( active_category, @@ -653,7 +734,7 @@ pub fn view( let panel = column![ tabs, horizontal_rule(1), - scrollable(column![grid_rows, imported_rows].spacing(6)).height(Length::Fill), + scrollable(column![grid_rows, wc_rows, imported_rows].spacing(6)).height(Length::Fill), horizontal_rule(1), row![button(text("Import ABR").size(10)) .on_press(Message::BrushImportAbr) diff --git a/hcie-iced-app/crates/hcie-watercolor-brushes/Cargo.toml b/hcie-iced-app/crates/hcie-watercolor-brushes/Cargo.toml new file mode 100644 index 0000000..c94cdbf --- /dev/null +++ b/hcie-iced-app/crates/hcie-watercolor-brushes/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "hcie-watercolor-brushes" +version.workspace = true +edition.workspace = true + +[dependencies] +hcie-engine-api = { path = "../../../hcie-engine-api" } diff --git a/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs b/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs new file mode 100644 index 0000000..3bb4148 --- /dev/null +++ b/hcie-iced-app/crates/hcie-watercolor-brushes/src/lib.rs @@ -0,0 +1,372 @@ +//! Watercolor brush presets and splat preview renderer. +//! +//! **Purpose:** Provides a self-contained set of watercolor brush presets and a procedural +//! watercolor-splat thumbnail renderer for the Brushes panel. The presets use the engine's +//! existing `BrushStyle::Watercolor` rendering path — no engine modifications required. +//! **Logic & Workflow:** Exports `watercolor_presets()` for the panel to consume and +//! `render_watercolor_splat()` for generating 64×64 RGBA splat thumbnails. +//! **Side Effects / Dependencies:** None. + +use hcie_engine_api::brush::{BrushStyle, BrushTip}; +use hcie_engine_api::BrushPreset; + +// ───────────────────────────────────────────────────────────────────────────── +// Noise primitives (no external dependencies) +// ───────────────────────────────────────────────────────────────────────────── + +/// 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)); + h = (h ^ (h.wrapping_shr(13))).wrapping_mul(1274126177); + h = h ^ h.wrapping_shr(16); + (h & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32 +} + +/// Bilinear-interpolated value noise at continuous coordinates. +fn value_noise(x: f32, y: f32) -> f32 { + let xi = x.floor() as i32; + let yi = y.floor() as i32; + let xf = x - x.floor(); + let yf = y - y.floor(); + let sx = xf * xf * (3.0 - 2.0 * xf); + let sy = yf * yf * (3.0 - 2.0 * yf); + let n00 = hash_noise(xi, yi); + let n10 = hash_noise(xi + 1, yi); + let n01 = hash_noise(xi, yi + 1); + let n11 = hash_noise(xi + 1, yi + 1); + let nx0 = n00 + (n10 - n00) * sx; + let nx1 = n01 + (n11 - n01) * sx; + nx0 + (nx1 - nx0) * sy +} + +/// Fractal Brownian Motion (octaves of value noise). +fn fbm(x: f32, y: f32, octaves: u32) -> f32 { + let mut value = 0.0; + let mut amplitude = 0.5; + let mut frequency = 1.0; + for _ in 0..octaves { + value += amplitude * value_noise(x * frequency, y * frequency); + amplitude *= 0.5; + frequency *= 2.0; + } + value +} + +// ───────────────────────────────────────────────────────────────────────────── +// Color helpers +// ───────────────────────────────────────────────────────────────────────────── + +/// Simple HSL → RGB conversion. `h` in degrees, `s` and `l` in `0.0..=1.0`. +fn hsl_to_rgb(h: f32, s: f32, l: f32) -> [u8; 3] { + let c = (1.0 - (2.0 * l - 1.0).abs()) * s; + let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); + let m = l - c / 2.0; + let (r, g, b) = if h < 60.0 { + (c, x, 0.0) + } else if h < 120.0 { + (x, c, 0.0) + } else if h < 180.0 { + (0.0, c, x) + } else if h < 240.0 { + (0.0, x, c) + } else if h < 300.0 { + (x, 0.0, c) + } else { + (c, 0.0, x) + }; + [ + ((r + m) * 255.0).round() as u8, + ((g + m) * 255.0).round() as u8, + ((b + m) * 255.0).round() as u8, + ] +} + +/// Approximate RGB → HSL. Returns `(h in degrees, s, l)`. +fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) { + let rf = r as f32 / 255.0; + let gf = g as f32 / 255.0; + let bf = b as f32 / 255.0; + let max = rf.max(gf).max(bf); + let min = rf.min(gf).min(bf); + let l = (max + min) / 2.0; + if (max - min).abs() < 1e-6 { + 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 h = if max == rf { + ((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) * 60.0 + } else if max == gf { + ((bf - rf) / d + 2.0) * 60.0 + } else { + ((rf - gf) / d + 4.0) * 60.0 + }; + (h, s, l) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Watercolor splat preview renderer +// ───────────────────────────────────────────────────────────────────────────── + +/// Preview thumbnail size in pixels. +const SPLAT_SIZE: u32 = 64; + +/// Seed offset per layer to decorrelate noise. +const LAYER_SEEDS: &[(f32, f32)] = &[(0.0, 0.0), (7.3, 3.1), (13.7, 9.5), (21.2, 17.8)]; + +/// Render a watercolor splat preview as RGBA pixels. +/// +/// **Arguments:** `style` determines the visual flavour (currently only `BrushStyle::Watercolor` +/// is meaningful); `base_color` is the RGB tint. +/// **Returns:** A `Vec` of `SPLAT_SIZE × SPLAT_SIZE × 4` RGBA bytes. +/// **Side Effects:** None. +pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec { + let w = SPLAT_SIZE; + let h = SPLAT_SIZE; + let mut pixels = vec![0u8; (w * h * 4) as usize]; + + let (bh, bs, bl) = rgb_to_hsl(base_color[0], base_color[1], base_color[2]); + let cx = w as f32 / 2.0; + let cy = h as f32 / 2.0; + let base_radius = w as f32 * 0.38; + + // Draw 4 overlapping layers for depth + for (li, &(sx, sy)) in LAYER_SEEDS.iter().enumerate() { + let layer_hue = bh + (li as f32 - 1.5) * 8.0; + let layer_sat = (bs + (li as f32 - 1.5) * 0.05).clamp(0.0, 1.0); + let layer_lum = (bl + (li as f32 - 1.5) * 0.03).clamp(0.0, 1.0); + let rgb = hsl_to_rgb(layer_hue.rem_euclid(360.0), layer_sat, layer_lum); + let layer_alpha = 0.35 - li as f32 * 0.04; + let offset_x = (li as f32 - 1.5) * 2.5; + let offset_y = (li as f32 - 1.5) * 1.8; + let radius = base_radius * (1.0 + li as f32 * 0.06); + + for py in 0..h { + for px in 0..w { + let dx = px as f32 - (cx + offset_x); + let dy = py as f32 - (cy + offset_y); + let dist = (dx * dx + dy * dy).sqrt(); + + // Angular displacement for organic edge + let angle = dy.atan2(dx); + let noise_val = fbm( + angle * 2.0 + sx * 10.0, + dist / radius + sy * 5.0, + 3, + ); + let displaced = radius * (0.7 + 0.3 * noise_val); + + if dist > displaced { + continue; + } + + let t = dist / displaced; + + // Radial alpha: solid center, soft edge + let mut alpha = (1.0 - t * t) * layer_alpha; + + // Wet edge: slightly more opaque/darker near boundary (60-90%) + if t > 0.6 && t < 0.95 { + let edge_t = (t - 0.6) / 0.35; + alpha += 0.08 * (1.0 - edge_t); + } + + // Granulation in outer region + if t > 0.35 { + let gran = fbm(px as f32 * 0.4 + sx * 100.0, py as f32 * 0.4 + sy * 100.0, 2); + let gran_mask = ((t - 0.35) / 0.65).min(1.0); + alpha *= 0.7 + 0.3 * gran * gran_mask; + } + + alpha = alpha.clamp(0.0, 1.0); + if alpha < 0.005 { + continue; + } + + let idx = ((py * w + px) * 4) as usize; + let src_a = alpha; + let dst_a = pixels[idx + 3] as f32 / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + if out_a > 0.0 { + pixels[idx] = ((rgb[0] as f32 * src_a + + pixels[idx] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; + pixels[idx + 1] = ((rgb[1] as f32 * src_a + + pixels[idx + 1] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; + pixels[idx + 2] = ((rgb[2] as f32 * src_a + + pixels[idx + 2] as f32 * dst_a * (1.0 - src_a)) + / out_a) as u8; + pixels[idx + 3] = (out_a * 255.0) as u8; + } + } + } + } + + pixels +} + +// ───────────────────────────────────────────────────────────────────────────── +// BrushTip factory +// ───────────────────────────────────────────────────────────────────────────── + +fn wc_tip(size: f32, opacity: f32, hardness: f32, spacing: f32) -> BrushTip { + BrushTip { + style: BrushStyle::Watercolor, + size, + opacity, + hardness, + spacing, + flow: 1.0, + jitter_amount: 0.0, + scatter_amount: 0.0, + angle: 0.0, + roundness: 1.0, + spray_particle_size: 2.0, + spray_density: 100, + bitmap_pixels: Vec::new(), + bitmap_w: 0, + bitmap_h: 0, + color_variant: false, + variant_amount: 0.0, + density: 1.0, + drawing_angle: false, + rotation_random: 0.0, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Public presets +// ───────────────────────────────────────────────────────────────────────────── + +/// Returns the full set of watercolor brush presets. +pub fn watercolor_presets() -> Vec { + vec![ + BrushPreset { + id: "wc_wash".into(), + name: "Watercolor Wash".into(), + category: "Watercolor".into(), + tip: wc_tip(80.0, 0.25, 0.0, 0.08), + style: BrushStyle::Watercolor, + pressure_size: false, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_wet".into(), + name: "Wet Watercolor".into(), + category: "Watercolor".into(), + tip: wc_tip(60.0, 0.35, 0.0, 0.06), + style: BrushStyle::Watercolor, + pressure_size: false, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_dry".into(), + name: "Dry Brush".into(), + category: "Watercolor".into(), + tip: wc_tip(30.0, 0.70, 0.6, 0.12), + style: BrushStyle::Watercolor, + pressure_size: true, + pressure_opacity: true, + pressure_flow: false, + jitter_position: 0.05, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_glaze".into(), + name: "Glazing".into(), + category: "Watercolor".into(), + tip: wc_tip(50.0, 0.15, 0.0, 0.10), + style: BrushStyle::Watercolor, + pressure_size: false, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_splat".into(), + name: "Splatter".into(), + category: "Watercolor".into(), + tip: wc_tip(40.0, 0.50, 0.2, 0.30), + style: BrushStyle::Watercolor, + pressure_size: true, + pressure_opacity: false, + pressure_flow: false, + jitter_position: 0.4, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_bleed".into(), + name: "Color Bleed".into(), + category: "Watercolor".into(), + tip: wc_tip(70.0, 0.30, 0.0, 0.07), + style: BrushStyle::Watercolor, + pressure_size: false, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_grain".into(), + name: "Granulation".into(), + category: "Watercolor".into(), + tip: wc_tip(45.0, 0.40, 0.3, 0.09), + style: BrushStyle::Watercolor, + pressure_size: true, + pressure_opacity: true, + pressure_flow: false, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + BrushPreset { + id: "wc_bloom".into(), + name: "Bloom".into(), + category: "Watercolor".into(), + tip: wc_tip(90.0, 0.20, 0.0, 0.05), + style: BrushStyle::Watercolor, + pressure_size: false, + pressure_opacity: true, + pressure_flow: true, + jitter_position: 0.0, + jitter_angle: 0.0, + }, + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn presets_count() { + assert_eq!(watercolor_presets().len(), 8); + } + + #[test] + fn all_presets_use_watercolor_style() { + for p in watercolor_presets() { + assert_eq!(p.style, BrushStyle::Watercolor); + } + } + + #[test] + fn splat_dimensions() { + let pixels = render_watercolor_splat(BrushStyle::Watercolor, [100, 150, 200]); + assert_eq!(pixels.len(), (SPLAT_SIZE * SPLAT_SIZE * 4) as usize); + } + + #[test] + fn splat_has_nonzero_alpha() { + let pixels = render_watercolor_splat(BrushStyle::Watercolor, [100, 150, 200]); + let has_alpha = pixels.chunks(4).any(|p| p[3] > 0); + assert!(has_alpha, "splat should contain visible pixels"); + } +}