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.
This commit is contained in:
@@ -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<BrushPreset>` — returns 8 watercolor brush presets
|
||||
- `pub fn render_watercolor_splat(style: BrushStyle, base_color: [u8; 3]) -> Vec<u8>` — 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
|
||||
|
||||
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "hcie-watercolor-brushes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||
@@ -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<u8>` of `SPLAT_SIZE × SPLAT_SIZE × 4` RGBA bytes.
|
||||
/// **Side Effects:** None.
|
||||
pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u8> {
|
||||
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<BrushPreset> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user