feat: Add digital, dry, ink, and paint brush presets; implement regression testing hooks
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled

- 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:
Your Name
2026-07-23 02:28:49 +03:00
parent 2d5b7a37e8
commit e509def0b2
26 changed files with 2446 additions and 335 deletions
@@ -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-engine-api = { path = "../../../hcie-engine-api" }
hcie-watercolor-brushes = { path = "../hcie-watercolor-brushes" }
hcie-dry-media-brushes = { path = "../hcie-dry-media-brushes" }
hcie-ink-brushes = { path = "../hcie-ink-brushes" }
hcie-paint-brushes = { path = "../hcie-paint-brushes" }
hcie-digital-brushes = { path = "../hcie-digital-brushes" }
iced-panel-adapter = { path = "../iced-panel-adapter" }
iced = { workspace = true }
env_logger = { workspace = true }
@@ -13,9 +13,12 @@ use crate::panels::styles;
use crate::theme::ThemeColors;
use crate::widgets::plain_slider::plain_slider;
use hcie_engine_api::BrushStyle;
use iced::widget::{button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text};
use iced::widget::{
button, checkbox, column, container, horizontal_rule, row, scrollable, svg, text,
};
use iced::{Element, Length};
use std::sync::OnceLock;
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// One built-in brush style exposed by the engine.
struct BrushStyleEntry {
@@ -209,8 +212,9 @@ const BRUSH_STYLES: &[BrushStyleEntry] = &[
},
];
/// Cached brush preview images: (style_index, pixels).
static BRUSH_PREVIEW_CACHE: OnceLock<Vec<(usize, Vec<u8>)>> = OnceLock::new();
/// Cached brush preview handles keyed by engine style index.
static BRUSH_PREVIEW_CACHE: OnceLock<Mutex<HashMap<usize, iced::widget::image::Handle>>> =
OnceLock::new();
/// Thumbnail size in pixels.
const THUMB_SIZE: u32 = 64;
@@ -274,6 +278,18 @@ fn preset_style(style: hcie_engine_api::brush::BrushStyle) -> BrushStyle {
}
}
/// Maps a named media preset category into the panel's compact tab model.
///
/// **Arguments:** `category` is supplied by a media preset crate. **Returns:** The matching panel
/// filter. **Side Effects / Dependencies:** None.
fn media_preset_category(category: &str) -> BrushCategory {
match category {
"Opaque Watermedia" | "Oils" | "Acrylics" | "Tempera" => BrushCategory::Painting,
"Digital Blend" | "Digital Texture" | "Digital FX" => BrushCategory::Effects,
_ => BrushCategory::Drawing,
}
}
/// Generate a brush stroke preview as RGBA pixels.
///
/// Draws a sine-wave stroke with varying thickness to show the brush characteristics.
@@ -384,26 +400,20 @@ fn generate_brush_preview(style: BrushStyle) -> Vec<u8> {
/// Get or generate brush preview for a style.
fn get_brush_preview(style: BrushStyle) -> iced::widget::image::Handle {
let style_idx = style as usize;
// Check cache
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
if let Some((_, pixels)) = cache.iter().find(|(idx, _)| *idx == style_idx) {
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels.clone());
}
let cache = BRUSH_PREVIEW_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
let mut cache = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(handle) = cache.get(&style_idx) {
return handle.clone();
}
// Generate preview
let pixels = generate_brush_preview(style);
// Store in cache (ignore error if already set)
if let Some(cache) = BRUSH_PREVIEW_CACHE.get() {
let mut new_cache = cache.clone();
new_cache.push((style_idx, pixels.clone()));
// Can't replace OnceLock, so we just use the local copy
return iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels);
}
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, pixels)
let handle = iced::widget::image::Handle::from_rgba(
THUMB_SIZE,
THUMB_SIZE,
generate_brush_preview(style),
);
cache.insert(style_idx, handle.clone());
handle
}
/// Build the brushes panel with visual thumbnails.
@@ -424,7 +434,11 @@ pub fn view(
(BrushCategory::All, "All brushes", "icons/panel-brush.svg"),
(BrushCategory::Drawing, "Drawing", "icons/pen.svg"),
(BrushCategory::Painting, "Painting", "icons/brush.svg"),
(BrushCategory::Watercolor, "Watercolor", "icons/watercolor.svg"),
(
BrushCategory::Watercolor,
"Watercolor",
"icons/watercolor.svg",
),
(BrushCategory::Effects, "Effects", "icons/glow.svg"),
(BrushCategory::Custom, "Custom", "icons/star.svg"),
(BrushCategory::Imported, "Imported", "icons/import_abr.svg"),
@@ -556,7 +570,9 @@ pub fn view(
let mut wc_row = row![].spacing(4);
for (idx, preset) in wc_presets.iter().enumerate() {
let is_selected = preset_style(preset.style) == current_style
&& active_brush_preset.as_ref().map_or(false, |p| p.id == preset.id);
&& active_brush_preset
.as_ref()
.map_or(false, |p| p.id == preset.id);
let c = colors;
let wc_color: [u8; 3] = match preset.id.as_str() {
"wc_wash" => [100, 150, 200],
@@ -571,11 +587,8 @@ pub fn view(
};
let splat_pixels =
hcie_watercolor_brushes::render_watercolor_splat(preset.style, wc_color);
let preview = iced::widget::image::Handle::from_rgba(
THUMB_SIZE,
THUMB_SIZE,
splat_pixels,
);
let preview =
iced::widget::image::Handle::from_rgba(THUMB_SIZE, THUMB_SIZE, splat_pixels);
let thumb = container(iced::widget::image(preview).width(48).height(48))
.width(56)
.height(56)
@@ -623,6 +636,64 @@ pub fn view(
}
}
// Traditional and digital media catalogs live in independent crates and expose only public
// engine-API preset data. This keeps catalog growth out of the GUI and engine internals.
let mut media_presets = hcie_dry_media_brushes::dry_media_presets();
media_presets.extend(hcie_ink_brushes::ink_presets());
media_presets.extend(hcie_paint_brushes::paint_presets());
media_presets.extend(hcie_digital_brushes::digital_presets());
let filtered_media: Vec<_> = media_presets
.into_iter()
.filter(|preset| {
active_category == BrushCategory::All
|| active_category == media_preset_category(&preset.category)
})
.collect();
let mut media_rows = column![].spacing(4);
let mut media_row = row![].spacing(4);
for (idx, preset) in filtered_media.iter().enumerate() {
let style = preset_style(preset.style);
let selected = style == current_style
&& active_brush_preset.is_some_and(|active| active.id == preset.id);
let preview = get_brush_preview(style);
let preset_message = preset.clone();
let c = colors;
let cell = column![
container(iced::widget::image(preview).width(48).height(48))
.width(56)
.height(56)
.center_x(48)
.center_y(48)
.style(move |_theme| styles::raised_card(c, selected)),
container(text(preset.name.clone()).size(9))
.width(Length::Fill)
.center_x(Length::Fill),
]
.spacing(2)
.align_x(iced::Alignment::Center);
media_row = media_row.push(
button(cell)
.on_press(Message::BrushPresetSelected(preset_message))
.padding(2)
.style(move |_theme, status| iced::widget::button::Style {
background: Some(iced::Background::Color(
if status == iced::widget::button::Status::Hovered {
c.bg_hover
} else {
iced::Color::TRANSPARENT
},
)),
text_color: c.text_primary,
border: iced::Border::default(),
..Default::default()
}),
);
if (idx + 1) % 2 == 0 || idx == filtered_media.len() - 1 {
media_rows = media_rows.push(media_row);
media_row = row![].spacing(4);
}
}
let mut imported_rows = column![].spacing(3);
if matches!(
active_category,
@@ -751,7 +822,8 @@ pub fn view(
let panel = column![
tabs,
horizontal_rule(1),
scrollable(column![grid_rows, wc_rows, imported_rows].spacing(6)).height(Length::Fill),
scrollable(column![grid_rows, wc_rows, media_rows, imported_rows].spacing(6))
.height(Length::Fill),
horizontal_rule(1),
row![button(text("Import ABR").size(10))
.on_press(Message::BrushImportAbr)
@@ -796,6 +868,7 @@ pub fn view(
#[cfg(test)]
mod tests {
use super::{category_matches_style, BrushCategory, BRUSH_STYLES};
use std::collections::HashSet;
#[test]
fn catalog_contains_every_engine_brush_style() {
@@ -821,4 +894,23 @@ mod tests {
BrushCategory::Imported
));
}
/// Locks the reference-derived crate inventory and prevents preset ID collisions.
#[test]
fn media_crates_expose_complete_unique_catalog() {
let mut presets = hcie_dry_media_brushes::dry_media_presets();
presets.extend(hcie_ink_brushes::ink_presets());
presets.extend(hcie_paint_brushes::paint_presets());
presets.extend(hcie_digital_brushes::digital_presets());
presets.extend(hcie_watercolor_brushes::watercolor_presets());
assert_eq!(presets.len(), 47);
assert_eq!(
presets
.iter()
.map(|preset| preset.id.as_str())
.collect::<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("Clear", MenuCommand::ClearPixels),
MenuItem::separator(),
MenuItem::command_with_shortcut(
"Paste Special",
"",
MenuCommand::PasteSpecial,
),
MenuItem::command_with_shortcut("Paste Special", "", MenuCommand::PasteSpecial),
MenuItem::command_with_shortcut(
"Paste as New Layer",
"Ctrl+Shift+V",
@@ -1281,6 +1277,7 @@ mod tests {
"Edit/Paste",
"Edit/Clear",
"Edit/Paste Special",
"Edit/Paste as New Layer",
"Edit/Fill...",
"Edit/Stroke...",
"Edit/Free Transform",
@@ -92,15 +92,19 @@ fn cycle_three_canvas_selection_clipboard_crop_text_paths_are_connected() {
let app = source("src/app.rs");
let canvas = source("src/canvas/mod.rs");
let shader = source("src/canvas/shader_canvas.rs");
let canvas_shader = source("src/canvas/canvas.wgsl");
let state = source("src/selection/state.rs");
let clipboard = source("src/selection/clipboard.rs");
let crop = source("src/selection/crop.rs");
let raster = source("src/raster.rs");
assert!(
state.contains("pub fn selected_spans")
&& canvas.contains("selection_fill_spans")
&& canvas.contains("Fill exact mask runs"),
state.contains("pub fn encode_selection_texture")
&& shader.contains("selection_overlay_uses_one_texture_sample")
&& canvas_shader
.matches("textureSample(selection_texture")
.count()
== 1,
"{SELECTION_IRREGULAR_EDGES}"
);
assert!(
@@ -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`.
fn hash_noise(x: i32, y: i32) -> f32 {
let mut h = x.wrapping_mul(374761393).wrapping_add(y.wrapping_mul(668265263));
let mut h = x
.wrapping_mul(374761393)
.wrapping_add(y.wrapping_mul(668265263));
h = (h ^ (h.wrapping_shr(13))).wrapping_mul(1274126177);
h = h ^ h.wrapping_shr(16);
(h & 0x00FF_FFFF) as f32 / 0x00FF_FFFF as f32
@@ -93,7 +95,11 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
return (0.0, 0.0, l);
}
let d = max - min;
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
let s = if l > 0.5 {
d / (2.0 - max - min)
} else {
d / (max + min)
};
let h = if max == rf {
((gf - bf) / d + if gf < bf { 6.0 } else { 0.0 }) * 60.0
} else if max == gf {
@@ -152,11 +158,7 @@ pub fn render_watercolor_splat(_style: BrushStyle, base_color: [u8; 3]) -> Vec<u
// More aggressive noise displacement for ragged edges
let a = dy.atan2(dx);
let noise_val = fbm(
a * 3.0 + sx * 10.0,
dist / radius * 1.5 + sy * 7.0,
4,
);
let noise_val = fbm(a * 3.0 + sx * 10.0, dist / radius * 1.5 + sy * 7.0, 4);
let displaced = radius * (0.55 + 0.45 * noise_val);
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
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);
alpha *= 0.65 + 0.35 * gran * gran_mask;
}
@@ -296,7 +302,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_wash".into(),
name: "Watercolor Wash".into(),
category: "Watercolor".into(),
tip: wc_tip(80.0, 0.25, 0.0, 0.08),
tip: BrushTip {
density: 0.75,
roundness: 0.82,
..wc_tip(80.0, 0.25, 0.0, 0.08)
},
style: BrushStyle::Watercolor,
pressure_size: false,
pressure_opacity: true,
@@ -308,7 +318,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_wet".into(),
name: "Wet Watercolor".into(),
category: "Watercolor".into(),
tip: wc_tip(60.0, 0.35, 0.0, 0.06),
tip: BrushTip {
density: 1.0,
jitter_amount: 0.03,
..wc_tip(60.0, 0.35, 0.0, 0.06)
},
style: BrushStyle::Watercolor,
pressure_size: false,
pressure_opacity: true,
@@ -320,7 +334,12 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_dry".into(),
name: "Dry Brush".into(),
category: "Watercolor".into(),
tip: wc_tip(30.0, 0.70, 0.6, 0.12),
tip: BrushTip {
density: 0.55,
roundness: 0.42,
drawing_angle: true,
..wc_tip(30.0, 0.70, 0.6, 0.12)
},
style: BrushStyle::Watercolor,
pressure_size: true,
pressure_opacity: true,
@@ -332,7 +351,10 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_glaze".into(),
name: "Glazing".into(),
category: "Watercolor".into(),
tip: wc_tip(50.0, 0.15, 0.0, 0.10),
tip: BrushTip {
density: 0.85,
..wc_tip(50.0, 0.15, 0.0, 0.10)
},
style: BrushStyle::Watercolor,
pressure_size: false,
pressure_opacity: true,
@@ -344,7 +366,13 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_splat".into(),
name: "Splatter".into(),
category: "Watercolor".into(),
tip: wc_tip(40.0, 0.50, 0.2, 0.30),
tip: BrushTip {
density: 0.5,
jitter_amount: 0.4,
scatter_amount: 0.9,
rotation_random: 1.0,
..wc_tip(40.0, 0.50, 0.2, 0.30)
},
style: BrushStyle::Watercolor,
pressure_size: true,
pressure_opacity: false,
@@ -356,7 +384,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_bleed".into(),
name: "Color Bleed".into(),
category: "Watercolor".into(),
tip: wc_tip(70.0, 0.30, 0.0, 0.07),
tip: BrushTip {
density: 1.1,
scatter_amount: 0.15,
..wc_tip(70.0, 0.30, 0.0, 0.07)
},
style: BrushStyle::Watercolor,
pressure_size: false,
pressure_opacity: true,
@@ -368,7 +400,10 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_grain".into(),
name: "Granulation".into(),
category: "Watercolor".into(),
tip: wc_tip(45.0, 0.40, 0.3, 0.09),
tip: BrushTip {
density: 1.5,
..wc_tip(45.0, 0.40, 0.3, 0.09)
},
style: BrushStyle::Watercolor,
pressure_size: true,
pressure_opacity: true,
@@ -380,7 +415,11 @@ pub fn watercolor_presets() -> Vec<BrushPreset> {
id: "wc_bloom".into(),
name: "Bloom".into(),
category: "Watercolor".into(),
tip: wc_tip(90.0, 0.20, 0.0, 0.05),
tip: BrushTip {
density: 0.65,
scatter_amount: 0.25,
..wc_tip(90.0, 0.20, 0.0, 0.05)
},
style: BrushStyle::Watercolor,
pressure_size: false,
pressure_opacity: true,