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:
@@ -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();
|
||||
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)
|
||||
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)),
|
||||
);
|
||||
}
|
||||
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 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));
|
||||
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,22 +587,69 @@ pub fn draw_brush_preview(
|
||||
}
|
||||
}
|
||||
hcie_engine_api::tools::BrushStyle::Bitmap => {
|
||||
// Show a simple stroke preview for imported bitmap brushes
|
||||
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 + 5.0 * p;
|
||||
painter.line_segment(
|
||||
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
|
||||
egui::Stroke::new(thickness, color),
|
||||
);
|
||||
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];
|
||||
let p = (t1 * std::f32::consts::PI).sin();
|
||||
let thickness = 1.0 + 5.0 * p;
|
||||
painter.line_segment(
|
||||
[egui::pos2(x1, y1), egui::pos2(x2, y2)],
|
||||
egui::Stroke::new(thickness, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert standard lib-level `hcie_engine_api::BrushStyle` to tool-level `hcie_engine_api::tools::BrushStyle`.
|
||||
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));
|
||||
}
|
||||
|
||||
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,
|
||||
preview_rect,
|
||||
to_tools_style(map_brush_style(preset.style)),
|
||||
primary_srgba,
|
||||
);
|
||||
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,
|
||||
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,
|
||||
preview_rect,
|
||||
to_tools_style(map_brush_style(preset.style)),
|
||||
primary_srgba,
|
||||
);
|
||||
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,
|
||||
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,18 +1434,84 @@ 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,
|
||||
);
|
||||
painter.circle_stroke(
|
||||
egui::pos2(screen_x, screen_y),
|
||||
radius,
|
||||
egui::Stroke::new(1.5, color),
|
||||
);
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user