feat: Add digital, dry, ink, and paint brush presets; implement regression testing hooks
- Introduced new crates for digital brushes, dry media brushes, ink brushes, and paint brushes, each containing various presets. - Updated the menus to include "Paste Special" and "Paste as New Layer" options. - Enhanced the feature scorecard tests to validate selection texture encoding. - Added a regression gate workflow for automated testing on push and pull requests. - Implemented Git hooks for pre-commit checks to ensure code formatting and run tests before commits. - Created a settings file for local configurations.
This commit is contained in:
@@ -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)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
+13
-18
@@ -1,21 +1,16 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
CHANGED_FILES=$(git diff --cached --name-only)
|
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||||
if [ -z "$CHANGED_FILES" ]; then
|
cd "$REPO_ROOT"
|
||||||
exit 0
|
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
echo "🔍 Semantik etki analizi başlatılıyor..."
|
echo "Pre-commit checks passed."
|
||||||
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
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import subprocess
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
def load_manifest():
|
def load_manifest():
|
||||||
@@ -42,13 +41,9 @@ def main():
|
|||||||
log.write(report_line + "\n")
|
log.write(report_line + "\n")
|
||||||
|
|
||||||
if high_risk_change:
|
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...")
|
print("High-risk change detected; the mandatory pre-commit gate already ran the deterministic workspace tests.")
|
||||||
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)
|
|
||||||
|
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Generated
+32
@@ -2605,6 +2605,13 @@ dependencies = [
|
|||||||
"rstest",
|
"rstest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hcie-digital-brushes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hcie-engine-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hcie-document"
|
name = "hcie-document"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2632,6 +2639,13 @@ dependencies = [
|
|||||||
"rstest",
|
"rstest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hcie-dry-media-brushes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hcie-engine-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hcie-engine-api"
|
name = "hcie-engine-api"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2755,7 +2769,11 @@ dependencies = [
|
|||||||
"evdev",
|
"evdev",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"hcie-build-info",
|
"hcie-build-info",
|
||||||
|
"hcie-digital-brushes",
|
||||||
|
"hcie-dry-media-brushes",
|
||||||
"hcie-engine-api",
|
"hcie-engine-api",
|
||||||
|
"hcie-ink-brushes",
|
||||||
|
"hcie-paint-brushes",
|
||||||
"hcie-watercolor-brushes",
|
"hcie-watercolor-brushes",
|
||||||
"iced",
|
"iced",
|
||||||
"iced-panel-adapter",
|
"iced-panel-adapter",
|
||||||
@@ -2770,6 +2788,13 @@ dependencies = [
|
|||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hcie-ink-brushes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hcie-engine-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hcie-io"
|
name = "hcie-io"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2829,6 +2854,13 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hcie-paint-brushes"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"hcie-engine-api",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hcie-protocol"
|
name = "hcie-protocol"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ members = [
|
|||||||
"hcie-iced-app/crates/iced-panel-script",
|
"hcie-iced-app/crates/iced-panel-script",
|
||||||
"hcie-iced-app/crates/iced-panel-ai-chat",
|
"hcie-iced-app/crates/iced-panel-ai-chat",
|
||||||
"hcie-iced-app/crates/hcie-watercolor-brushes",
|
"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/screenshot-diff",
|
||||||
"tools/panel-tuner",
|
"tools/panel-tuner",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -581,6 +581,13 @@ impl eframe::App for App {
|
|||||||
self.preset.tip.color_variant,
|
self.preset.tip.color_variant,
|
||||||
self.preset.tip.variant_amount,
|
self.preset.tip.variant_amount,
|
||||||
self.preset.tip.density,
|
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;
|
self.stroke_count += 1;
|
||||||
|
|
||||||
|
|||||||
+1272
-233
File diff suppressed because it is too large
Load Diff
@@ -162,8 +162,8 @@ pub fn draw_brush_stroke(
|
|||||||
color_variant: tip.color_variant,
|
color_variant: tip.color_variant,
|
||||||
variant_amount: tip.variant_amount,
|
variant_amount: tip.variant_amount,
|
||||||
density: tip.density,
|
density: tip.density,
|
||||||
drawing_angle: false,
|
drawing_angle: tip.drawing_angle,
|
||||||
rotation_random: 0.0,
|
rotation_random: tip.rotation_random,
|
||||||
};
|
};
|
||||||
hcie_draw::draw_brush_stroke(
|
hcie_draw::draw_brush_stroke(
|
||||||
layer,
|
layer,
|
||||||
@@ -198,6 +198,13 @@ pub fn draw_specialized_stroke(
|
|||||||
color_variant: bool,
|
color_variant: bool,
|
||||||
variant_amount: f32,
|
variant_amount: f32,
|
||||||
density: 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);
|
let style = map_brush_style(brush_style);
|
||||||
hcie_brush_engine::draw_specialized_stroke(
|
hcie_brush_engine::draw_specialized_stroke(
|
||||||
@@ -221,6 +228,13 @@ pub fn draw_specialized_stroke(
|
|||||||
color_variant,
|
color_variant,
|
||||||
variant_amount,
|
variant_amount,
|
||||||
density,
|
density,
|
||||||
|
jitter_amount,
|
||||||
|
scatter_amount,
|
||||||
|
angle,
|
||||||
|
roundness,
|
||||||
|
rotation_random,
|
||||||
|
drawing_angle,
|
||||||
|
include_first_dab,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ use crate::Engine;
|
|||||||
use hcie_protocol::{BrushStyle, BrushTip};
|
use hcie_protocol::{BrushStyle, BrushTip};
|
||||||
|
|
||||||
impl Engine {
|
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.
|
/// 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
|
/// `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.color_variant,
|
||||||
tip.variant_amount,
|
tip.variant_amount,
|
||||||
tip.density,
|
tip.density,
|
||||||
|
tip.jitter_amount,
|
||||||
|
tip.scatter_amount,
|
||||||
|
tip.angle,
|
||||||
|
tip.roundness,
|
||||||
|
tip.rotation_random,
|
||||||
|
tip.drawing_angle,
|
||||||
|
false,
|
||||||
);
|
);
|
||||||
layer.dirty = true;
|
layer.dirty = true;
|
||||||
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
if !layer.effects.is_empty() || !layer.styles.is_empty() {
|
||||||
*layer.effects_cache.lock().unwrap() = None;
|
*layer.effects_cache.lock().unwrap() = None;
|
||||||
}
|
}
|
||||||
let dirty_r = match tip.style {
|
let dirty_r = Self::brush_dirty_radius(&tip);
|
||||||
// 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),
|
|
||||||
};
|
|
||||||
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
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.document.expand_dirty(x as u32, y as u32, dirty_r);
|
||||||
self.expand_stroke_bounds(lx as u32, ly 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.color_variant,
|
||||||
tip.variant_amount,
|
tip.variant_amount,
|
||||||
tip.density,
|
tip.density,
|
||||||
|
tip.jitter_amount,
|
||||||
|
tip.scatter_amount,
|
||||||
|
tip.angle,
|
||||||
|
tip.roundness,
|
||||||
|
tip.rotation_random,
|
||||||
|
tip.drawing_angle,
|
||||||
|
true,
|
||||||
);
|
);
|
||||||
layer.dirty = true;
|
layer.dirty = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -297,15 +317,7 @@ impl Engine {
|
|||||||
}
|
}
|
||||||
self.document.composite_dirty = true;
|
self.document.composite_dirty = true;
|
||||||
self.document.modified = true;
|
self.document.modified = true;
|
||||||
let dirty_r = match tip.style {
|
let dirty_r = Self::brush_dirty_radius(&tip);
|
||||||
// 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),
|
|
||||||
};
|
|
||||||
for &(px, py, _) in points {
|
for &(px, py, _) in points {
|
||||||
if px >= 0.0
|
if px >= 0.0
|
||||||
&& py >= 0.0
|
&& py >= 0.0
|
||||||
@@ -322,7 +334,8 @@ impl Engine {
|
|||||||
"Brush Stroke ({})",
|
"Brush Stroke ({})",
|
||||||
crate::stroke_brush::brush_style_label(tip.style)
|
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::WetPaint
|
||||||
| BrushStyle::Leaf
|
| BrushStyle::Leaf
|
||||||
| BrushStyle::Mixer
|
| 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",
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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_len(), history_before + 1);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engine.history_description(history_before).as_deref(),
|
engine.history_description(history_before).as_deref(),
|
||||||
Some("Add Vector Shape")
|
Some("Add Vector Shape (Rect)")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
engine
|
engine
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "hcie-digital-brushes"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||||
@@ -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<BrushPreset> {
|
||||||
|
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<VectorBrushPreset> {
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "hcie-dry-media-brushes"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||||
@@ -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<BrushPreset> {
|
||||||
|
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::<HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
19
|
||||||
|
);
|
||||||
|
assert!(presets
|
||||||
|
.iter()
|
||||||
|
.all(|preset| preset.style == preset.tip.style));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ path = "src/main.rs"
|
|||||||
hcie-build-info = { workspace = true }
|
hcie-build-info = { workspace = true }
|
||||||
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||||
hcie-watercolor-brushes = { path = "../hcie-watercolor-brushes" }
|
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-panel-adapter = { path = "../iced-panel-adapter" }
|
||||||
iced = { workspace = true }
|
iced = { workspace = true }
|
||||||
env_logger = { workspace = true }
|
env_logger = { workspace = true }
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ use crate::panels::styles;
|
|||||||
use crate::theme::ThemeColors;
|
use crate::theme::ThemeColors;
|
||||||
use crate::widgets::plain_slider::plain_slider;
|
use crate::widgets::plain_slider::plain_slider;
|
||||||
use hcie_engine_api::BrushStyle;
|
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 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.
|
/// One built-in brush style exposed by the engine.
|
||||||
struct BrushStyleEntry {
|
struct BrushStyleEntry {
|
||||||
@@ -209,8 +212,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Cached brush preview images: (style_index, pixels).
|
/// Cached brush preview handles keyed by engine style index.
|
||||||
static BRUSH_PREVIEW_CACHE: OnceLock<Vec<(usize, Vec<u8>)>> = OnceLock::new();
|
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
|
||||||
|
OnceLock::new();
|
||||||
|
|
||||||
/// Thumbnail size in pixels.
|
/// Thumbnail size in pixels.
|
||||||
const THUMB_SIZE: u32 = 64;
|
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.
|
/// Generate a brush stroke preview as RGBA pixels.
|
||||||
///
|
///
|
||||||
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
|
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
|
||||||
@@ -384,26 +400,20 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
|
|||||||
/// Get or generate brush preview for a style.
|
/// Get or generate brush preview for a style.
|
||||||
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
|
||||||
let style_idx = style as usize;
|
let style_idx = style as usize;
|
||||||
|
let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
|
||||||
// Check cache
|
let mut cache = cache
|
||||||
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
.lock()
|
||||||
if let Some((_, pixels)) = cache.iter().find(|(idx, _)| *idx == style_idx) {
|
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels.clone());
|
if let Some(handle) = cache.get(&style_idx) {
|
||||||
}
|
return handle.clone();
|
||||||
}
|
}
|
||||||
|
let handle = iced::widget::image::Handle::from_rgba(
|
||||||
// Generate preview
|
THUMB_SIZE,
|
||||||
let pixels = generate_brush_preview(style);
|
THUMB_SIZE,
|
||||||
|
generate_brush_preview(style),
|
||||||
// Store in cache (ignore error if already set)
|
);
|
||||||
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
|
cache.insert(style_idx, handle.clone());
|
||||||
let mut new_cache = cache.clone();
|
handle
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the brushes panel with visual thumbnails.
|
/// Build the brushes panel with visual thumbnails.
|
||||||
@@ -424,7 +434,11 @@ pub fn view(
|
|||||||
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
|
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
|
||||||
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
|
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
|
||||||
(BrushCategory::Painting, "Painting", "icons/brush.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::Effects, "Effects", "icons/glow.svg"),
|
||||||
(BrushCategory::Custom, "Custom", "icons/star.svg"),
|
(BrushCategory::Custom, "Custom", "icons/star.svg"),
|
||||||
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
|
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
|
||||||
@@ -556,7 +570,9 @@ pub fn view(
|
|||||||
let mut wc_row = row![].spacing(4);
|
let mut wc_row = row![].spacing(4);
|
||||||
for (idx, preset) in wc_presets.iter().enumerate() {
|
for (idx, preset) in wc_presets.iter().enumerate() {
|
||||||
let is_selected = preset_style(preset.style) == current_style
|
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 c = colors;
|
||||||
let wc_color: [u8; 3] = match preset.id.as_str() {
|
let wc_color: [u8; 3] = match preset.id.as_str() {
|
||||||
"wc_wash" => [100, 150, 200],
|
"wc_wash" => [100, 150, 200],
|
||||||
@@ -571,11 +587,8 @@ pub fn view(
|
|||||||
};
|
};
|
||||||
let splat_pixels =
|
let splat_pixels =
|
||||||
hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color);
|
hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color);
|
||||||
let preview = iced::widget::image::Handle::from_rgba(
|
let preview =
|
||||||
THUMB_SIZE,
|
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, splat_pixels);
|
||||||
THUMB_SIZE,
|
|
||||||
splat_pixels,
|
|
||||||
);
|
|
||||||
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
let thumb = container(iced::widget::image(preview).width(48).height(48))
|
||||||
.width(56)
|
.width(56)
|
||||||
.height(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);
|
let mut imported_rows = column![].spacing(3);
|
||||||
if matches!(
|
if matches!(
|
||||||
active_category,
|
active_category,
|
||||||
@@ -751,7 +822,8 @@ pub fn view(
|
|||||||
let panel = column![
|
let panel = column![
|
||||||
tabs,
|
tabs,
|
||||||
horizontal_rule(1),
|
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),
|
horizontal_rule(1),
|
||||||
row![button(text("Import ABR").size(10))
|
row![button(text("Import ABR").size(10))
|
||||||
.on_press(Message::BrushImportAbr)
|
.on_press(Message::BrushImportAbr)
|
||||||
@@ -796,6 +868,7 @@ pub fn view(
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{category_matches_style, BrushCategory, BRUSH_STYLES};
|
use super::{category_matches_style, BrushCategory, BRUSH_STYLES};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn catalog_contains_every_engine_brush_style() {
|
fn catalog_contains_every_engine_brush_style() {
|
||||||
@@ -821,4 +894,23 @@ mod tests {
|
|||||||
BrushCategory::Imported
|
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::<HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
presets.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,11 +342,7 @@ fn all_menus(recent_files: &[crate::app::RecentFileEntry]) -> Vec<MenuDef> {
|
|||||||
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
|
MenuItem::command_with_shortcut("Paste", "Ctrl+V", MenuCommand::Paste),
|
||||||
MenuItem::command("Clear", MenuCommand::ClearPixels),
|
MenuItem::command("Clear", MenuCommand::ClearPixels),
|
||||||
MenuItem::separator(),
|
MenuItem::separator(),
|
||||||
MenuItem::command_with_shortcut(
|
MenuItem::command_with_shortcut("Paste Special", "", MenuCommand::PasteSpecial),
|
||||||
"Paste Special",
|
|
||||||
"",
|
|
||||||
MenuCommand::PasteSpecial,
|
|
||||||
),
|
|
||||||
MenuItem::command_with_shortcut(
|
MenuItem::command_with_shortcut(
|
||||||
"Paste as New Layer",
|
"Paste as New Layer",
|
||||||
"Ctrl+Shift+V",
|
"Ctrl+Shift+V",
|
||||||
@@ -1281,6 +1277,7 @@ mod tests {
|
|||||||
"Edit/Paste",
|
"Edit/Paste",
|
||||||
"Edit/Clear",
|
"Edit/Clear",
|
||||||
"Edit/Paste Special",
|
"Edit/Paste Special",
|
||||||
|
"Edit/Paste as New Layer",
|
||||||
"Edit/Fill...",
|
"Edit/Fill...",
|
||||||
"Edit/Stroke...",
|
"Edit/Stroke...",
|
||||||
"Edit/Free Transform",
|
"Edit/Free Transform",
|
||||||
|
|||||||
@@ -92,15 +92,19 @@ fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() {
|
|||||||
let app = source("src/app.rs");
|
let app = source("src/app.rs");
|
||||||
let canvas = source("src/canvas/mod.rs");
|
let canvas = source("src/canvas/mod.rs");
|
||||||
let shader = source("src/canvas/shader_canvas.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 state = source("src/selection/state.rs");
|
||||||
let clipboard = source("src/selection/clipboard.rs");
|
let clipboard = source("src/selection/clipboard.rs");
|
||||||
let crop = source("src/selection/crop.rs");
|
let crop = source("src/selection/crop.rs");
|
||||||
let raster = source("src/raster.rs");
|
let raster = source("src/raster.rs");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
state.contains("pub fn selected_spans")
|
state.contains("pub fn encode_selection_texture")
|
||||||
&& canvas.contains("selection_fill_spans")
|
&& shader.contains("selection_overlay_uses_one_texture_sample")
|
||||||
&& canvas.contains("Fill exact mask runs"),
|
&& canvas_shader
|
||||||
|
.matches("textureSample(selection_texture")
|
||||||
|
.count()
|
||||||
|
== 1,
|
||||||
"{SELECTION_IRREGULAR_EDGES}"
|
"{SELECTION_IRREGULAR_EDGES}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "hcie-ink-brushes"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||||
@@ -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<BrushPreset> {
|
||||||
|
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::<HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
8
|
||||||
|
);
|
||||||
|
assert!(presets
|
||||||
|
.iter()
|
||||||
|
.all(|preset| preset.style == preset.tip.style));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[package]
|
||||||
|
name = "hcie-paint-brushes"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
hcie-engine-api = { path = "../../../hcie-engine-api" }
|
||||||
@@ -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<BrushPreset> {
|
||||||
|
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::<HashSet<_>>()
|
||||||
|
.len(),
|
||||||
|
7
|
||||||
|
);
|
||||||
|
assert!(presets
|
||||||
|
.iter()
|
||||||
|
.all(|preset| preset.style == preset.tip.style));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,9 @@ use hcie_engine_api::BrushPreset;
|
|||||||
|
|
||||||
/// Hash-based pseudo-random value noise. Returns a value in `0.0..=1.0`.
|
/// Hash-based pseudo-random value noise. Returns a value in `0.0..=1.0`.
|
||||||
fn hash_noise(x: i32, y: i32) -> f32 {
|
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(13))).wrapping_mul(1274126177);
|
||||||
h = h ^ h.wrapping_shr(16);
|
h = h ^ h.wrapping_shr(16);
|
||||||
(h & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32
|
(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);
|
return (0.0, 0.0, l);
|
||||||
}
|
}
|
||||||
let d = max - min;
|
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 {
|
let h = if max == rf {
|
||||||
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) * 60.0
|
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) * 60.0
|
||||||
} else if max == gf {
|
} else if max == gf {
|
||||||
@@ -152,11 +158,7 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
|||||||
|
|
||||||
// More aggressive noise displacement for ragged edges
|
// More aggressive noise displacement for ragged edges
|
||||||
let a = dy.atan2(dx);
|
let a = dy.atan2(dx);
|
||||||
let noise_val = fbm(
|
let noise_val = fbm(a * 3.0 + sx * 10.0, dist / radius * 1.5 + sy * 7.0, 4);
|
||||||
a * 3.0 + sx * 10.0,
|
|
||||||
dist / radius * 1.5 + sy * 7.0,
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
let displaced = radius * (0.55 + 0.45 * noise_val);
|
let displaced = radius * (0.55 + 0.45 * noise_val);
|
||||||
|
|
||||||
if dist > displaced {
|
if dist > displaced {
|
||||||
@@ -176,7 +178,11 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
|
|||||||
|
|
||||||
// Granulation / paper texture in outer region
|
// Granulation / paper texture in outer region
|
||||||
if t > 0.25 {
|
if t > 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);
|
let gran_mask = ((t - 0.25) / 0.75).min(1.0);
|
||||||
alpha *= 0.65 + 0.35 * gran * gran_mask;
|
alpha *= 0.65 + 0.35 * gran * gran_mask;
|
||||||
}
|
}
|
||||||
@@ -296,7 +302,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_wash".into(),
|
id: "wc_wash".into(),
|
||||||
name: "Watercolor Wash".into(),
|
name: "Watercolor Wash".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: false,
|
pressure_size: false,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -308,7 +318,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_wet".into(),
|
id: "wc_wet".into(),
|
||||||
name: "Wet Watercolor".into(),
|
name: "Wet Watercolor".into(),
|
||||||
category: "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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: false,
|
pressure_size: false,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -320,7 +334,12 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_dry".into(),
|
id: "wc_dry".into(),
|
||||||
name: "Dry Brush".into(),
|
name: "Dry Brush".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: true,
|
pressure_size: true,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -332,7 +351,10 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_glaze".into(),
|
id: "wc_glaze".into(),
|
||||||
name: "Glazing".into(),
|
name: "Glazing".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: false,
|
pressure_size: false,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -344,7 +366,13 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_splat".into(),
|
id: "wc_splat".into(),
|
||||||
name: "Splatter".into(),
|
name: "Splatter".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: true,
|
pressure_size: true,
|
||||||
pressure_opacity: false,
|
pressure_opacity: false,
|
||||||
@@ -356,7 +384,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_bleed".into(),
|
id: "wc_bleed".into(),
|
||||||
name: "Color Bleed".into(),
|
name: "Color Bleed".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: false,
|
pressure_size: false,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -368,7 +400,10 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_grain".into(),
|
id: "wc_grain".into(),
|
||||||
name: "Granulation".into(),
|
name: "Granulation".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: true,
|
pressure_size: true,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
@@ -380,7 +415,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
|
|||||||
id: "wc_bloom".into(),
|
id: "wc_bloom".into(),
|
||||||
name: "Bloom".into(),
|
name: "Bloom".into(),
|
||||||
category: "Watercolor".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,
|
style: BrushStyle::Watercolor,
|
||||||
pressure_size: false,
|
pressure_size: false,
|
||||||
pressure_opacity: true,
|
pressure_opacity: true,
|
||||||
|
|||||||
Executable
+16
@@ -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"
|
||||||
Executable
+54
@@ -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."
|
||||||
Reference in New Issue
Block a user