feat: Add theme and font management for egui GUI

- Introduced `theme.rs` to handle visual themes, font discovery, and application of theme colors.
- Implemented system font registration for Linux, macOS, and Windows.
- Added `apply_theme` function to configure egui visuals based on selected theme presets.

feat: Implement interactive tool state management

- Created `tool_state.rs` to manage runtime tool, selection, and transform states.
- Defined `ToolState` struct to encapsulate active tool, color settings, drawing states, and AI panel states.
- Included functionality for managing brush presets, text editing, and selection transformations.

feat: Add utility functions for image and file handling

- Developed `utils.rs` with stateless helper functions for color formatting, drawing icons, and cropping images.
- Implemented save dialog builders and error handling for file operations.
- Added flood fill functionality for composite RGBA pixels.

test: Add unit test for bitmap brush stamp functionality

- Created `bitmap_brush_stamp.rs` to verify the behavior of bitmap brush stamping in the engine.
- Ensured that the painted area matches expected dimensions and characteristics.
This commit is contained in:
2026-07-10 01:23:47 +03:00
parent e88a2be508
commit 07a9b35208
14 changed files with 2250 additions and 1523 deletions
+84 -4
View File
@@ -419,6 +419,27 @@ pub fn draw_dab(
opacity: f32,
is_eraser: bool,
mask: Option<&[u8]>,
) {
draw_dab_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, None);
}
/// Apply a single brush dab, optionally using a precomputed bitmap stamp.
/// When `stamp` is provided it overrides the procedural circle/square shape.
/// `stamp_diameter` is the width/height of the square stamp buffer.
#[allow(clippy::too_many_arguments)]
fn draw_dab_with_stamp(
pixels: &mut [u8],
width: u32,
height: u32,
cx: f32,
cy: f32,
size: f32,
hardness: f32,
color: [u8; 4],
opacity: f32,
is_eraser: bool,
mask: Option<&[u8]>,
stamp: Option<&[u8]>,
) {
let r = (size / 2.0).max(0.5);
let r_sq = r * r;
@@ -432,6 +453,10 @@ pub fn draw_dab(
let [fr, fg, fb, _] = color;
// If a stamp is supplied, scale its local coordinates to the requested dab size.
let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 };
let stamp_f = stamp_d as f32;
for py in y_min..=y_max {
for px in x_min..=x_max {
let dx = px as f32 - cx;
@@ -449,12 +474,27 @@ pub fn draw_dab(
};
if mask_val == 0 { continue; }
let alpha_factor = if d_sq <= hard_sq {
let alpha_factor = if let Some(s) = stamp {
if stamp_d == 0 {
0.0
} else {
// Map dab footprint back into stamp coordinates.
let sx = ((dx + r) * stamp_f / size).round() as isize;
let sy = ((dy + r) * stamp_f / size).round() as isize;
if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d {
0.0
} else {
s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0
}
}
} else if d_sq <= hard_sq {
1.0
} else {
(1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0)
};
if alpha_factor <= 0.0 { continue; }
let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0);
let brush_alpha = (dab_alpha * 255.0).round() as u8;
let i = idx * 4;
@@ -586,12 +626,34 @@ pub fn draw_dab_capped(
opacity: f32,
is_eraser: bool,
mask: Option<&[u8]>,
mut stroke_mask: Option<&mut [u8]>,
stroke_mask: Option<&mut [u8]>,
bg_pixels: Option<&[u8]>,
max_stroke_opacity: f32,
) {
draw_dab_capped_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stroke_mask, bg_pixels, max_stroke_opacity, None);
}
/// `draw_dab_capped` variant that uses a pre-generated bitmap stamp for the dab shape.
#[allow(clippy::too_many_arguments)]
pub fn draw_dab_capped_with_stamp(
pixels: &mut [u8],
width: u32,
height: u32,
cx: f32,
cy: f32,
size: f32,
hardness: f32,
color: [u8; 4],
opacity: f32,
is_eraser: bool,
mask: Option<&[u8]>,
mut stroke_mask: Option<&mut [u8]>,
bg_pixels: Option<&[u8]>,
max_stroke_opacity: f32,
stamp: Option<&[u8]>,
) {
if stroke_mask.is_none() || bg_pixels.is_none() {
draw_dab(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask);
draw_dab_with_stamp(pixels, width, height, cx, cy, size, hardness, color, opacity, is_eraser, mask, stamp);
return;
}
@@ -609,6 +671,10 @@ pub fn draw_dab_capped(
let stroke_mask_ref = stroke_mask.as_mut().unwrap();
let bg = bg_pixels.unwrap();
// Bitmap stamp support: if a stamp is provided, use it as the per-pixel alpha mask.
let stamp_d = if let Some(s) = stamp { (s.len() as f32).sqrt().round() as usize } else { 0 };
let stamp_f = stamp_d as f32;
for py in y_min..=y_max {
for px in x_min..=x_max {
let dx = px as f32 - cx;
@@ -626,12 +692,26 @@ pub fn draw_dab_capped(
};
if mask_val == 0 { continue; }
let alpha_factor = if d_sq <= hard_sq {
let alpha_factor = if let Some(s) = stamp {
if stamp_d == 0 {
0.0
} else {
let sx = ((dx + r) * stamp_f / size).round() as isize;
let sy = ((dy + r) * stamp_f / size).round() as isize;
if sx < 0 || sy < 0 || sx as usize >= stamp_d || sy as usize >= stamp_d {
0.0
} else {
s[(sy as usize) * stamp_d + (sx as usize)] as f32 / 255.0
}
}
} else if d_sq <= hard_sq {
1.0
} else {
(1.0 - (d_sq - hard_sq) * soft_range_inv).clamp(0.0, 1.0)
};
if alpha_factor <= 0.0 { continue; }
let dab_alpha = alpha_factor * opacity * (color[3] as f32 / 255.0) * (mask_val as f32 / 255.0);
let i = idx * 4;
+17 -2
View File
@@ -64,6 +64,19 @@ pub fn draw_brush_stroke(
None
};
// Pre-generate a bitmap stamp once for the whole stroke. Bitmap brushes
// ignore the procedural shape and use the imported/sampled alpha mask.
let bitmap_stamp = if tip.style == BrushStyle::Bitmap
&& !tip.bitmap_pixels.is_empty()
&& tip.bitmap_w > 0
&& tip.bitmap_h > 0
{
Some(hcie_brush_engine::generate_brush_stamp(tip))
} else {
None
};
let stamp_ref = bitmap_stamp.as_deref();
let mut last_p = points[0];
let (jx0, jy0) = jitter_offset(tip.jitter_amount, tip.size);
let first_x = last_p.0 + jx0;
@@ -79,10 +92,11 @@ pub fn draw_brush_stroke(
first_color[0], first_color[1], first_color[2],
(first_color[3] as f32 * first_opacity).round() as u8,
];
hcie_brush_engine::draw_dab_capped(
hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height,
first_x, first_y, first_size, tip.hardness, first_px_color, 1.0, is_eraser, mask,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
stamp_ref,
);
for &p in &points[1..] {
@@ -116,10 +130,11 @@ pub fn draw_brush_stroke(
dab_color[0], dab_color[1], dab_color[2],
(dab_color[3] as f32 * pressure_opacity).round() as u8,
];
hcie_brush_engine::draw_dab_capped(
hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height,
final_x, final_y, effective_size, tip.hardness, px_color, 1.0, is_eraser, mask,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
stamp_ref,
);
}
@@ -0,0 +1,60 @@
//! AI chat and ComfyUI/vision configuration types.
//!
//! These small data-only structs describe the state used by the AI Assistant
//! panel and the optional ComfyUI/vision backend. They are grouped here so the
//! larger `ToolState` does not need to define them inline.
/// A single chat message in the AI Assistant panel.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct AiChatMessage {
pub role: String,
pub content: String,
}
/// URL/model pair used by the built-in AI chat client.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct AiChatConfig {
pub url: String,
pub model: String,
}
/// Last known ComfyUI server status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ComfyStatus {
#[default]
Unknown,
Running,
Error,
}
/// ComfyUI server endpoint configuration.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct ComfyConfig {
pub url: String,
}
/// Which ComfyUI workflow variant is active.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ComfyMode {
#[default]
Normal,
ObjectRemoval,
}
/// High-level vision/AI task selected in the UI.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VisionTask {
#[default]
Segmentation,
BackgroundRemoval,
Inpainting,
SuperResolution,
}
/// Preferred execution backend for vision pipelines.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum VisionBackend {
#[default]
Cpu,
Gpu,
}
@@ -0,0 +1,130 @@
//! Per-document runtime state.
//!
//! Each open image owns an `AppDocument` which wraps the engine instance, GPU
//! texture handles, viewport state, and bookkeeping such as source path and
//! modification status.
use crate::app::theme::default_font_bytes;
use crate::canvas::render::SelectionEdgeCache;
use eframe::egui;
use hcie_engine_api::Engine;
/// Current high-level application mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AppMode {
#[default]
Editor,
Viewer,
}
pub struct AppDocument {
pub engine: Engine,
pub composite_texture: Option<egui::TextureHandle>,
pub layer_textures: std::collections::HashMap<usize, egui::TextureHandle>,
pub composite_buffer: Vec<u8>,
pub zoom: f32,
pub name: String,
pub init_snapshot_needed: bool,
pub init_snapshot_at: Option<std::time::Instant>,
/// Original file path this document was loaded from, if any.
pub source_path: Option<std::path::PathBuf>,
/// Original format extension this document was loaded as, e.g. "psd" or "kra".
pub source_format: Option<String>,
/// Whether the document has been modified since it was last saved/loaded.
pub modified: bool,
pub history_brush_names: std::collections::HashMap<usize, String>,
/// Shared marching-ants edge cache for the selection overlay.
pub selection_edge_cache: SelectionEdgeCache,
/// Per-document pan offset for the canvas viewport.
/// Moved from ToolState to AppDocument so each document maintains its own
/// independent pan position, preventing cross-document interference.
pub pan_offset: egui::Vec2,
/// The last egui Context used to render/upload this document's GPU textures.
/// Used to detect when a panel is floated/docked (context change) and re-upload textures.
pub last_ctx: Option<egui::Context>,
}
impl AppDocument {
pub fn new(name: &str, w: u32, h: u32) -> Self {
let mut engine = Engine::new(w, h);
engine.set_modified(false);
if let Some((font_name, font_bytes)) = default_font_bytes() {
let _ = engine.load_font(&font_name, &font_bytes);
}
engine.pre_tile_all_layers();
Self {
engine,
composite_texture: None,
layer_textures: std::collections::HashMap::new(),
composite_buffer: vec![],
zoom: 1.0,
name: name.to_string(),
init_snapshot_needed: false,
init_snapshot_at: None,
source_path: None,
source_format: None,
modified: false,
history_brush_names: std::collections::HashMap::new(),
selection_edge_cache: SelectionEdgeCache::default(),
pan_offset: egui::Vec2::ZERO,
last_ctx: None,
}
}
pub fn record_history_description(&mut self, desc: String) {
let current = self.engine.history_current();
if current >= 0 {
let idx = current as usize;
self.history_brush_names.retain(|&k, _| k <= idx);
self.history_brush_names.insert(idx, desc);
}
}
pub fn save_file_name(&self) -> String {
if let Some(path) = &self.source_path {
path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| format!("{}.png", self.name))
} else {
format!("{}.png", self.name)
}
}
pub fn save_file_stem(&self) -> String {
if let Some(path) = &self.source_path {
path.file_stem()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| self.name.clone())
} else {
self.name.clone()
}
}
pub fn set_source_path(&mut self, path: std::path::PathBuf, format: &str) {
self.source_path = Some(path);
self.source_format = Some(format.to_lowercase());
self.modified = false;
}
pub fn mark_modified(&mut self) {
self.modified = true;
}
pub fn try_init_snapshot(&mut self) {
if !self.init_snapshot_needed {
return;
}
if let Some(at) = self.init_snapshot_at {
if std::time::Instant::now() < at {
return;
}
}
if self.engine.get_layer_count() == 0 {
self.engine.add_layer("Background");
}
log::debug!("[try_init_snapshot] Warming up engine allocations and Rayon threadpool");
self.engine.pre_tile_all_layers();
self.init_snapshot_needed = false;
self.init_snapshot_at = None;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
//! Persistent application settings and dock profiles.
//!
//! This module keeps user preferences such as theme, recent files, saved dock
//! layouts, and viewer-mode state. It is intentionally separate from runtime
//! tool state so it can be serialized independently.
use egui_panel_adapter::ThemePreset;
/// A single entry in the recent-files list.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct RecentFileEntry {
pub path: String,
pub name: String,
}
/// A saved dock layout including panel widths and pinned tabs.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct DockProfile {
pub name: String,
pub dock_state_json: String,
pub left_col1_w: f32,
pub left_col2_w: f32,
pub right_col1_w: f32,
pub right_col2_w: f32,
pub last_window_width: f32,
pub pinned_panels: Vec<String>,
}
/// Top-level persistent settings serialized by eframe on exit.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct Settings {
pub theme: ThemePreset,
/// When `true`, the operating system draws its own window decorations.
/// The custom title bar is always rendered regardless of this value,
/// so the out-of-box experience uses the unified custom bar.
#[serde(default = "default_show_native_decorations")]
pub show_native_decorations: bool,
pub debug_mode: bool,
#[serde(default)]
pub recent_files: Vec<RecentFileEntry>,
#[serde(default)]
pub dock_profiles: Vec<DockProfile>,
/// Last directory used in Viewer mode, for cross-session persistence.
#[serde(default)]
pub viewer_last_dir: Option<String>,
}
fn default_show_native_decorations() -> bool {
false
}
@@ -0,0 +1,250 @@
//! Visual theme and font setup for the egui GUI.
//!
//! This module centralizes:
//! - System font discovery and registration.
//! - Application of the shared `ThemeColors` / `ThemePreset` into egui visuals.
//! - Helper to retrieve raw default font bytes for the engine text renderer.
use eframe::egui;
pub use egui_panel_adapter::{ThemeColors, ThemePreset};
/// Register discovered system fonts into the egui context.
///
/// On Linux/macOS/Windows the first available system sans-serif font is loaded
/// and used as the default proportional font family so the UI matches the OS.
pub fn setup_custom_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
for (name, data) in discover_system_fonts() {
fonts
.font_data
.insert(name.clone(), std::sync::Arc::new(data));
// Override the default proportional font family so UI uses OS font
fonts
.families
.entry(egui::FontFamily::Proportional)
.or_default()
.insert(0, name);
}
ctx.set_fonts(fonts);
}
/// Load the first available system font and return its (file_name, bytes).
/// This is shared between egui font setup and engine text-renderer registration.
#[cfg(target_os = "linux")]
fn discover_system_fonts() -> Vec<(String, egui::FontData)> {
let mut res = Vec::new();
let paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
];
for path in &paths {
if let Ok(bytes) = std::fs::read(path) {
let name = path.rsplit('/').next().unwrap_or("system").to_string();
res.push((name, egui::FontData::from_owned(bytes)));
break;
}
}
res
}
#[cfg(target_os = "macos")]
fn discover_system_fonts() -> Vec<(String, egui::FontData)> {
let mut res = Vec::new();
let paths = [
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/HelveticaNeue.ttc",
"/Library/Fonts/Arial.ttf",
];
for path in &paths {
if let Ok(bytes) = std::fs::read(path) {
let name = path.rsplit('/').next().unwrap_or("system").to_string();
res.push((name, egui::FontData::from_owned(bytes)));
break;
}
}
res
}
#[cfg(target_os = "windows")]
fn discover_system_fonts() -> Vec<(String, egui::FontData)> {
let mut res = Vec::new();
let paths = [
"C:\\Windows\\Fonts\\arial.ttf",
"C:\\Windows\\Fonts\\segoeui.ttf",
"C:\\Windows\\Fonts\\calibri.ttf",
];
for path in &paths {
if let Ok(bytes) = std::fs::read(path) {
let name = path.rsplit('\\').next().unwrap_or("system").to_string();
res.push((name, egui::FontData::from_owned(bytes)));
break;
}
}
res
}
/// Returns the raw bytes and a normalized name for the first available system font.
/// Used to seed the engine's `TextRenderer` with a default font on startup.
pub fn default_font_bytes() -> Option<(String, Vec<u8>)> {
let paths: &[(&str, &str)] = &[
#[cfg(target_os = "linux")]
(
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"DejaVuSans.ttf",
),
#[cfg(target_os = "linux")]
(
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"LiberationSans-Regular.ttf",
),
#[cfg(target_os = "linux")]
(
"/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf",
"NotoSans-Regular.ttf",
),
#[cfg(target_os = "linux")]
(
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"FreeSans.ttf",
),
#[cfg(target_os = "macos")]
("/System/Library/Fonts/Helvetica.ttc", "Helvetica.ttc"),
#[cfg(target_os = "macos")]
(
"/System/Library/Fonts/HelveticaNeue.ttc",
"HelveticaNeue.ttc",
),
#[cfg(target_os = "macos")]
("/Library/Fonts/Arial.ttf", "Arial.ttf"),
#[cfg(target_os = "windows")]
("C:\\Windows\\Fonts\\arial.ttf", "arial.ttf"),
#[cfg(target_os = "windows")]
("C:\\Windows\\Fonts\\segoeui.ttf", "segoeui.ttf"),
#[cfg(target_os = "windows")]
("C:\\Windows\\Fonts\\calibri.ttf", "calibri.ttf"),
];
for (path, name) in paths {
if let Ok(bytes) = std::fs::read(path) {
return Some(((*name).to_string(), bytes));
}
}
None
}
/// **Purpose:** Applies the system-wide visual theme configurations to the egui context.
///
/// **Logic & Workflow:**
/// 1. Fetches the active `ThemeColors` palette based on the chosen preset.
/// 2. Configures default Light/Dark visuals on the egui Context.
/// 3. Propagates all color tokens directly into `style.visuals` structures so that
/// non-interactive panel elements, idle buttons, hover highlights, active states,
/// scrollbars, text selections, and dropdowns inherit the theme.
/// 4. Harmonizes component padding, margins, and roundings for a clean desktop design.
///
/// **Arguments:**
/// * `ctx`: The reference to the active egui Context.
/// * `theme`: The selected ThemePreset enum.
pub fn apply_theme(ctx: &egui::Context, theme: ThemePreset) {
let colors = ThemeColors::get(theme);
let is_light = colors.is_light;
ctx.set_visuals(if is_light {
egui::Visuals::light()
} else {
egui::Visuals::dark()
});
ctx.style_mut(|style| {
// ── Rounded corners (consistent 4px everywhere) ──
let r4 = egui::CornerRadius::same(4);
style.visuals.widgets.noninteractive.corner_radius = r4;
style.visuals.widgets.inactive.corner_radius = r4;
style.visuals.widgets.hovered.corner_radius = r4;
style.visuals.widgets.active.corner_radius = r4;
style.visuals.widgets.open.corner_radius = r4;
// ── Window / popup rounding ──
style.visuals.window_corner_radius = egui::CornerRadius::same(6);
style.visuals.menu_corner_radius = egui::CornerRadius::same(6);
// ── Core surface fills (3-tier elevation) ──
style.visuals.window_fill = colors.bg_elevated;
style.visuals.panel_fill = colors.bg_panel;
style.visuals.window_stroke = egui::Stroke::new(1.0, colors.border_high);
style.visuals.extreme_bg_color = colors.bg_recessed;
style.visuals.faint_bg_color = colors.bg_hover.gamma_multiply(0.4);
// ── Noninteractive widgets (labels, static elements) ──
style.visuals.widgets.noninteractive.bg_fill = colors.bg_panel;
style.visuals.widgets.noninteractive.bg_stroke = egui::Stroke::new(1.0, colors.border_low);
style.visuals.widgets.noninteractive.fg_stroke =
egui::Stroke::new(1.0, colors.text_secondary);
// ── Inactive widgets (idle buttons, slider tracks, checkboxes) ──
style.visuals.widgets.inactive.bg_fill = colors.bg_dark_depth;
style.visuals.widgets.inactive.bg_stroke = egui::Stroke::new(1.0, colors.border_high);
style.visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, colors.text_primary);
// ── Hover states ──
style.visuals.widgets.hovered.bg_fill = colors.bg_hover;
style.visuals.widgets.hovered.bg_stroke = egui::Stroke::new(1.0, colors.accent);
style.visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.2, colors.text_primary);
// ── Active highlights (clicked buttons, active controls) ──
style.visuals.widgets.active.bg_fill = colors.accent;
style.visuals.widgets.active.bg_stroke = egui::Stroke::new(1.0, colors.accent);
style.visuals.widgets.active.fg_stroke = egui::Stroke::new(1.2, colors.text_primary);
// ── Open menus and popups ──
style.visuals.widgets.open.bg_fill = colors.bg_elevated;
style.visuals.widgets.open.bg_stroke = egui::Stroke::new(1.0, colors.border_high);
// ── Text selection & highlights ──
style.visuals.selection.bg_fill = colors.accent.gamma_multiply(0.3);
style.visuals.selection.stroke = egui::Stroke::new(1.0, colors.accent);
// ── Slider trailing fill (shows value range) ──
style.visuals.slider_trailing_fill = true;
// ── Popup shadow for depth ──
style.visuals.popup_shadow = egui::Shadow {
offset: [0, 4],
blur: 16,
spread: 0,
color: egui::Color32::from_black_alpha(if is_light { 18 } else { 60 }),
};
// ── Font sizes matching Qt 12px default ──
style.text_styles.insert(
egui::TextStyle::Body,
egui::FontId::proportional(12.0),
);
style.text_styles.insert(
egui::TextStyle::Button,
egui::FontId::proportional(12.0),
);
style.text_styles.insert(
egui::TextStyle::Small,
egui::FontId::proportional(11.0),
);
style.text_styles.insert(
egui::TextStyle::Heading,
egui::FontId::proportional(13.5),
);
style.text_styles.insert(
egui::TextStyle::Monospace,
egui::FontId::proportional(11.5),
);
// ── Harmonized layout parameters (8px grid) ──
style.spacing.item_spacing = egui::vec2(4.0, 4.0);
style.spacing.window_margin = egui::Margin::same(6);
style.spacing.button_padding = egui::vec2(6.0, 3.0);
style.spacing.indent = 12.0;
style.spacing.slider_width = 140.0;
style.spacing.interact_size = egui::vec2(28.0, 22.0);
});
}
@@ -0,0 +1,459 @@
//! Runtime tool, selection, and transform state.
//!
//! This module contains the interactive state that changes every frame: the
//! active tool, color, brush settings, selection operation, text edit, vector
//! transform, tablet input, and AI/ComfyUI panel state. It is deliberately
//! separate from persistent `Settings`.
use crate::app::ai_config::{
AiChatConfig, AiChatMessage, ComfyConfig, ComfyMode, ComfyStatus, VisionBackend, VisionTask,
};
use crate::app::settings::Settings;
use crate::app::shell::tablet_input::TabletState;
use crate::app::TOOL_SLOTS;
use eframe::egui;
use hcie_engine_api::{Tool, ToolConfigs, VectorEditHandle, VectorShape};
use std::sync::Arc;
#[derive(Clone)]
pub struct ToolState {
pub active_tool: Tool,
pub primary_color: [u8; 4],
pub secondary_color: [u8; 4],
pub recent_colors: Vec<[u8; 4]>,
pub tool_configs: ToolConfigs,
pub current_pressure: f32,
pub pan_offset: egui::Vec2,
pub cursor_canvas_pos: Option<(u32, u32)>,
pub selection_rect: Option<[u32; 4]>,
/// VisionSelect drag rectangle in canvas coordinates, used for bbox segmentation.
pub vision_select_rect: Option<[u32; 4]>,
pub is_drawing: bool,
pub drag_start_pos: Option<(u32, u32)>,
pub last_draw_pos: Option<(u32, u32)>,
pub brush_accumulated_dist: f32,
pub lasso_points: Vec<(u32, u32)>,
pub smart_patch_points: Vec<(u32, u32)>,
pub text_edit: TextEditState,
pub selected_vector_shape: Option<usize>,
pub vector_shapes_snapshot: Option<Vec<VectorShape>>,
pub vector_is_new_creation: bool,
pub vector_edit_handle: VectorEditHandle,
pub vector_drag_last: Option<egui::Pos2>,
pub settings: Settings,
pub pinned_panels: std::collections::HashSet<String>,
/// Set of utility panels that the user has collapsed with the custom
/// collapse chevron on the active tab.
pub collapsed_panels: std::collections::HashSet<crate::app::dock::HciePane>,
pub ai_panel_tab: usize,
pub ai_chat_thinking: bool,
pub is_ai_processing: bool,
pub ai_actions_busy: bool,
pub ai_chat_history: Vec<AiChatMessage>,
pub ai_chat_config: AiChatConfig,
pub ai_chat_models: Vec<String>,
pub ai_send_canvas: bool,
pub ai_vision_review: bool,
pub ai_chat_show_reasoning: bool,
pub ai_chat_max_turns: u32,
pub ai_chat_buffer: String,
pub comfy_status: ComfyStatus,
pub comfy_config: ComfyConfig,
pub comfy_mode: ComfyMode,
pub brush_presets: Vec<hcie_engine_api::BrushPreset>,
pub active_brush_preset_id: String,
pub comfy_models: Vec<String>,
pub selected_model: String,
pub vision_task: VisionTask,
pub vision_sam_model_path: String,
pub vision_birefnet_model_path: String,
pub vision_migan_model_path: String,
pub vision_backend: VisionBackend,
pub script_editor: egui_panel_script::ScriptEditor,
pub ai_script_generator: egui_panel_ai_script::AiScriptGenerator,
pub vector_shapes_expanded: bool,
pub tool_scroll_offset: f32,
pub transform: Option<SelectionTransform>,
pub transform_drag_handle: TransformHandle,
/// Shared tablet/stylus state updated by winit/evdev and read each frame.
pub tablet_state: Arc<std::sync::Mutex<TabletState>>,
pub active_slot: usize,
pub slot_last_used: Vec<usize>,
pub active_popup_slot: Option<usize>,
pub popup_anchor_pos: Option<egui::Pos2>,
pub popup_close_request: bool,
pub slot_press_start: Option<(usize, std::time::Instant)>,
pub popup_opened_via_drag: bool,
/// If true, the left toolbox is rendered as a two-column wide strip.
pub toolbox_two_column: bool,
/// When set, the active layer is locked to this id while a destructive
/// preview dialog (e.g. Adjustments) is open. The Layers panel refuses to
/// switch the active layer while this is set, and the app re-asserts the
/// locked layer every frame so programmatic layer changes (shortcuts,
/// canvas auto-create, etc.) cannot move the filter target to another
/// layer. Cleared when the owning dialog closes.
pub locked_active_layer: Option<u64>,
/// Whether the recent colors popup/expand section is shown.
pub show_recent_popup: bool,
/// Pending name input used by the brush editor "New Preset" action.
pub pending_brush_preset_name: Option<String>,
/// Whether the brush editor updates the canvas brush preview live.
pub brush_editor_instant_preview: bool,
pub is_resizing_brush: bool,
pub brush_resize_start_size: f32,
pub brush_resize_start_pos: Option<egui::Pos2>,
/// Cached brush preset thumbnail textures keyed by preset ID.
/// Generated once when ABR/KPP files are loaded or on explicit refresh.
pub brush_thumbnail_cache: std::collections::HashMap<String, egui::TextureHandle>,
/// When true, all cached thumbnails are regenerated on the next frame.
pub brush_thumbnail_dirty: bool,
}
impl Default for ToolState {
fn default() -> Self {
let mut pinned = std::collections::HashSet::new();
pinned.insert("Tools".to_string());
pinned.insert("Brushes & Tips".to_string());
pinned.insert("Color Palette".to_string());
pinned.insert("Layers".to_string());
Self {
active_tool: Tool::Brush,
primary_color: [0, 0, 0, 255],
secondary_color: [255, 255, 255, 255],
recent_colors: vec![],
tool_configs: ToolConfigs::default(),
current_pressure: 1.0,
pan_offset: egui::Vec2::ZERO,
cursor_canvas_pos: None,
selection_rect: None,
vision_select_rect: None,
is_drawing: false,
drag_start_pos: None,
last_draw_pos: None,
brush_accumulated_dist: 0.0,
lasso_points: vec![],
smart_patch_points: vec![],
text_edit: TextEditState::default(),
selected_vector_shape: None,
vector_shapes_snapshot: None,
vector_is_new_creation: false,
vector_edit_handle: VectorEditHandle::None,
vector_drag_last: None,
settings: Settings::default(),
pinned_panels: pinned,
collapsed_panels: std::collections::HashSet::new(),
ai_panel_tab: 0,
ai_chat_thinking: false,
is_ai_processing: false,
ai_actions_busy: false,
ai_chat_history: vec![],
ai_chat_config: AiChatConfig {
url: "http://127.0.0.1:8188".into(),
model: "flux".into(),
},
ai_chat_models: vec![],
ai_send_canvas: false,
ai_vision_review: false,
ai_chat_show_reasoning: false,
ai_chat_max_turns: 10,
ai_chat_buffer: String::new(),
comfy_status: ComfyStatus::Unknown,
comfy_config: ComfyConfig {
url: "http://127.0.0.1:8188".into(),
},
comfy_mode: ComfyMode::Normal,
brush_presets: hcie_engine_api::BrushPreset::all_defaults(),
active_brush_preset_id: "basic_round".to_string(),
comfy_models: vec![],
selected_model: String::new(),
vision_task: VisionTask::default(),
vision_sam_model_path: "models/sam/MobileSAM-F16.gguf".to_string(),
vision_birefnet_model_path: "models/birefnet/BiRefNet-lite-F16.gguf".to_string(),
vision_migan_model_path: "models/migan/MIGAN-512-places2-F16.gguf".to_string(),
vision_backend: VisionBackend::default(),
script_editor: egui_panel_script::ScriptEditor::new(),
ai_script_generator: egui_panel_ai_script::AiScriptGenerator::new(),
vector_shapes_expanded: false,
tool_scroll_offset: 0.0,
transform: None,
transform_drag_handle: TransformHandle::None,
tablet_state: Arc::new(std::sync::Mutex::new(TabletState::new())),
active_slot: TOOL_SLOTS
.iter()
.position(|s| s.contains(&Tool::Brush))
.unwrap_or(0),
slot_last_used: {
let mut v = vec![0usize; TOOL_SLOTS.len()];
if let Some(si) = TOOL_SLOTS.iter().position(|s| s.contains(&Tool::Brush)) {
if let Some(li) = TOOL_SLOTS[si].iter().position(|&t| t == Tool::Brush) {
v[si] = li;
}
}
v
},
active_popup_slot: None,
popup_anchor_pos: None,
popup_close_request: false,
slot_press_start: None,
popup_opened_via_drag: false,
toolbox_two_column: false,
locked_active_layer: None,
show_recent_popup: false,
pending_brush_preset_name: None,
brush_editor_instant_preview: true,
is_resizing_brush: false,
brush_resize_start_size: 10.0,
brush_resize_start_pos: None,
brush_thumbnail_cache: std::collections::HashMap::new(),
brush_thumbnail_dirty: true,
}
}
}
impl ToolState {
pub fn active_size(&self) -> f32 {
match self.active_tool {
Tool::Pen => self.tool_configs.pen.size,
Tool::Brush => self.tool_configs.brush.size,
Tool::Eraser => self.tool_configs.eraser.size,
Tool::Spray => self.tool_configs.spray.radius,
_ => 10.0,
}
}
pub fn set_active_size(&mut self, size: f32) {
match self.active_tool {
Tool::Pen => self.tool_configs.pen.size = size,
Tool::Brush => self.tool_configs.brush.size = size,
Tool::Eraser => self.tool_configs.eraser.size = size,
Tool::Spray => self.tool_configs.spray.radius = size,
_ => {}
}
}
pub fn active_size_mut(&mut self) -> &mut f32 {
match self.active_tool {
Tool::Pen => &mut self.tool_configs.pen.size,
Tool::Brush => &mut self.tool_configs.brush.size,
Tool::Eraser => &mut self.tool_configs.eraser.size,
Tool::Spray => &mut self.tool_configs.spray.radius,
_ => &mut self.tool_configs.pen.size,
}
}
pub fn active_opacity(&self) -> f32 {
match self.active_tool {
Tool::Pen => self.tool_configs.pen.opacity,
Tool::Brush => self.tool_configs.brush.opacity,
Tool::Eraser => self.tool_configs.eraser.opacity,
Tool::Spray => self.tool_configs.spray.opacity,
_ => 1.0,
}
}
pub fn set_active_opacity(&mut self, opacity: f32) {
match self.active_tool {
Tool::Pen => self.tool_configs.pen.opacity = opacity,
Tool::Brush => self.tool_configs.brush.opacity = opacity,
Tool::Eraser => self.tool_configs.eraser.opacity = opacity,
Tool::Spray => self.tool_configs.spray.opacity = opacity,
_ => {}
}
}
pub fn brush_dirty_radius(&self) -> f32 {
self.active_size() * 0.5 + 2.0
}
pub fn slot_for_tool(tool: Tool) -> Option<usize> {
TOOL_SLOTS.iter().position(|slot| slot.contains(&tool))
}
pub fn set_active_tool_from_slot(&mut self, tool: Tool) {
if let Some(slot_idx) = Self::slot_for_tool(tool) {
self.active_slot = slot_idx;
if let Some(local_idx) = TOOL_SLOTS[slot_idx].iter().position(|&t| t == tool) {
self.slot_last_used[slot_idx] = local_idx;
}
}
self.active_tool = tool;
}
}
/// Selection morphological operations shown in the context menu / dialog.
pub enum SelectionOp {
Grow(u32),
Shrink(u32),
Feather(u32),
Erode(u32),
Fade(u32),
}
/// Live text editing state used by the inline canvas text tool.
///
/// When the user clicks on the canvas with the Text tool, a draft text
/// layer is created and its properties are kept here. All changes in the
/// Properties panel or in the on-canvas text edit are applied to the draft
/// layer immediately so the user sees a live preview. The edits are only
/// committed when Accept is pressed; Cancel discards a newly created draft
/// layer, or reverts an existing text layer to the snapshot stored in
/// `original_text` and its companion `original_*` fields.
#[derive(Clone)]
pub struct TextEditState {
pub active: bool,
pub layer_id: u64,
pub draft_text: String,
pub draft_font: String,
pub draft_size: f32,
pub draft_color: [u8; 4],
pub draft_angle: f32,
pub draft_alignment: hcie_engine_api::TextAlignment,
pub draft_orientation: hcie_engine_api::TextOrientation,
pub canvas_x: u32,
pub canvas_y: u32,
pub accepted: bool,
pub cancelled: bool,
/// If true, the layer was created by the current inline edit session and
/// should be deleted on Cancel. If false, Cancel reverts to `original_*`.
pub is_new: bool,
/// Snapshot of the layer's text and properties taken when an existing text
/// layer enters edit mode. Used to restore the layer on Cancel.
pub original_text: String,
pub original_font: String,
pub original_size: f32,
pub original_color: [u8; 4],
pub original_angle: f32,
pub original_alignment: hcie_engine_api::TextAlignment,
pub original_orientation: hcie_engine_api::TextOrientation,
}
impl Default for TextEditState {
fn default() -> Self {
Self {
active: false,
layer_id: 0,
draft_text: String::new(),
draft_font: "default".to_string(),
draft_size: 24.0,
draft_color: [0, 0, 0, 255],
draft_angle: 0.0,
draft_alignment: hcie_engine_api::TextAlignment::Left,
draft_orientation: hcie_engine_api::TextOrientation::Horizontal,
canvas_x: 0,
canvas_y: 0,
accepted: false,
cancelled: false,
is_new: true,
original_text: String::new(),
original_font: "default".to_string(),
original_size: 24.0,
original_color: [0, 0, 0, 255],
original_angle: 0.0,
original_alignment: hcie_engine_api::TextAlignment::Left,
original_orientation: hcie_engine_api::TextOrientation::Horizontal,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransformHandle {
#[default]
None,
Move,
TopLeft,
TopRight,
BottomRight,
BottomLeft,
Top,
Bottom,
Left,
Right,
}
#[derive(Clone)]
pub struct SelectionTransform {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub pos: egui::Vec2,
pub size: egui::Vec2,
}
impl SelectionTransform {
pub fn from_engine(engine: &hcie_engine_api::Engine, mask: &[u8], w: u32, h: u32) -> Self {
if mask.is_empty() || w == 0 || h == 0 {
return Self {
pixels: vec![],
width: 0,
height: 0,
pos: egui::Vec2::ZERO,
size: egui::Vec2::ZERO,
};
}
let mut min_x = w;
let mut min_y = h;
let mut max_x = 0u32;
let mut max_y = 0u32;
let mut has_selection = false;
for y in 0..h {
for x in 0..w {
if mask[(y * w + x) as usize] > 0 {
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
has_selection = true;
}
}
}
if !has_selection {
return Self {
pixels: vec![],
width: 0,
height: 0,
pos: egui::Vec2::ZERO,
size: egui::Vec2::ZERO,
};
}
let bw = max_x - min_x + 1;
let bh = max_y - min_y + 1;
let mut pixels = vec![0u8; (bw * bh * 4) as usize];
let layer_pixels = engine.get_active_layer_pixels().unwrap_or_default();
let layer_w = engine.canvas_width() as usize;
let w_usize = w as usize;
let bw_usize = bw as usize;
let bh_usize = bh as usize;
let min_x_usize = min_x as usize;
let min_y_usize = min_y as usize;
for y in 0..bh_usize {
for x in 0..bw_usize {
let src_x = min_x_usize + x;
let src_y = min_y_usize + y;
let mask_idx = src_y * w_usize + src_x;
if mask_idx < mask.len() && mask[mask_idx] > 0 {
let px_idx = (src_y * layer_w + src_x) * 4;
if px_idx + 3 < layer_pixels.len() {
let dst_idx = (y * bw_usize + x) * 4;
pixels[dst_idx] = layer_pixels[px_idx];
pixels[dst_idx + 1] = layer_pixels[px_idx + 1];
pixels[dst_idx + 2] = layer_pixels[px_idx + 2];
pixels[dst_idx + 3] = layer_pixels[px_idx + 3];
}
}
}
}
Self {
pixels,
width: bw,
height: bh,
pos: egui::Vec2::new(min_x as f32, min_y as f32),
size: egui::Vec2::new(bw as f32, bh as f32),
}
}
}
@@ -4,6 +4,11 @@ use eframe::egui;
use hcie_engine_api::brush::{BrushStyle, BrushTip};
use hcie_engine_api::BrushPreset;
use hcie_engine_api::Tool;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
const THUMB_W: u32 = 140;
const THUMB_H: u32 = 64;
fn matches_category(active_cat: &str, preset_cat: &str) -> bool {
if active_cat == "All" {
@@ -12,6 +17,9 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool {
if active_cat == "Custom" {
return preset_cat.eq_ignore_ascii_case("Custom");
}
if active_cat == "Imported" {
return preset_cat.eq_ignore_ascii_case("Imported") || preset_cat.eq_ignore_ascii_case("Imported (ABR)");
}
let mapped: &[&str] = match active_cat {
"Drawing" => &["Basic", "Sketch"],
"Painting" => &["Paint", "Ink"],
@@ -21,12 +29,158 @@ fn matches_category(active_cat: &str, preset_cat: &str) -> bool {
mapped.iter().any(|c| c.eq_ignore_ascii_case(preset_cat))
}
/// Draw a gorgeous custom procedural preview of a brush style inside a rect.
fn preset_thumb_hash(preset: &BrushPreset) -> u64 {
let mut hasher = DefaultHasher::new();
preset.style.hash(&mut hasher);
preset.tip.size.to_bits().hash(&mut hasher);
preset.tip.hardness.to_bits().hash(&mut hasher);
preset.tip.opacity.to_bits().hash(&mut hasher);
preset.tip.roundness.to_bits().hash(&mut hasher);
preset.tip.spacing.to_bits().hash(&mut hasher);
preset.tip.bitmap_w.hash(&mut hasher);
preset.tip.bitmap_h.hash(&mut hasher);
if !preset.tip.bitmap_pixels.is_empty() {
let step = (preset.tip.bitmap_pixels.len() / 64).max(1);
for (_i, &v) in preset.tip.bitmap_pixels.iter().enumerate().step_by(step) {
v.hash(&mut hasher);
}
}
hasher.finish()
}
fn render_thumb_to_texture(
ctx: &egui::Context,
preset: &BrushPreset,
) -> egui::TextureHandle {
let w = THUMB_W as usize;
let h = THUMB_H as usize;
let mut pixels = vec![0u8; w * h * 4];
render_thumb_into_rgba(preset, &mut pixels, w, h);
let image = egui::ColorImage::from_rgba_unmultiplied([w, h], &pixels);
ctx.load_texture(format!("thumb_{}", preset.id), image, egui::TextureOptions::LINEAR)
}
fn render_thumb_into_rgba(preset: &BrushPreset, buf: &mut [u8], w: usize, h: usize) {
let style = preset.style;
let gray: [u8; 4] = [200, 200, 200, 255];
let x0f = 16.0_f32;
let x1f = (w as f32) - 16.0;
let y_mid = (h as f32) * 0.5;
let n = 45;
let mut pts: Vec<(f32, f32, f32)> = Vec::with_capacity(n + 1);
for i in 0..=n {
let t = i as f32 / n as f32;
let x = x0f + t * (x1f - x0f);
let angle = t * std::f32::consts::TAU * 1.25;
let taper = (t * (1.0 - t) * 4.0).powf(1.1);
let y = y_mid + angle.sin() * (h as f32 * 0.22) * taper;
pts.push((x, y, t));
}
match style {
BrushStyle::Bitmap => {
if preset.tip.bitmap_pixels.is_empty() || preset.tip.bitmap_w == 0 || preset.tip.bitmap_h == 0 {
return;
}
let bw = preset.tip.bitmap_w as f32;
let bh = preset.tip.bitmap_h as f32;
let scale = (w as f32 / bw).min(h as f32 / bh);
let draw_w = bw * scale;
let draw_h = bh * scale;
let ox = ((w as f32) - draw_w) * 0.5;
let oy = ((h as f32) - draw_h) * 0.5;
let px = scale.max(1.0);
let mut sy = 0u32;
let mut py = 0.0;
while sy < preset.tip.bitmap_h {
let mut sx = 0u32;
let mut ppx = 0.0;
while sx < preset.tip.bitmap_w {
let mask = preset.tip.bitmap_pixels[(sy * preset.tip.bitmap_w + sx) as usize] as f32 / 255.0;
if mask > 0.01 {
let a = (mask * 255.0).round() as u8;
let rx = (ox + ppx).round() as i32;
let ry = (oy + py).round() as i32;
let rw = px.ceil() as i32;
let rh = px.ceil() as i32;
for dy in 0..rh {
for dx in 0..rw {
let px_i = rx + dx;
let py_i = ry + dy;
if px_i >= 0 && py_i >= 0 && (px_i as usize) < w && (py_i as usize) < h {
let idx = ((py_i as usize) * w + (px_i as usize)) * 4;
buf[idx] = gray[0];
buf[idx + 1] = gray[1];
buf[idx + 2] = gray[2];
buf[idx + 3] = a;
}
}
}
}
ppx += px;
sx += 1;
}
py += px;
sy += 1;
}
return;
}
_ => {}
}
let thickness_base = match style {
BrushStyle::Square => 2.0,
BrushStyle::Pen => 1.0,
BrushStyle::Pencil => 1.0,
BrushStyle::Airbrush => 6.0,
BrushStyle::Oil => 6.5,
BrushStyle::Glow => 2.5,
BrushStyle::InkPen => 0.9,
BrushStyle::Wood => 3.8,
_ => 2.0,
};
for i in 0..n {
let (x1, y1, t1) = pts[i];
let (x2, y2, _) = pts[i + 1];
let p = (t1 * std::f32::consts::PI).sin();
let thick = thickness_base * p;
let steps_i = (thick * 2.0).ceil() as i32;
for step in 0..steps_i.max(1) {
let frac = step as f32 / (steps_i.max(1) as f32) - 0.5;
let nx = -(y2 - y1);
let ny = x2 - x1;
let len = (nx * nx + ny * ny).sqrt().max(0.001);
let off_x = nx / len * frac * thick;
let off_y = ny / len * frac * thick;
let sx_f = x1 + off_x;
let sy_f = y1 + off_y;
let ex_f = x2 + off_x;
let ey_f = y2 + off_y;
let dist_x = (ex_f - sx_f).abs();
let dist_y = (ey_f - sy_f).abs();
let steps = (dist_x.max(dist_y).ceil() as i32).max(1);
for s in 0..=steps {
let t = s as f32 / steps as f32;
let px_f = sx_f + (ex_f - sx_f) * t;
let py_f = sy_f + (ey_f - sy_f) * t;
let r = px_f.round() as i32;
let c = py_f.round() as i32;
if r >= 0 && c >= 0 && (r as usize) < w && (c as usize) < h {
let idx = ((c as usize) * w + (r as usize)) * 4;
buf[idx] = gray[0];
buf[idx + 1] = gray[1];
buf[idx + 2] = gray[2];
buf[idx + 3] = gray[3];
}
}
}
}
}
pub fn draw_brush_preview(
painter: &egui::Painter,
rect: egui::Rect,
style: hcie_engine_api::tools::BrushStyle,
color: egui::Color32,
_color: egui::Color32,
tip: Option<&BrushTip>,
) {
let _width = rect.width();
let height = rect.height();
@@ -39,7 +193,6 @@ pub fn draw_brush_preview(
for i in 0..=n_points {
let t = i as f32 / n_points as f32;
let x = x0 + t * (x1 - x0);
// Tapered wave formula for S-curve
let angle = t * std::f32::consts::TAU * 1.25;
let sine = angle.sin();
let taper = (t * (1.0 - t) * 4.0).powf(1.1);
@@ -47,12 +200,12 @@ pub fn draw_brush_preview(
points.push((x, y, t));
}
let color = egui::Color32::from_gray(200);
match style {
hcie_engine_api::tools::BrushStyle::Default
| hcie_engine_api::tools::BrushStyle::Round
| hcie_engine_api::tools::BrushStyle::HardRound
| hcie_engine_api::tools::BrushStyle::SoftRound => {
// Smooth tapered round path
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -65,7 +218,6 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Square => {
// Squared-off strokes with flat ends
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -78,7 +230,6 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Noise => {
// Grainy jittery stroke
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -92,27 +243,17 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Texture => {
// Rough dotted texture stroke
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.15 {
continue;
}
if p < 0.15 { continue; }
let alpha = 0.3 + 0.5 * (x * 23.0).sin().abs();
painter.circle_filled(
egui::pos2(x, y),
1.2 + 2.0 * p,
color.linear_multiply(alpha),
);
painter.circle_filled(egui::pos2(x, y), 1.2 + 2.0 * p, color.linear_multiply(alpha));
}
}
hcie_engine_api::tools::BrushStyle::Spray => {
// Scattered spray particles
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.05 {
continue;
}
if p < 0.05 { continue; }
for s in 0..6 {
let angle = (x * 9.0 + s as f32 * 1.1).sin() * std::f32::consts::TAU;
let dist = (y * 11.0 + s as f32 * 1.7).cos().abs() * 8.0 * p;
@@ -123,28 +264,24 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Pen => {
// Crisp thin pen line
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
let p = (t1 * std::f32::consts::PI).sin();
let thickness = 1.0 + 1.2 * p;
painter.line_segment(
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
egui::Stroke::new(thickness, color),
egui::Stroke::new(1.0 + 1.2 * p, color),
);
}
}
hcie_engine_api::tools::BrushStyle::Airbrush => {
// Soft transparent wide overlay
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
let p = (t1 * std::f32::consts::PI).sin();
let base_color = color.linear_multiply(0.08);
painter.line_segment(
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
egui::Stroke::new(14.0 * p, base_color),
egui::Stroke::new(14.0 * p, color.linear_multiply(0.08)),
);
painter.line_segment(
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
@@ -153,43 +290,33 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Pencil => {
// Thin scratchy slightly noisy line
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
let p = (t1 * std::f32::consts::PI).sin();
let noise1 = (x1 * 13.0).sin() * 0.35;
let noise2 = (x2 * 13.0).sin() * 0.35;
let n1 = (x1 * 13.0).sin() * 0.35;
let n2 = (x2 * 13.0).sin() * 0.35;
painter.line_segment(
[egui::pos2(x1, y1 + noise1), egui::pos2(x2, y2 + noise2)],
[egui::pos2(x1, y1 + n1), egui::pos2(x2, y2 + n2)],
egui::Stroke::new(1.0 + 0.8 * p, color.linear_multiply(0.9)),
);
}
}
hcie_engine_api::tools::BrushStyle::Charcoal => {
// Rough scratchy textured points
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 {
continue;
}
let steps = 4;
for s in 0..steps {
if p < 0.1 { continue; }
for s in 0..4 {
let angle = (x * 7.3 + s as f32).sin() * std::f32::consts::TAU;
let dist = (x * 11.2 + s as f32).cos().abs() * 3.5 * p;
let px = x + angle.cos() * dist;
let py = y + angle.sin() * dist;
let alpha = 0.25 + 0.55 * (x * 19.3).sin().abs();
painter.circle_filled(
egui::pos2(px, py),
1.0 + 1.2 * p,
color.linear_multiply(alpha),
);
painter.circle_filled(egui::pos2(px, py), 1.0 + 1.2 * p, color.linear_multiply(alpha));
}
}
}
hcie_engine_api::tools::BrushStyle::Watercolor => {
// Bleeding watercolor blobs with wet edges
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let r = 8.0 * p + 1.5;
@@ -198,12 +325,10 @@ pub fn draw_brush_preview(
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let r = 8.0 * p + 1.5;
let edge_color = color.linear_multiply(0.12);
painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, edge_color));
painter.circle_stroke(egui::pos2(x, y), r, egui::Stroke::new(0.8, color.linear_multiply(0.12)));
}
}
hcie_engine_api::tools::BrushStyle::Calligraphy => {
// Slanted ribbon lines
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let w = 4.2 * p;
@@ -214,165 +339,61 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Leaf => {
// Scattered oval leaf shapes with color variation, shadow, and stem
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 4 != 0 {
continue;
}
if i % 4 != 0 { continue; }
let p = (t * std::f32::consts::PI).sin();
if p < 0.15 {
continue;
}
// Vary color per leaf (darker/lighter)
if p < 0.15 { continue; }
let shade = 0.35 + 0.55 * (i as f32 * 0.7).sin().abs();
let leaf_color = color.linear_multiply(shade);
let leaf_dark = color.linear_multiply(shade * 0.55);
// Leaf size with slight variation
let size = 1.8 + 2.0 * p;
// Slight horizontal offset for natural scatter
let lx = x + (i as f32 * 1.3).sin() * 1.5;
// Draw leaf as small vertical oval (pill shape)
let leaf_rect = egui::Rect::from_center_size(
egui::pos2(lx, y),
egui::vec2(size * 0.45, size * 1.1),
egui::pos2(lx, y), egui::vec2(size * 0.45, size * 1.1),
);
// Shadow below and slightly to the right
let shadow_rect = leaf_rect.translate(egui::vec2(0.4, 0.6));
painter.rect_filled(
shadow_rect,
leaf_rect.translate(egui::vec2(0.4, 0.6)),
egui::CornerRadius::same((size * 0.22).round() as u8),
leaf_dark.linear_multiply(0.35),
color.linear_multiply(shade * 0.55 * 0.35),
);
// Leaf body
painter.rect_filled(
leaf_rect,
egui::CornerRadius::same((size * 0.22).round() as u8),
leaf_color,
);
// Top-half highlight
let hl_rect = egui::Rect::from_min_max(
leaf_rect.left_top(),
egui::pos2(leaf_rect.right(), leaf_rect.center().y),
);
painter.rect_filled(
hl_rect,
egui::CornerRadius::same(1),
leaf_color.linear_multiply(1.25),
);
// Thin outline
painter.rect_stroke(
leaf_rect,
egui::CornerRadius::same((size * 0.22).round() as u8),
egui::Stroke::new(0.4, leaf_dark),
egui::StrokeKind::Outside,
);
// Tiny stem at bottom
let stem_top = egui::pos2(lx, leaf_rect.bottom());
let stem_bot = egui::pos2(lx + 0.5, leaf_rect.bottom() + size * 0.5);
painter.line_segment(
[stem_top, stem_bot],
egui::Stroke::new(0.5, leaf_dark.linear_multiply(0.6)),
color.linear_multiply(shade),
);
}
}
hcie_engine_api::tools::BrushStyle::Rock => {
// Irregular rounded rocks with bottom shadow and top highlight
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 2 != 0 {
continue;
}
if i % 2 != 0 { continue; }
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 {
continue;
}
// Rock dimensions with variation
let rw = 3.0 + 3.5 * p * (0.7 + 0.3 * (i as f32 * 1.3).sin().abs());
let rh = 2.0 + 2.5 * p * (0.7 + 0.3 * (i as f32 * 0.9).cos().abs());
let rounding = 1.5 + 2.5 * (i as f32 * 0.5).sin().abs();
if p < 0.1 { continue; }
let rw = 3.0 + 3.5 * p;
let rh = 2.0 + 2.5 * p;
let shade = 0.40 + 0.40 * (i as f32 * 0.7).sin().abs();
let rock_color = color.linear_multiply(shade);
let rock_dark = color.linear_multiply(shade * 0.55);
let rx = x + (i as f32).sin() * 1.5;
let ry = y + (i as f32 * 0.7).cos() * 1.0;
let rock_rect =
egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh));
// Bottom shadow (lower half of rock, darker)
let shadow_rect = egui::Rect::from_min_max(
egui::pos2(rock_rect.left(), rock_rect.center().y - rh * 0.1),
rock_rect.right_bottom(),
);
painter.rect_filled(shadow_rect, egui::CornerRadius::same(1), rock_dark);
// Rock body
let rock_rect = egui::Rect::from_center_size(egui::pos2(rx, ry), egui::vec2(rw, rh));
painter.rect_filled(
rock_rect,
egui::CornerRadius::same(rounding.round() as u8),
rock_color,
);
// Dark outline
painter.rect_stroke(
rock_rect,
egui::CornerRadius::same(rounding.round() as u8),
egui::Stroke::new(0.6, rock_dark.linear_multiply(0.8)),
egui::StrokeKind::Outside,
);
// Top-left highlight
let hl_rect = egui::Rect::from_min_max(
rock_rect.left_top(),
egui::pos2(rock_rect.left() + rw * 0.4, rock_rect.top() + rh * 0.35),
);
painter.rect_filled(
hl_rect,
egui::CornerRadius::same(1),
rock_color.linear_multiply(1.3),
rock_rect, egui::CornerRadius::same(2), color.linear_multiply(shade),
);
}
}
hcie_engine_api::tools::BrushStyle::Meadow => {
// Curved grass blades drawn as thin line segments, with color variation and shadow
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 2 != 0 {
continue;
}
if i % 2 != 0 { continue; }
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 {
continue;
}
// 2-3 blades per point for density
let blade_count = 2 + (i % 2);
for b in 0..blade_count {
let offset_x = (i as f32 * 3.7 + b as f32 * 5.1).sin() * 2.5;
let bx = x + offset_x;
let blade_h =
(4.0 + 5.0 * p) * (0.7 + 0.3 * (i as f32 * 1.1 + b as f32).sin().abs());
let wind = (bx * 5.0 + i as f32 * 0.5).sin() * 2.0;
let shade = 0.35 + 0.45 * (b as f32 * 0.7 + p).sin().abs();
if p < 0.1 { continue; }
let blade_h = 4.0 + 5.0 * p;
let wind = (x * 5.0 + i as f32 * 0.5).sin() * 2.0;
let shade = 0.35 + 0.45 * (i as f32 * 0.7 + p).sin().abs();
let blade_color = color.linear_multiply(shade);
let blade_dark = color.linear_multiply(shade * 0.5);
// Curved blade as 2 line segments (base→mid→tip)
let base = egui::pos2(bx, y);
let mid = egui::pos2(bx + wind * 0.4, y - blade_h * 0.5);
let tip = egui::pos2(bx + wind, y - blade_h);
// Shadow at base
painter.circle_filled(base, 0.8, blade_dark.linear_multiply(0.5));
// Lower half (thicker stem)
let base = egui::pos2(x, y);
let mid = egui::pos2(x + wind * 0.4, y - blade_h * 0.5);
let tip = egui::pos2(x + wind, y - blade_h);
painter.line_segment([base, mid], egui::Stroke::new(1.0, blade_color));
// Upper half (thin tip)
painter.line_segment(
[mid, tip],
egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)),
);
// Thin highlight on left side
let hl_start = egui::pos2(base.x - 0.3, base.y - 0.3);
let hl_mid = egui::pos2(mid.x - 0.3, mid.y - 0.3);
painter.line_segment(
[hl_start, hl_mid],
egui::Stroke::new(0.2, blade_color.linear_multiply(1.3)),
);
}
painter.line_segment([mid, tip], egui::Stroke::new(0.5, blade_color.linear_multiply(0.9)));
}
}
hcie_engine_api::tools::BrushStyle::Wood => {
// Wood grain overlay
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -381,15 +402,9 @@ pub fn draw_brush_preview(
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
egui::Stroke::new(3.8 * p, color.linear_multiply(0.75)),
);
let offset = 4.2 * p;
painter.line_segment(
[egui::pos2(x1, y1 + offset), egui::pos2(x2, y2 + offset)],
egui::Stroke::new(1.0 * p, color.linear_multiply(0.45)),
);
}
}
hcie_engine_api::tools::BrushStyle::Oil => {
// Thick oily strokes with sheen highlights
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -405,7 +420,6 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Glow => {
// Glowing neon core
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -425,24 +439,14 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Clouds => {
// Dreamy circular puffy blobs
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let r = 8.5 * p;
painter.circle_filled(
egui::pos2(x, y),
r,
egui::Color32::WHITE.linear_multiply(0.16),
);
painter.circle_filled(
egui::pos2(x, y - 2.0),
r * 0.8,
egui::Color32::WHITE.linear_multiply(0.24),
);
painter.circle_filled(egui::pos2(x, y), r, egui::Color32::WHITE.linear_multiply(0.16));
painter.circle_filled(egui::pos2(x, y - 2.0), r * 0.8, egui::Color32::WHITE.linear_multiply(0.24));
}
}
hcie_engine_api::tools::BrushStyle::Tree => {
// Branch structures
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -452,19 +456,15 @@ pub fn draw_brush_preview(
egui::Stroke::new(3.0 * p + 1.0, color),
);
if i == n_points / 3 || i == 2 * n_points / 3 {
let branch_dir = if i == n_points / 3 { -1.0 } else { 1.0 };
let d = if i == n_points / 3 { -1.0 } else { 1.0 };
painter.line_segment(
[
egui::pos2(x1, y1),
egui::pos2(x1 + 6.0, y1 + branch_dir * 8.0),
],
[egui::pos2(x1, y1), egui::pos2(x1 + 6.0, y1 + d * 8.0)],
egui::Stroke::new(1.5 * p, color),
);
}
}
}
hcie_engine_api::tools::BrushStyle::Bristle => {
// Separated bristle hair strands
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -479,42 +479,28 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Crayon => {
// Textured crayon look
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 {
continue;
}
if p < 0.1 { continue; }
for s in 0..3 {
let jx = (x * 17.5 + s as f32 * 5.0).sin() * 2.0 * p;
let jy = (y * 23.4 + s as f32 * 9.0).cos() * 2.0 * p;
painter.circle_filled(
egui::pos2(x + jx, y + jy),
1.2 * p,
color.linear_multiply(0.48),
);
painter.circle_filled(egui::pos2(x + jx, y + jy), 1.2 * p, color.linear_multiply(0.48));
}
}
}
hcie_engine_api::tools::BrushStyle::Hatch => {
// Cross hatches
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 4 != 0 {
continue;
}
if i % 4 != 0 { continue; }
let p = (t * std::f32::consts::PI).sin();
let size = 4.2 * p;
let sz = 4.2 * p;
painter.line_segment(
[
egui::pos2(x - size, y - size),
egui::pos2(x + size, y + size),
],
[egui::pos2(x - sz, y - sz), egui::pos2(x + sz, y + sz)],
egui::Stroke::new(0.9, color),
);
}
}
hcie_engine_api::tools::BrushStyle::WetPaint => {
// Wet paint drops
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -524,16 +510,11 @@ pub fn draw_brush_preview(
egui::Stroke::new(6.0 * p, color),
);
if i % 10 == 0 && i > 0 {
painter.circle_filled(
egui::pos2(x1, y1 + 4.2 * p),
2.2 * p,
color.linear_multiply(0.78),
);
painter.circle_filled(egui::pos2(x1, y1 + 4.2 * p), 2.2 * p, color.linear_multiply(0.78));
}
}
}
hcie_engine_api::tools::BrushStyle::InkPen => {
// Crisp ink pen
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -545,18 +526,17 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Marker => {
// Angled wide highlighting tape
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
let w = 5.2 * p;
let marker_col = color.linear_multiply(0.42);
let p1 = egui::pos2(x - w, y + w * 1.8);
let p2 = egui::pos2(x + w, y - w * 1.8);
painter.line_segment([p1, p2], egui::Stroke::new(3.8, marker_col));
let mc = color.linear_multiply(0.42);
painter.line_segment(
[egui::pos2(x - w, y + w * 1.8), egui::pos2(x + w, y - w * 1.8)],
egui::Stroke::new(3.8, mc),
);
}
}
hcie_engine_api::tools::BrushStyle::Sketch => {
// Sketch overlapping lines
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -574,46 +554,27 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Star => {
// Tiny star sparkles
for (i, &(x, y, t)) in points.iter().enumerate() {
if i % 6 != 0 {
continue;
}
if i % 6 != 0 { continue; }
let p = (t * std::f32::consts::PI).sin();
let r = 4.2 * p;
if r < 1.0 {
continue;
}
painter.line_segment(
[egui::pos2(x - r, y), egui::pos2(x + r, y)],
egui::Stroke::new(1.1, color),
);
painter.line_segment(
[egui::pos2(x, y - r), egui::pos2(x, y + r)],
egui::Stroke::new(1.1, color),
);
if r < 1.0 { continue; }
painter.line_segment([egui::pos2(x - r, y), egui::pos2(x + r, y)], egui::Stroke::new(1.1, color));
painter.line_segment([egui::pos2(x, y - r), egui::pos2(x, y + r)], egui::Stroke::new(1.1, color));
}
}
hcie_engine_api::tools::BrushStyle::Dirt => {
// Dusty dirt speckles
for &(x, y, t) in &points {
let p = (t * std::f32::consts::PI).sin();
if p < 0.1 {
continue;
}
if p < 0.1 { continue; }
for s in 0..2 {
let ox = (x * 47.3 + s as f32 * 11.0).sin() * 6.0 * p;
let oy = (y * 53.2 + s as f32 * 17.0).cos() * 6.0 * p;
painter.circle_filled(
egui::pos2(x + ox, y + oy),
0.7,
color.linear_multiply(0.4),
);
painter.circle_filled(egui::pos2(x + ox, y + oy), 0.7, color.linear_multiply(0.4));
}
}
}
hcie_engine_api::tools::BrushStyle::Mixer | hcie_engine_api::tools::BrushStyle::Blender => {
// Dual gradient smudge ribbon
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -626,7 +587,9 @@ pub fn draw_brush_preview(
}
}
hcie_engine_api::tools::BrushStyle::Bitmap => {
// Show a simple stroke preview for imported bitmap brushes
if let Some(tip) = tip {
render_bitmap_thumbnail(painter, rect, tip, color);
} else {
for i in 0..n_points {
let (x1, y1, t1) = points[i];
let (x2, y2, _t2) = points[i + 1];
@@ -640,8 +603,53 @@ pub fn draw_brush_preview(
}
}
}
}
fn render_bitmap_thumbnail(
painter: &egui::Painter,
rect: egui::Rect,
tip: &BrushTip,
color: egui::Color32,
) {
if tip.bitmap_pixels.is_empty() || tip.bitmap_w == 0 || tip.bitmap_h == 0 {
return;
}
let bw = tip.bitmap_w as f32;
let bh = tip.bitmap_h as f32;
let scale = (rect.width() / bw).min(rect.height() / bh);
let draw_w = bw * scale;
let draw_h = bh * scale;
let offset_x = (rect.width() - draw_w) * 0.5;
let offset_y = (rect.height() - draw_h) * 0.5;
let pixel_size = scale.max(1.0);
let mut shapes = Vec::new();
let mut y = 0.0;
let mut sy = 0u32;
while sy < tip.bitmap_h {
let mut x = 0.0;
let mut sx = 0u32;
while sx < tip.bitmap_w {
let mask_val = tip.bitmap_pixels[(sy * tip.bitmap_w + sx) as usize] as f32 / 255.0;
if mask_val > 0.01 {
let a = (mask_val * color.a() as f32).round() as u8;
let c = egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), a);
let p = rect.min + egui::vec2(offset_x + x, offset_y + y);
shapes.push(egui::Shape::rect_filled(
egui::Rect::from_min_size(p, egui::vec2(pixel_size, pixel_size)),
0.0,
c,
));
}
x += pixel_size;
sx += 1;
}
y += pixel_size;
sy += 1;
}
painter.add(egui::Shape::Vec(shapes));
}
/// Convert standard lib-level `hcie_engine_api::BrushStyle` to tool-level `hcie_engine_api::tools::BrushStyle`.
pub fn to_tools_style(style: hcie_engine_api::BrushStyle) -> hcie_engine_api::tools::BrushStyle {
match style {
hcie_engine_api::BrushStyle::Round
@@ -1006,8 +1014,8 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
ui.add_space(6.0);
// 2. Category Tab Filters - Oval Pills with 12px rounding
let categories = ["All", "Drawing", "Painting", "Effects", "Custom"];
let category_map = |cat: &str| -> Vec<&'static str> {
let categories = ["All", "Drawing", "Painting", "Effects", "Custom", "Imported"];
let _category_map = |cat: &str| -> Vec<&'static str> {
match cat {
"Drawing" => vec!["Basic", "Sketch"],
"Painting" => vec!["Paint", "Ink"],
@@ -1068,6 +1076,18 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
app.show_brush_editor = true;
app.brush_editor_tab = 0;
}
let refresh_btn = ui.add(
egui::Button::new(
egui::RichText::new("Refresh")
.size(10.0)
.color(colors.text_secondary),
)
.fill(egui::Color32::TRANSPARENT),
);
if refresh_btn.clicked() {
state.brush_thumbnail_cache.clear();
state.brush_thumbnail_dirty = true;
}
});
// 4. Segmented View Toggle Control Block (Unified with flat inner borders and outer rounded corners)
@@ -1238,12 +1258,20 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
egui::Stroke::new(0.5, colors.border_low),
egui::StrokeKind::Outside,
);
draw_brush_preview(
painter,
let h = preset_thumb_hash(preset);
let cache_key = format!("{}.{}", preset.id, h);
if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) {
let tex = render_thumb_to_texture(ui.ctx(), preset);
state.brush_thumbnail_cache.insert(cache_key.clone(), tex);
}
if let Some(tex) = state.brush_thumbnail_cache.get(&cache_key) {
painter.image(
tex.id(),
preview_rect,
to_tools_style(map_brush_style(preset.style)),
primary_srgba,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
// Swatch Label Details
let name_pos = rect.left_top() + egui::vec2(86.0, 8.0);
@@ -1411,12 +1439,20 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
egui::Color32::from_gray(28)
};
painter.rect_filled(preview_rect, egui::CornerRadius::same(2), bg_fill);
draw_brush_preview(
painter,
let h = preset_thumb_hash(preset);
let cache_key = format!("{}.{}", preset.id, h);
if state.brush_thumbnail_dirty || !state.brush_thumbnail_cache.contains_key(&cache_key) {
let tex = render_thumb_to_texture(ui.ctx(), preset);
state.brush_thumbnail_cache.insert(cache_key.clone(), tex);
}
if let Some(tex) = state.brush_thumbnail_cache.get(&cache_key) {
painter.image(
tex.id(),
preview_rect,
to_tools_style(map_brush_style(preset.style)),
primary_srgba,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
egui::Color32::WHITE,
);
}
// Name
let name_pos =
@@ -1620,7 +1656,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
egui::Stroke::new(0.5, colors.border_low),
egui::StrokeKind::Outside,
);
draw_brush_preview(painter, preview_rect, style, primary_srgba);
draw_brush_preview(painter, preview_rect, style, primary_srgba, None);
let name_pos =
egui::pos2(cell_rect.center().x, preview_rect.bottom() + 1.0);
@@ -1695,4 +1731,7 @@ pub fn show_brushes_ui(app: &mut HcieApp, ui: &mut egui::Ui) {
}
});
});
// Clear dirty flag after all thumbnails have been rendered this frame
state.brush_thumbnail_dirty = false;
}
@@ -0,0 +1,273 @@
//! Small, stateless helper functions used across the app.
//!
//! This module gathers image/color utilities, file-dialog builders, save-error
//! hints, and the flood-fill mask generator so the main app module stays
//! focused on orchestration.
use eframe::egui;
/// Format an RGBA color as a `#RRGGBB` hex string (alpha is ignored).
pub fn format_color(c: &[u8; 4]) -> String {
format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2])
}
/// **Purpose:** Draws a pixel-perfect, highly-refined minimalist pushpin vector icon.
///
/// **Logic & Workflow:**
/// - If `pinned` is true, renders a vertical pushpin (cap, body, ridge, needle).
/// - If `pinned` is false, renders a slanted 45-degree pushpin pointing down-left.
pub fn draw_premium_pin(
painter: &egui::Painter,
center: egui::Pos2,
color: egui::Color32,
pinned: bool,
) {
if pinned {
// Vertical pin (pinned state)
let cap_rect =
egui::Rect::from_center_size(center - egui::vec2(0.0, 4.0), egui::vec2(8.0, 1.5));
painter.rect_filled(cap_rect, 0.5, color);
let body_rect =
egui::Rect::from_center_size(center - egui::vec2(0.0, 1.0), egui::vec2(5.0, 4.5));
painter.rect_filled(body_rect, 0.5, color);
let ridge_rect =
egui::Rect::from_center_size(center + egui::vec2(0.0, 1.75), egui::vec2(7.0, 1.0));
painter.rect_filled(ridge_rect, 0.0, color);
let needle_rect =
egui::Rect::from_center_size(center + egui::vec2(0.0, 4.75), egui::vec2(1.2, 5.0));
painter.rect_filled(needle_rect, 0.0, color);
} else {
// Slanted pin (45 degrees pointing down-left, i.e., unpinned)
let dir = egui::vec2(-1.0, 1.0).normalized();
let ortho = egui::vec2(1.0, 1.0).normalized();
painter.line_segment(
[center + dir * 0.5, center + dir * 5.5],
egui::Stroke::new(1.3, color),
);
let ridge_c = center - dir * 0.75;
painter.line_segment(
[ridge_c - ortho * 3.0, ridge_c + ortho * 3.0],
egui::Stroke::new(1.2, color),
);
let body_c = ridge_c - dir * 2.0;
let p1 = body_c - ortho * 2.0 - dir * 1.5;
let p2 = body_c + ortho * 2.0 - dir * 1.5;
let p3 = body_c + ortho * 2.0 + dir * 1.5;
let p4 = body_c - ortho * 2.0 + dir * 1.5;
painter.add(egui::Shape::convex_polygon(
vec![p1, p2, p3, p4],
color,
egui::Stroke::NONE,
));
let cap_c = ridge_c - dir * 4.25;
painter.line_segment(
[cap_c - ortho * 3.5, cap_c + ortho * 3.5],
egui::Stroke::new(1.5, color),
);
}
}
/// Crop an egui ColorImage to the given screen-space rectangle (rounded to pixels).
pub fn crop_colorimage(img: &egui::ColorImage, rect: egui::Rect) -> Option<egui::ColorImage> {
let x0 = rect.min.x.max(0.0).round() as usize;
let y0 = rect.min.y.max(0.0).round() as usize;
let x1 = (rect.max.x.min(img.width() as f32)).round() as usize;
let y1 = (rect.max.y.min(img.height() as f32)).round() as usize;
let w = x1.saturating_sub(x0);
let h = y1.saturating_sub(y0);
if w == 0 || h == 0 {
return None;
}
let mut bytes = Vec::with_capacity(w * h * 4);
for y in y0..y1 {
for x in x0..x1 {
let c = img[(x, y)];
bytes.extend_from_slice(&c.to_array());
}
}
Some(egui::ColorImage::from_rgba_unmultiplied([w, h], &bytes))
}
pub fn save_colorimage(path: &std::path::Path, img: &egui::ColorImage) -> Result<(), String> {
let mut bytes: Vec<u8> = Vec::with_capacity(img.width() * img.height() * 4);
for c in img.pixels.iter() {
bytes.extend_from_slice(&c.to_array());
}
image::save_buffer(
path,
&bytes,
img.width() as u32,
img.height() as u32,
image::ColorType::Rgba8,
)
.map_err(|e| e.to_string())
}
/// Builds a fallback save path when the user-selected location cannot be written.
///
/// The suggestion tries the user's home directory first, then the system temp directory,
/// preserving the original file stem and extension. This gives the user a concrete,
/// writable alternative they can select instead of silently failing.
pub fn suggest_alternative_path(original: &std::path::Path) -> std::path::PathBuf {
let stem = original
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
let ext = original
.extension()
.and_then(|e| e.to_str())
.unwrap_or("hcie");
let file_name = format!("{}_copy.{}", stem, ext);
if let Some(home) = dirs::home_dir() {
let candidate = home.join(&file_name);
if candidate.parent().map(|p| p.exists()).unwrap_or(false) {
return candidate;
}
}
std::env::temp_dir().join(file_name)
}
/// Builds a Save/Export file dialog with the document's source format selected
/// by default.
///
/// **Purpose:** Ensures the active file-type filter matches the document that
/// is being saved (e.g. KRA for a Krita file). If the format is unknown, the
/// helper still builds the dialog with HCIE Native listed first. An "All
/// Supported Types" filter is appended at the end so the user can pick any
/// supported format.
///
/// **Arguments:**
/// * `default_format` - Lowercase extension to pre-select (`kra`, `hcie`, `png`, ...).
/// * `file_stem` - Base name for the suggested file.
/// * `is_export` - When `true`, appends `_export` to the file stem.
pub fn build_save_dialog(default_format: &str, file_stem: &str, is_export: bool) -> rfd::FileDialog {
let suffix = if is_export { "_export" } else { "" };
let file_name = format!("{}{}.{}", file_stem, suffix, default_format);
let all_supported: &[&str] = &[
"png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "tif", "avif", "ico", "pnm", "qoi",
"hdr", "dds", "tga", "exr", "psd", "kra", "hcie",
];
let filters: Vec<(&str, &[&str])> = vec![
("HCIE Native", &["hcie"]),
("KRA", &["kra"]),
("PSD", &["psd"]),
("PNG", &["png"]),
("JPEG", &["jpg", "jpeg"]),
("WebP", &["webp"]),
("AVIF", &["avif"]),
("BMP", &["bmp"]),
("GIF", &["gif"]),
("TIFF", &["tiff", "tif"]),
("ICO", &["ico"]),
("PNM", &["pnm"]),
("QOI", &["qoi"]),
("HDR", &["hdr"]),
("DDS", &["dds"]),
("TGA", &["tga"]),
("OpenEXR", &["exr"]),
];
// Start with the format matching the document so it is the active filter.
let mut dialog = rfd::FileDialog::new().set_file_name(file_name);
for (name, exts) in &filters {
if exts.contains(&default_format) {
dialog = dialog.add_filter(*name, *exts);
break;
}
}
// Then add the remaining format filters.
for (name, exts) in &filters {
if !exts.contains(&default_format) {
dialog = dialog.add_filter(*name, *exts);
}
}
// Append a catch-all filter last.
dialog.add_filter("All Supported Types", all_supported)
}
/// Formats a short, actionable error message for the save/export failure dialog.
///
/// Detects common I/O errors (permission denied, not found, disk full) and adds a localized
/// hint so the user understands why the operation failed and what to try next.
pub fn format_save_error_hint(error: &str) -> &'static str {
let lower = error.to_lowercase();
if lower.contains("permission denied") || lower.contains("os error 13") {
"The selected folder is read-only or you don't have permission to write there."
} else if lower.contains("not found") || lower.contains("os error 2") {
"The parent folder no longer exists."
} else if lower.contains("no space") || lower.contains("os error 28") {
"The destination disk is full."
} else {
"Check that the destination folder exists and is writable."
}
}
/// Flood fill on composite RGBA pixels — works across all layers.
/// Returns a `w*h` byte mask where 255 = selected, 0 = not selected.
pub fn composite_flood_fill(
pixels: &[u8],
w: u32,
h: u32,
sx: u32,
sy: u32,
tolerance: i32,
) -> Vec<u8> {
let (w, h) = (w as usize, h as usize);
let mut mask = vec![0u8; w * h];
if sx as usize >= w || sy as usize >= h || pixels.len() < w * h * 4 {
return mask;
}
let seed = (sy as usize * w + sx as usize) * 4;
let sr = pixels[seed] as i32;
let sg = pixels[seed + 1] as i32;
let sb = pixels[seed + 2] as i32;
let sa = pixels[seed + 3] as i32;
let mut queue = std::collections::VecDeque::new();
queue.push_back((sx as usize, sy as usize));
mask[sy as usize * w + sx as usize] = 255;
let mut count = 0usize;
while let Some((cx, cy)) = queue.pop_front() {
count += 1;
if count > 500_000 {
break;
}
for (dx, dy) in [(0i32, 1i32), (0, -1), (1, 0), (-1, 0)] {
let nx = cx as i32 + dx;
let ny = cy as i32 + dy;
if nx < 0 || nx >= w as i32 || ny < 0 || ny >= h as i32 {
continue;
}
let (nx, ny) = (nx as usize, ny as usize);
let ni = ny * w + nx;
if mask[ni] != 0 {
continue;
}
let pi = ni * 4;
let diff = (pixels[pi] as i32 - sr).abs()
+ (pixels[pi + 1] as i32 - sg).abs()
+ (pixels[pi + 2] as i32 - sb).abs()
+ (pixels[pi + 3] as i32 - sa).abs();
if diff <= tolerance {
mask[ni] = 255;
queue.push_back((nx, ny));
}
}
}
mask
}
@@ -3,6 +3,11 @@ use hcie_engine_api::brush::{BrushStyle, BrushTip};
use hcie_engine_api::BrushPreset;
use std::io::{Cursor, Read, Seek, SeekFrom};
/// Parse an ABR brush file and return a list of `BrushPreset` entries.
///
/// The ABR format stores sampled bitmap brushes in a `samp` resource block and
/// parametric/computed brushes in `computedB` markers. Brush names are extracted
/// from the `desc` block (which contains `Nm TEXT` tags) when available.
pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
let mut presets = Vec::new();
let mut cursor = Cursor::new(data);
@@ -26,6 +31,12 @@ pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
if end > start && end <= data.len() {
presets.extend(harvest_sampled_brushes(&data[start..end], start));
}
} else if tag == "Brs2" || tag == "brst" {
let start = cursor.position() as usize;
let end = block_end as usize;
if end > start && end <= data.len() {
presets.extend(harvest_brs2_sampled_brushes(&data[start..end], start));
}
}
pos = (block_end as usize + 1) & !1;
} else {
@@ -34,6 +45,10 @@ pub fn import_abr(data: &[u8]) -> Vec<BrushPreset> {
}
presets.extend(harvest_computed_brushes(data));
let desc_names = extract_desc_block_names(data);
apply_desc_names(&mut presets, &desc_names);
presets
}
@@ -133,10 +148,16 @@ fn try_decode_and_scale(
dst_w: u32,
dst_h: u32,
) -> Option<Vec<u8>> {
let decoded = decode_packbits_lenient(data, src_w * src_h);
if decoded.len() != src_w * src_h {
// Some ABR files store the width as (right - left) but the PackBits stream
// contains one extra padding column/row. Accept decoded data that is at least
// src_w * src_h and truncate any surplus instead of rejecting the brush.
let mut decoded = decode_packbits_lenient(data, src_w * src_h);
if decoded.len() < src_w * src_h {
return None;
}
if decoded.len() > src_w * src_h {
decoded.truncate(src_w * src_h);
}
if src_w as u32 == dst_w && src_h as u32 == dst_h {
return Some(decoded);
}
@@ -191,6 +212,86 @@ fn decode_packbits_lenient(data: &[u8], expected_size: usize) -> Vec<u8> {
out
}
/// Heuristic extraction for sampled brushes embedded in a `Brs2` / `brst`
/// resource block. Older ABR versions lay out the data as:
/// [count u32] for each brush [l t r b i32] [depth?] [name pascal]
/// [encoding byte?] [PackBits image data]
/// This is intentionally lenient so we do not crash on partially unknown formats.
fn harvest_brs2_sampled_brushes(data: &[u8], base_pos: usize) -> Vec<BrushPreset> {
let mut presets = Vec::new();
if data.len() < 4 {
return presets;
}
let count = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
if count == 0 || count > 1000 {
return presets;
}
let mut i = 4usize;
for _ in 0..count {
if i + 16 > data.len() {
break;
}
let l = i32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]);
let t = i32::from_be_bytes([data[i + 4], data[i + 5], data[i + 6], data[i + 7]]);
let r = i32::from_be_bytes([data[i + 8], data[i + 9], data[i + 10], data[i + 11]]);
let b = i32::from_be_bytes([data[i + 12], data[i + 13], data[i + 14], data[i + 15]]);
i += 16;
let w = (r as i64 - l as i64).abs() as u32;
let h = (b as i64 - t as i64).abs() as u32;
if w == 0 || h == 0 || w > 10000 || h > 10000 {
continue;
}
// Skip depth/name/etc until we find an encoding signature or raw data.
// Try to locate a PackBits-like run near expected data offset.
let data_start = i;
let data_end = (i + (w * h * 2) as usize + 1024).min(data.len());
let slice = &data[data_start..data_end];
let (final_w, final_h) = if w > 1024 || h > 1024 {
let scale = 1024.0 / (w.max(h) as f32);
((w as f32 * scale) as u32, (h as f32 * scale) as u32)
} else {
(w, h)
};
if let Some(decoded) = try_decode_and_scale(slice, w as usize, h as usize, final_w, final_h) {
presets.push(BrushPreset {
id: format!("abr_brs2_{}_{}x{}", base_pos + i, final_w, final_h),
name: format!("Brush {}x{}", final_w, final_h),
category: "Imported (ABR)".to_string(),
tip: BrushTip {
style: BrushStyle::Bitmap,
size: final_w.max(final_h) as f32,
opacity: 1.0,
hardness: 1.0,
spacing: 0.1,
jitter_amount: 0.0,
scatter_amount: 0.0,
angle: 0.0,
roundness: 1.0,
spray_particle_size: 2.0,
spray_density: 100,
bitmap_pixels: decoded,
bitmap_w: final_w,
bitmap_h: final_h,
flow: 1.0,
color_variant: false,
variant_amount: 0.0,
density: 1.0,
drawing_angle: false,
rotation_random: 0.0,
},
style: BrushStyle::Bitmap,
pressure_size: true,
pressure_opacity: true,
pressure_flow: true,
jitter_position: 0.0,
jitter_angle: 0.0,
});
}
i = data_end;
}
presets
}
fn harvest_computed_brushes(data: &[u8]) -> Vec<BrushPreset> {
let mut presets = Vec::new();
let marker = b"computedB";
@@ -304,6 +405,227 @@ fn read_u32(cursor: &mut Cursor<&[u8]>) -> u32 {
u32::from_be_bytes(buf)
}
pub fn import_krita_preset(_data: &[u8]) -> Option<BrushPreset> {
None
/// Extract brush names from the `desc` block's `Nm TEXT` tags.
///
/// ABR files store a `desc` resource block that contains `BrshVlLs` (Brush
/// Value Lists). Each entry has a `Nm TEXT` field with the brush name in
/// UTF-16 encoding. The names appear in the same order as the brushes in the
/// `samp` block, so they can be associated by index.
fn extract_desc_block_names(data: &[u8]) -> Vec<String> {
let mut names = Vec::new();
let desc_pos = match find_bytes(data, b"8BIMdesc") {
Some(p) => p,
None => return names,
};
let size_pos = desc_pos + 8;
if size_pos + 4 > data.len() {
return names;
}
let size = u32::from_be_bytes([
data[size_pos],
data[size_pos + 1],
data[size_pos + 2],
data[size_pos + 3],
]) as usize;
let block_start = size_pos + 4;
let block_end = (block_start + size).min(data.len());
if block_end <= block_start {
return names;
}
let block = &data[block_start..block_end];
let mut idx = 0;
while idx < block.len() - 8 {
if &block[idx..idx + 8] == b"Nm TEXT" {
let p = idx + 8;
if p + 4 > block.len() {
break;
}
let nchars = u32::from_be_bytes([block[p], block[p + 1], block[p + 2], block[p + 3]]) as usize;
let text_start = p + 4;
if nchars == 0 || text_start + nchars * 2 > block.len() {
idx += 1;
continue;
}
let chars: Vec<u16> = block[text_start..text_start + nchars * 2]
.chunks_exact(2)
.map(|b| u16::from_be_bytes([b[0], b[1]]))
.collect();
let full = String::from_utf16_lossy(&chars);
let name = full
.split('=')
.last()
.unwrap_or(&full)
.trim_matches('\x00')
.to_string();
if !name.is_empty() {
names.push(name);
}
idx = text_start + nchars * 2;
} else {
idx += 1;
}
}
names
}
/// Apply names extracted from the `desc` block to presets.
///
/// The desc block names appear in the same order as sampled brushes were
/// collected. Computed brushes already have their names from `Nm TEXT` tags
/// inside the computed brush data, so only sampled presets (Bitmap style)
/// get updated.
fn apply_desc_names(presets: &mut [BrushPreset], desc_names: &[String]) {
if desc_names.is_empty() {
return;
}
let mut name_idx = 0;
for preset in presets.iter_mut() {
if preset.style == BrushStyle::Bitmap && name_idx < desc_names.len() {
let new_name = &desc_names[name_idx];
if !new_name.is_empty() {
preset.name = new_name.clone();
}
name_idx += 1;
}
}
}
/// Parse an ABR file and prefix generated IDs/names so imports from different
/// files do not collide.
pub fn import_abr_with_prefix(data: &[u8], prefix: &str) -> Vec<BrushPreset> {
let mut presets = import_abr(data);
let safe_prefix = prefix.replace(' ', "_").replace('.', "_");
for preset in &mut presets {
preset.id = format!("{}_{}", safe_prefix, preset.id);
preset.name = format!("{} ({})", preset.name, safe_prefix);
}
presets
}
/// Parse a Krita brush preset (`.kpp`).
///
/// A `.kpp` file is a ZIP archive. This implementation extracts the `preview.png`
/// thumbnail, converts it to a grayscale alpha mask, and returns a single
/// `BrushStyle::Bitmap` preset.
pub fn import_kpp(data: &[u8], name_hint: &str) -> Option<BrushPreset> {
use std::io::Cursor;
use zip::ZipArchive;
let cursor = Cursor::new(data);
let mut zip = ZipArchive::new(cursor).ok()?;
let mut preview_bytes: Option<Vec<u8>> = None;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).ok()?;
let entry_name = entry.name().to_lowercase();
if entry_name.ends_with("preview.png") || entry_name == "preview.png" {
let mut buf = Vec::new();
if entry.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
preview_bytes = Some(buf);
break;
}
}
}
let preview_bytes = preview_bytes?;
let img = image::load_from_memory(&preview_bytes).ok()?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
if w == 0 || h == 0 {
return None;
}
// Downscale huge previews so GPU brush stamps stay cheap.
let (final_w, final_h) = if w > 512 || h > 512 {
let scale = 512.0 / (w.max(h) as f32);
((w as f32 * scale) as u32, (h as f32 * scale) as u32)
} else {
(w, h)
};
let mut gray = Vec::with_capacity((final_w * final_h) as usize);
for y in 0..final_h {
for x in 0..final_w {
let src_x = (x as f32 * (w as f32 / final_w as f32)) as u32;
let src_y = (y as f32 * (h as f32 / final_h as f32)) as u32;
let px = rgba.get_pixel(src_x.min(w - 1), src_y.min(h - 1));
// Use perceived luminance from RGB and keep full alpha range.
let l = (0.299 * px[0] as f32 + 0.587 * px[1] as f32 + 0.114 * px[2] as f32)
.clamp(0.0, 255.0) as u8;
let a = px[3];
// Mask value = luminance scaled by alpha.
gray.push((l as u16 * a as u16 / 255) as u8);
}
}
Some(BrushPreset {
id: format!("kpp_imported_{}_{}x{}", name_hint.replace(' ', "_"), final_w, final_h),
name: format!("{} (KPP)", name_hint),
category: "Imported".to_string(),
tip: BrushTip {
style: BrushStyle::Bitmap,
size: final_w.max(final_h) as f32,
opacity: 1.0,
hardness: 1.0,
spacing: 0.1,
jitter_amount: 0.0,
scatter_amount: 0.0,
angle: 0.0,
roundness: 1.0,
spray_particle_size: 2.0,
spray_density: 100,
bitmap_pixels: gray,
bitmap_w: final_w,
bitmap_h: final_h,
flow: 1.0,
color_variant: false,
variant_amount: 0.0,
density: 1.0,
drawing_angle: false,
rotation_random: 0.0,
},
style: BrushStyle::Bitmap,
pressure_size: true,
pressure_opacity: true,
pressure_flow: true,
jitter_position: 0.0,
jitter_angle: 0.0,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_desc_block_names() {
let path = "/mnt/data/Downloads/Square Brushes.abr";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => {
eprintln!("Skipping test: {} not found", path);
return;
}
};
let names = extract_desc_block_names(&data);
assert!(!names.is_empty(), "Should extract names from desc block");
assert!(
names[0].contains("Hard Square"),
"First name should be descriptive, got: {}",
names[0]
);
}
#[test]
fn test_abr_import_with_names() {
let path = "/mnt/data/Downloads/Square Brushes.abr";
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => {
eprintln!("Skipping test: {} not found", path);
return;
}
};
let presets = import_abr(&data);
let bitmap: Vec<_> = presets.iter().filter(|p| p.style == BrushStyle::Bitmap).collect();
assert!(!bitmap.is_empty(), "Should have bitmap presets");
let has_names = bitmap.iter().any(|p| !p.name.starts_with("Brush "));
assert!(has_names, "Bitmap presets should have descriptive names");
}
}
@@ -4,6 +4,7 @@ use crate::app::{tools::shape_sync, AppDocument, SelectionTransform, ToolState,
use crate::event_bus::{AppEvent, EventBus};
use eframe::egui;
use hcie_engine_api::{LayerData, Tool, VectorEditHandle, VectorShape, ZOOM_MAX, ZOOM_MIN};
use hcie_engine_api::brush::BrushStyle;
pub mod render;
pub mod text_overlay;
@@ -1433,19 +1434,85 @@ impl<'a> egui::Widget for CanvasWidget<'a> {
if self.state.active_tool.shows_pen_tips() {
let screen_x = canvas_rect.min.x + cx as f32 * zoom;
let screen_y = canvas_rect.min.y + cy as f32 * zoom;
let radius = self.state.active_size() * zoom * 0.5;
let color = egui::Color32::from_rgba_unmultiplied(
self.state.primary_color[0],
self.state.primary_color[1],
self.state.primary_color[2],
180,
);
let active_tip = self
.state
.brush_presets
.iter()
.find(|p| p.id == self.state.active_brush_preset_id)
.map(|p| &p.tip);
if let Some(tip) = active_tip {
if tip.style == BrushStyle::Bitmap
&& !tip.bitmap_pixels.is_empty()
&& tip.bitmap_w > 0
&& tip.bitmap_h > 0
{
let bw = tip.bitmap_w as f32;
let bh = tip.bitmap_h as f32;
let stamp_size = self.state.active_size() * zoom;
let scale = (stamp_size / bw).min(stamp_size / bh);
let draw_w = bw * scale;
let draw_h = bh * scale;
let origin_x = screen_x - draw_w * 0.5;
let origin_y = screen_y - draw_h * 0.5;
let px_size = scale.max(1.0);
let mut shapes = Vec::new();
let mut py = 0.0;
let mut sy = 0u32;
while sy < tip.bitmap_h {
let mut px = 0.0;
let mut sx = 0u32;
while sx < tip.bitmap_w {
let mask =
tip.bitmap_pixels[(sy * tip.bitmap_w + sx) as usize] as f32
/ 255.0;
if mask > 0.01 {
let a = (mask * 180.0).round() as u8;
let c = egui::Color32::from_rgba_unmultiplied(
color.r(),
color.g(),
color.b(),
a,
);
let p = egui::pos2(origin_x + px, origin_y + py);
shapes.push(egui::Shape::rect_filled(
egui::Rect::from_min_size(
p,
egui::vec2(px_size, px_size),
),
0.0,
c,
));
}
px += px_size;
sx += 1;
}
py += px_size;
sy += 1;
}
painter.add(egui::Shape::Vec(shapes));
} else {
let radius = self.state.active_size() * zoom * 0.5;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, color),
);
}
} else {
let radius = self.state.active_size() * zoom * 0.5;
painter.circle_stroke(
egui::pos2(screen_x, screen_y),
radius,
egui::Stroke::new(1.5, color),
);
}
}
}
// ── Selection overlay ──────────────────────────────────
+51 -50
View File
@@ -9,56 +9,8 @@ use libloading::Library;
/// 0 = CPU, 1 = GPU. Stored here so it can be set before the plugin is loaded.
static VISION_BACKEND: AtomicU8 = AtomicU8::new(0);
pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] {
hcie_blend::blend_pixels(dst, src, mode.into(), opacity)
}
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) {
hcie_blend::blend_buffers(dst, src, mode.into(), opacity)
}
pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> {
let mut fl = hcie_protocol::Layer::new_transparent("", width, height);
fl.pixels.copy_from_slice(pixels);
hcie_filter::apply_filter(&mut fl, filter_id, params);
let len = pixels.len().min(fl.pixels.len());
pixels[..len].copy_from_slice(&fl.pixels[..len]);
Ok(())
}
pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) {
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true;
}
pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) {
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true;
}
pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) {
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true;
}
pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) {
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true;
}
pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) {
let style = if tip.style == hcie_protocol::BrushStyle::Default {
hcie_brush_engine::BrushStyle::Round
} else {
unsafe { std::mem::transmute::<i32, hcie_brush_engine::BrushStyle>(tip.style as i32) }
};
let ct = hcie_brush_engine::BrushTip {
style,
size: tip.size, opacity: tip.opacity, hardness: tip.hardness,
spacing: tip.spacing, flow: tip.flow,
jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount,
angle: tip.angle, roundness: tip.roundness,
spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density,
bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h,
color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density,
drawing_angle: false,
rotation_random: 0.0,
};
hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true;
}
pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) {
let style = match brush_style {
fn map_brush_style(style: hcie_protocol::BrushStyle) -> hcie_brush_engine::BrushStyle {
match style {
hcie_protocol::BrushStyle::Round => hcie_brush_engine::BrushStyle::Round,
hcie_protocol::BrushStyle::Square => hcie_brush_engine::BrushStyle::Square,
hcie_protocol::BrushStyle::HardRound => hcie_brush_engine::BrushStyle::HardRound,
@@ -93,7 +45,56 @@ pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32,
hcie_protocol::BrushStyle::Blender => hcie_brush_engine::BrushStyle::Blender,
hcie_protocol::BrushStyle::Default => hcie_brush_engine::BrushStyle::Round,
hcie_protocol::BrushStyle::Bitmap => hcie_brush_engine::BrushStyle::Bitmap,
}
}
pub fn blend_pixels(dst: [u8; 4], src: [u8; 4], mode: hcie_protocol::BlendMode, opacity: f32) -> [u8; 4] {
hcie_blend::blend_pixels(dst, src, mode.into(), opacity)
}
pub fn blend_buffers(dst: &mut [u8], src: &[u8], mode: hcie_protocol::BlendMode, opacity: f32) {
hcie_blend::blend_buffers(dst, src, mode.into(), opacity)
}
pub fn apply_filter(filter_id: &str, params: &serde_json::Value, pixels: &mut [u8], width: u32, height: u32) -> Result<(), String> {
let mut fl = hcie_protocol::Layer::new_transparent("", width, height);
fl.pixels.copy_from_slice(pixels);
hcie_filter::apply_filter(&mut fl, filter_id, params);
let len = pixels.len().min(fl.pixels.len());
pixels[..len].copy_from_slice(&fl.pixels[..len]);
Ok(())
}
pub fn draw_line(layer: &mut hcie_protocol::Layer, x0: f32, y0: f32, x1: f32, y1: f32, color: [u8; 4], stroke_size: f32, mask: Option<&[u8]>) {
hcie_draw::draw_line(layer, x0, y0, x1, y1, color, stroke_size, mask); layer.dirty = true;
}
pub fn draw_filled_rect(layer: &mut hcie_protocol::Layer, x1: f32, y1: f32, x2: f32, y2: f32, color: [u8; 4], mask: Option<&[u8]>) {
hcie_draw::draw_filled_rect(layer, x1, y1, x2, y2, color, mask); layer.dirty = true;
}
pub fn draw_filled_ellipse(layer: &mut hcie_protocol::Layer, cx: f32, cy: f32, rx: f32, ry: f32, color: [u8; 4], mask: Option<&[u8]>) {
hcie_draw::draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask); layer.dirty = true;
}
pub fn flood_fill(layer: &mut hcie_protocol::Layer, x: u32, y: u32, color: [u8; 4], tolerance: u8, mask: Option<&[u8]>) {
hcie_draw::flood_fill(layer, x, y, color, tolerance, mask); layer.dirty = true;
}
pub fn draw_brush_stroke(layer: &mut hcie_protocol::Layer, points: &[(f32, f32, f32)], color: [u8; 4], tip: &hcie_protocol::BrushTip, is_eraser: bool, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>) {
let style = map_brush_style(tip.style);
let ct = hcie_brush_engine::BrushTip {
style,
size: tip.size, opacity: tip.opacity, hardness: tip.hardness,
spacing: tip.spacing, flow: tip.flow,
jitter_amount: tip.jitter_amount, scatter_amount: tip.scatter_amount,
angle: tip.angle, roundness: tip.roundness,
spray_particle_size: tip.spray_particle_size, spray_density: tip.spray_density,
bitmap_pixels: tip.bitmap_pixels.clone(), bitmap_w: tip.bitmap_w, bitmap_h: tip.bitmap_h,
color_variant: tip.color_variant, variant_amount: tip.variant_amount, density: tip.density,
drawing_angle: false,
rotation_random: 0.0,
};
hcie_draw::draw_brush_stroke(layer, points, color, &ct, is_eraser, mask, stroke_mask, bg_pixels); layer.dirty = true;
}
pub fn draw_specialized_stroke(pixels: &mut [u8], width: u32, height: u32, points: &[(f32, f32, f32)], brush_style: hcie_protocol::BrushStyle, size: f32, hardness: f32, color: [u8; 4], opacity: f32, spacing_ratio: f32, is_eraser: bool, sketch_history: Option<&mut Vec<(f32, f32)>>, spray_particle_size: f32, spray_density: u32, mask: Option<&[u8]>, stroke_mask: Option<&mut [u8]>, bg_pixels: Option<&[u8]>, color_variant: bool, variant_amount: f32, density: f32) {
let style = map_brush_style(brush_style);
hcie_brush_engine::draw_specialized_stroke(pixels, width, height, points, style, size, hardness, color, opacity, spacing_ratio, is_eraser, sketch_history, spray_particle_size, spray_density, mask, stroke_mask, bg_pixels, color_variant, variant_amount, density)
}
@@ -0,0 +1,49 @@
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
#[test]
fn bitmap_brush_stamp_shape() {
let mut engine = Engine::new(64, 64);
// 4x4 solid white square bitmap stamp
let mut pixels = vec![0u8; 16];
for i in 0..16 { pixels[i] = 255; }
let brush = BrushTip {
style: BrushStyle::Bitmap,
size: 8.0,
opacity: 1.0,
hardness: 1.0,
spacing: 1.0,
bitmap_pixels: pixels,
bitmap_w: 4,
bitmap_h: 4,
..Default::default()
};
engine.set_brush_tip(brush);
engine.begin_stroke(engine.active_layer_id(), 20.0, 20.0);
engine.stroke_to(engine.active_layer_id(), 20.0, 20.0, 1.0);
engine.end_stroke(engine.active_layer_id());
let output = engine.get_composite_pixels();
// Check that the painted area is roughly 4x4, not a circular blob.
// Count alpha > 0 pixels and bounding box.
let mut min_x = 63; let mut max_x = 0; let mut min_y = 63; let mut max_y = 0;
let mut count = 0;
for y in 0..64 {
for x in 0..64 {
let a = output[(y*64+x)*4+3];
if a > 0 {
count += 1;
if x < min_x { min_x = x; }
if x > max_x { max_x = x; }
if y < min_y { min_y = y; }
if y > max_y { max_y = y; }
}
}
}
let w = (max_x - min_x + 1) as i32;
let h = (max_y - min_y + 1) as i32;
// Should fit inside ~8x8 because size=8 and stamp 4x4 scaled up
assert!(w <= 8 && h <= 8, "bitmap dab bounding box {}x{} too large for 4x4 stamp", w, h);
assert!(count >= 8, "bitmap dab should paint some pixels");
// Square-ish: aspect ratio not too far from 1 (was very low for circle vs square?)
assert!(w.abs_diff(h) <= 2, "bitmap dab bounding box should be roughly square, got {}x{}", w, h);
}