3127 lines
147 KiB
Rust
3127 lines
147 KiB
Rust
#![allow(dead_code)]
|
||
//! PUBLIC API for the entire HCIE engine.
|
||
//!
|
||
//! This is the ONLY interface exposed to GUI and AI modules.
|
||
//! AI never sees internal engine code — only these ~20 functions.
|
||
|
||
mod ai_templates;
|
||
pub mod dynamic_loader;
|
||
pub mod ffi;
|
||
|
||
// Performance-critical engine paths isolated into dedicated modules.
|
||
mod layer_property_ops;
|
||
pub mod partial_composite;
|
||
mod stroke_brush;
|
||
mod stroke_cache;
|
||
|
||
use hcie_document::Document;
|
||
use crate::dynamic_loader::{
|
||
draw_filled_rect,
|
||
flood_fill,
|
||
composite_layers,
|
||
apply_filter,
|
||
load_image, save_image, import_psd, import_kra, save_native, load_native, export_psd, export_kra,
|
||
map_brush_style,
|
||
};
|
||
use crate::dynamic_loader::svg_import::import_svg;
|
||
use hcie_tile::TiledLayer;
|
||
use crate::dynamic_loader::vector::{boolean_shapes, BooleanOp};
|
||
pub use crate::dynamic_loader::vector::BooleanOp as VectorBooleanOp;
|
||
use hcie_text::TextRenderer;
|
||
use std::path::Path;
|
||
use std::sync::Mutex;
|
||
|
||
pub use hcie_protocol::{BlendMode, BrushTip, EngineCommand, EngineEvent, LayerInfo, DocumentInfo, ModulePanel, WidgetDescription, VectorShape, VectorEditHandle, LineCap, LayerType, BrushStyle, ToolConfigs, FilterType, LayerStyle, LayerData, ZOOM_MIN, ZOOM_MAX, AlignAxis};
|
||
pub use hcie_protocol::tools::{TextAlignment, TextOrientation, TextEffect};
|
||
pub use hcie_protocol::{all_features, version_string};
|
||
pub use hcie_protocol::{presets, canvas_presets};
|
||
pub use hcie_protocol::thumbnail_nearest;
|
||
pub use hcie_protocol::tools::{self, Tool};
|
||
pub use hcie_blend::Adjustment;
|
||
pub use hcie_brush_engine::presets::BrushPreset;
|
||
pub mod brush {
|
||
pub use hcie_brush_engine::{BrushTip, BrushStyle};
|
||
}
|
||
|
||
/// Helper to convert a PSD `LayerEffect` to a GUI `LayerStyle`
|
||
fn protocol_effect_to_style(fx: &hcie_protocol::effects::LayerEffect) -> Option<hcie_protocol::LayerStyle> {
|
||
use hcie_protocol::effects::LayerEffect as E;
|
||
use hcie_protocol::LayerStyle as S;
|
||
match fx {
|
||
E::DropShadow { enabled, opacity, angle, distance, spread, size, color, blend_mode, .. } => {
|
||
Some(S::DropShadow { enabled: *enabled, opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
E::InnerShadow { enabled, opacity, angle, distance, choke, size, color, blend_mode, .. } => {
|
||
Some(S::InnerShadow { enabled: *enabled, opacity: *opacity, angle: *angle, distance: *distance, spread: *choke, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
E::OuterGlow { enabled, opacity, spread, size, color, blend_mode, .. } => {
|
||
Some(S::OuterGlow { enabled: *enabled, opacity: *opacity, spread: *spread, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
E::InnerGlow { enabled, opacity, choke, size, color, blend_mode, .. } => {
|
||
Some(S::InnerGlow { enabled: *enabled, opacity: *opacity, spread: *choke, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
E::BevelEmboss { enabled, depth, size, angle, altitude, highlight_opacity, shadow_opacity, direction, style, technique, soften, highlight_blend, highlight_color, shadow_blend, shadow_color, .. } => {
|
||
let dir_str = match direction {
|
||
hcie_protocol::effects::Direction::Up => "Up".to_string(),
|
||
hcie_protocol::effects::Direction::Down => "Down".to_string(),
|
||
};
|
||
let style_str = match style {
|
||
hcie_protocol::effects::BevelStyle::InnerBevel => "InnerBevel".to_string(),
|
||
hcie_protocol::effects::BevelStyle::OuterBevel => "OuterBevel".to_string(),
|
||
hcie_protocol::effects::BevelStyle::Emboss => "Emboss".to_string(),
|
||
hcie_protocol::effects::BevelStyle::PillowEmboss => "PillowEmboss".to_string(),
|
||
hcie_protocol::effects::BevelStyle::StrokeEmboss => "StrokeEmboss".to_string(),
|
||
};
|
||
let technique_str = match technique {
|
||
hcie_protocol::effects::Technique::Smooth => "Smooth".to_string(),
|
||
hcie_protocol::effects::Technique::ChiselHard => "ChiselHard".to_string(),
|
||
hcie_protocol::effects::Technique::ChiselSoft => "ChiselSoft".to_string(),
|
||
};
|
||
Some(S::BevelEmboss {
|
||
enabled: *enabled,
|
||
depth: *depth,
|
||
size: *size,
|
||
angle: *angle,
|
||
altitude: *altitude,
|
||
highlight_opacity: *highlight_opacity,
|
||
shadow_opacity: *shadow_opacity,
|
||
direction: dir_str,
|
||
style: style_str,
|
||
technique: technique_str,
|
||
soften: *soften,
|
||
highlight_blend_mode: highlight_blend.clone(),
|
||
highlight_color: *highlight_color,
|
||
shadow_blend_mode: shadow_blend.clone(),
|
||
shadow_color: *shadow_color,
|
||
})
|
||
}
|
||
E::Satin { enabled, opacity, angle, distance, size, color, invert, .. } => {
|
||
Some(S::Satin { enabled: *enabled, opacity: *opacity, angle: *angle, distance: *distance, size: *size, color: *color, invert: *invert })
|
||
}
|
||
E::ColorOverlay { enabled, opacity, color, blend_mode, .. } => {
|
||
Some(S::ColorOverlay { enabled: *enabled, opacity: *opacity, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
E::GradientOverlay { enabled, opacity, blend_mode, angle, scale, gradient, .. } => {
|
||
Some(S::GradientOverlay { enabled: *enabled, opacity: *opacity, blend_mode: blend_mode.clone(), angle: *angle, scale: *scale, gradient_type: gradient.gradient_type })
|
||
}
|
||
E::PatternOverlay { enabled, opacity, blend_mode, scale, pattern_id, .. } => {
|
||
Some(S::PatternOverlay { enabled: *enabled, opacity: *opacity, blend_mode: blend_mode.clone(), scale: *scale, pattern_name: pattern_id.clone() })
|
||
}
|
||
E::Stroke { enabled, size, position, opacity, color, blend_mode, .. } => {
|
||
let pos_str = match position {
|
||
hcie_protocol::effects::StrokePosition::Inside => "Inside".to_string(),
|
||
hcie_protocol::effects::StrokePosition::Center => "Center".to_string(),
|
||
hcie_protocol::effects::StrokePosition::Outside => "Outside".to_string(),
|
||
};
|
||
Some(S::Stroke { enabled: *enabled, size: *size, position: pos_str, opacity: *opacity, color: *color, blend_mode: blend_mode.clone() })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Opaque engine handle.
|
||
///
|
||
/// # Buffer Pooling & Allocation Notes
|
||
///
|
||
/// On a 4K canvas (3840×2160), each RGBA pixel buffer is ~33MB and each
|
||
/// mask buffer is ~8MB. Repeated allocation/deallocation per brush stroke
|
||
/// causes significant GC-style pauses and heap fragmentation during
|
||
/// continuous painting.
|
||
///
|
||
/// Two poolable buffers exist in this struct:
|
||
///
|
||
/// - `active_stroke_mask`: Pooled since 2026-05-28. On `begin_stroke()` the
|
||
/// buffer is reused (zeroed with `fill(0)`) if the size matches; otherwise
|
||
/// a fresh allocation occurs. On `end_stroke()` the buffer is zeroed but
|
||
/// **retained** (not set to `None`). This avoids ~8MB allocation per stroke.
|
||
///
|
||
/// - `stroke_before` + `stroke_before_buf`: Pooled.
|
||
/// At `begin_stroke()`, `layer.pixels` is copied into `stroke_before_buf`
|
||
/// (no allocation after first use). At `end_stroke()`, the buffer is moved
|
||
/// into the background thread for sub-rect extraction, then returned via
|
||
/// `PendingHistoryItem.return_before` and recycled by `commit_pending_history()`.
|
||
/// The "after" snapshot uses a zero-copy raw pointer (Layer 3): the background
|
||
/// thread reads directly from `layer.pixels` via a `SendPtr` wrapper, which
|
||
/// is safe because no mutations happen between `end_stroke()` and the next
|
||
/// `begin_stroke()`. This avoids ~66MB allocation per stroke on 4K canvases.
|
||
///
|
||
/// ## Merge Conflict Artifact Cleanup (2026-05-28)
|
||
///
|
||
/// During the `active_stroke_mask` pooling implementation, stale lines from the
|
||
/// old `end_stroke()` body remained after the merge:
|
||
///
|
||
/// ```ignore
|
||
/// } ← dangling brace from old end_stroke
|
||
/// } ← dangling brace from old end_stroke
|
||
/// self.last_stroke_pos = None; ← duplicate
|
||
/// self.active_stroke_mask = None; ← REGRESSION: drops pooled buffer!
|
||
/// } ← extra closing brace → COMPILATION ERROR
|
||
/// ```
|
||
///
|
||
/// These 5 lines were removed. The regression risk is severe: if
|
||
/// `self.active_stroke_mask = None` had been left in place alongside the new
|
||
/// `mask.fill(0)` retain logic, the mask buffer would be freed on every
|
||
/// stroke, completely negating the pooling optimization (~8MB alloc/stroke on
|
||
/// 4K). The compilation error (`unexpected closing delimiter`) caught this
|
||
/// before it could ship, but any future refactoring of `end_stroke()` must
|
||
/// ensure the mask is **zeroed and retained**, not dropped.
|
||
pub struct Engine {
|
||
document: Document,
|
||
text_renderer: Mutex<TextRenderer>,
|
||
current_color: [u8; 4],
|
||
current_tip: BrushTip,
|
||
sketch_history: Vec<(f32, f32)>,
|
||
last_stroke_pos: Option<(f32, f32, f32)>,
|
||
pen_hue: f32,
|
||
cyclic_color: bool,
|
||
cyclic_speed: f32,
|
||
/// Pre-stroke snapshot for undo history: (layer_id, pixel_buffer, vector_shapes).
|
||
///
|
||
/// The pixel buffer is now **pooled** in `stroke_before_buf` to avoid a
|
||
/// ~33MB allocation per stroke on 4K canvases. Only the layer_id and
|
||
/// vector shapes are stored here; the pixel data lives in the pooled buffer.
|
||
stroke_before: Option<(u64, Option<Vec<VectorShape>>)>,
|
||
/// Pooled buffer for the "before" snapshot pixel data.
|
||
///
|
||
/// Retained across strokes and reused if size matches. At `begin_stroke()`,
|
||
/// `layer.pixels` is copied into this buffer (no allocation after first use).
|
||
/// At `end_stroke()`, the buffer is moved into the background thread for
|
||
/// sub-rect extraction, then returned via the pending_history channel.
|
||
stroke_before_buf: Option<Vec<u8>>,
|
||
tile_layers: Vec<Option<TiledLayer>>,
|
||
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
||
filter_preview_original: Option<Vec<u8>>,
|
||
stroke_effects_backup: Option<Vec<hcie_protocol::effects::LayerEffect>>,
|
||
stroke_styles_backup: Option<Vec<hcie_protocol::LayerStyle>>,
|
||
pub is_eraser: bool,
|
||
/// Pooled stroke mask buffer. Zeroed on `begin_stroke()` and `end_stroke()`
|
||
/// but **never dropped** between strokes. Reused if size matches, reallocated
|
||
/// only when canvas dimensions change. Avoids ~8MB alloc/stroke on 4K.
|
||
pub active_stroke_mask: Option<Vec<u8>>,
|
||
/// Pooled composite scratch buffer. Allocated once and reused across
|
||
/// `render_composite_region` calls. Avoids ~33MB alloc/free per stroke_to
|
||
/// on 4K canvases. The pixel-bridge extracts dirty rect from this buffer
|
||
/// without allocating its own buffer.
|
||
composite_scratch: Option<Vec<u8>>,
|
||
/// Accumulated stroke extent `[x0, y0, x1, y1]` for sub-rect snapshot.
|
||
/// Tracks the union of all brush areas painted during the current stroke.
|
||
/// Independent from `document.dirty_bounds` — survives composite emits.
|
||
last_stroke_bounds: Option<[u32; 4]>,
|
||
/// Cached composite of all layers BELOW the active drawing layer.
|
||
///
|
||
/// Built once on `begin_stroke()` and consumed during the stroke by
|
||
/// `render_composite_region()`. Instead of re-compositing N layers per
|
||
/// pointer event, we copy the cached below-layer result and composite
|
||
/// only the active layer + layers above it.
|
||
///
|
||
/// For a 10-layer 4K document drawing on the top layer, this reduces
|
||
/// per-frame composite cost by ~90% (1 layer instead of 10).
|
||
///
|
||
/// Cleared on `end_stroke()` to release ~33MB memory.
|
||
below_cache: Option<Vec<u8>>,
|
||
/// The `document.active_layer` index when `below_cache` was built.
|
||
/// Used to validate cache: if the user switches layers mid-stroke
|
||
/// (shouldn't happen, but defensive), we fall back to full composite.
|
||
below_cache_active_idx: Option<usize>,
|
||
/// Per-layer raw pixel backup used by the layer styles pipeline.
|
||
///
|
||
/// When `update_layer_style` is called for a layer for the first time,
|
||
/// the **original, unprocessed** pixel buffer is snapshotted here (keyed
|
||
/// by layer id). Before every effects re-application in
|
||
/// `render_composite_region` / `get_composite_pixels`, the raw pixels are
|
||
/// restored from this backup so that multiple style edits always apply on
|
||
/// top of the untouched source pixels, not on top of previous renders.
|
||
///
|
||
/// The entry is removed when all styles are cleared from the layer or
|
||
/// when the layer itself is deleted.
|
||
raw_pixel_backup: std::collections::HashMap<u64, Vec<u8>>,
|
||
/// Tracks whether the `below_cache` snapshot of layers below the active
|
||
/// layer is stale. Set to `true` whenever a layer property that can
|
||
/// affect the below-layer composite changes (opacity, visibility, blend,
|
||
/// order, parent, clipping mask, deletion, undo/redo). Cleared when the
|
||
/// cache is rebuilt in `begin_stroke()` or explicitly invalidated at the
|
||
/// end of a stroke.
|
||
///
|
||
/// This avoids rebuilding the ~33MB below-layer composite on every
|
||
/// successive stroke when the user paints repeatedly on the same layer
|
||
/// and the lower layers have not changed.
|
||
below_cache_dirty: bool,
|
||
/// Cached selection mask snapshot taken at `begin_stroke()`.
|
||
///
|
||
/// The selection mask is ~8MB on a 4K canvas. Previously it was cloned on
|
||
/// every `stroke_to()` call (hundreds of times per stroke), causing ~8MB
|
||
/// alloc+copy per pointer event. This cache clones it once at stroke start
|
||
/// and all stroke functions reference it instead.
|
||
cached_selection_mask: Option<Vec<u8>>,
|
||
/// Thread-safe queue of history items computed on background threads.
|
||
/// Polled and committed on the UI thread via `commit_pending_history()`.
|
||
pub pending_history: std::sync::Arc<std::sync::Mutex<Vec<crate::stroke_cache::PendingHistoryItem>>>,
|
||
}
|
||
|
||
impl Engine {
|
||
/// Create a new engine with a default transparent document.
|
||
///
|
||
/// **Purpose:** Provides the simplest public constructor for an engine
|
||
/// backed by an `Untitled` canvas of the requested dimensions.
|
||
///
|
||
/// **Logic & Workflow:** Delegates to [`Engine::new_with_options`] with
|
||
/// the default document name `"Untitled"` and a transparent background.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `w`: Document width in pixels.
|
||
/// - `h`: Document height in pixels.
|
||
///
|
||
/// **Returns:** A freshly initialised `Engine` ready for commands.
|
||
///
|
||
/// **Side Effects:** None beyond allocation.
|
||
pub fn new(w: u32, h: u32) -> Self {
|
||
Self::new_with_options("Untitled", w, h, true)
|
||
}
|
||
|
||
/// Create a new engine with explicit document options.
|
||
///
|
||
/// **Purpose:** Allows callers to specify the document name, dimensions,
|
||
/// and whether the initial layer should be transparent or opaque white.
|
||
/// This avoids post-creation fills that would otherwise create unwanted
|
||
/// history steps or modified flags.
|
||
///
|
||
/// **Logic & Workflow:**
|
||
/// 1. Build a `Document` via `Document::new_blank(name, w, h, transparent)`.
|
||
/// 2. Initialise all engine caches and pooled buffers to empty so they are
|
||
/// lazily allocated on first use.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `name`: Display name for the document / first layer.
|
||
/// - `w`: Document width in pixels.
|
||
/// - `h`: Document height in pixels.
|
||
/// - `transparent`: When `true` the base layer is transparent; when `false`
|
||
/// it is filled with opaque white.
|
||
///
|
||
/// **Returns:** A freshly initialised `Engine` ready for commands.
|
||
///
|
||
/// **Side Effects:** None beyond allocation.
|
||
pub fn new_with_options(name: &str, w: u32, h: u32, transparent: bool) -> Self {
|
||
Self {
|
||
document: Document::new_blank(name, w, h, transparent),
|
||
text_renderer: Mutex::new(TextRenderer::new()),
|
||
current_color: [0, 0, 0, 255],
|
||
current_tip: BrushTip::default(),
|
||
sketch_history: Vec::new(),
|
||
last_stroke_pos: None,
|
||
pen_hue: 0.0,
|
||
cyclic_color: false,
|
||
cyclic_speed: 0.5,
|
||
stroke_before: None,
|
||
stroke_before_buf: None,
|
||
tile_layers: Vec::new(),
|
||
svg_sources: std::collections::HashMap::new(),
|
||
filter_preview_original: None,
|
||
stroke_effects_backup: None,
|
||
stroke_styles_backup: None,
|
||
is_eraser: false,
|
||
active_stroke_mask: None,
|
||
composite_scratch: None,
|
||
last_stroke_bounds: None,
|
||
below_cache: None,
|
||
below_cache_active_idx: None,
|
||
raw_pixel_backup: std::collections::HashMap::new(),
|
||
below_cache_dirty: true,
|
||
cached_selection_mask: None,
|
||
pending_history: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
|
||
}
|
||
}
|
||
|
||
/// Replace the current document with a new blank canvas of the given size.
|
||
pub fn new_document(&mut self, w: u32, h: u32) {
|
||
*self = Self::new(w, h);
|
||
}
|
||
|
||
/// Replace the current document with a new canvas using explicit options.
|
||
///
|
||
/// **Purpose:** Provides a way to recreate the engine document with a
|
||
/// custom name, dimensions, and transparent/opaque background choice.
|
||
///
|
||
/// **Logic & Workflow:** Replaces the entire engine state by assigning
|
||
/// `Self::new_with_options(...)`. This resets strokes, caches, pooled
|
||
/// buffers, and history in a single move.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `name`: Display name for the new document / first layer.
|
||
/// - `w`: Document width in pixels.
|
||
/// - `h`: Document height in pixels.
|
||
/// - `transparent`: When `true` the base layer is transparent; when `false`
|
||
/// it is filled with opaque white.
|
||
///
|
||
/// **Returns:** Nothing.
|
||
///
|
||
/// **Side Effects:** Overwrites the engine state; all existing layers,
|
||
/// history, and caches are discarded.
|
||
pub fn new_document_with_options(&mut self, name: &str, w: u32, h: u32, transparent: bool) {
|
||
*self = Self::new_with_options(name, w, h, transparent);
|
||
}
|
||
|
||
/// Set the current drawing tool.
|
||
pub fn set_tool(&mut self, tool: Tool) {
|
||
// Tool selection does not change engine state directly; it is a UI hint
|
||
// that the frontend uses to route pointer events to the correct command.
|
||
log::info!("[engine] active tool changed to {:?}", tool);
|
||
}
|
||
|
||
|
||
pub fn create_selection_rect(&mut self, x1: u32, y1: u32, x2: u32, y2: u32) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
self.document.selection_mask = Some(hcie_selection::create_rect_mask(w, h, x1, y1, x2, y2));
|
||
self.document.selection_active = true;
|
||
}
|
||
|
||
pub fn create_selection_ellipse(&mut self, cx: u32, cy: u32, rx: u32, ry: u32) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
self.document.selection_mask = Some(hcie_selection::create_ellipse_mask(w, h, cx, cy, rx, ry));
|
||
self.document.selection_active = true;
|
||
}
|
||
|
||
pub fn selection_grow(&mut self, px: u32) {
|
||
if let Some(ref mut mask) = self.document.selection_mask {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
hcie_selection::grow_mask(mask, w, h, px);
|
||
}
|
||
}
|
||
|
||
pub fn selection_shrink(&mut self, px: u32) {
|
||
if let Some(ref mut mask) = self.document.selection_mask {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
hcie_selection::shrink_mask(mask, w, h, px);
|
||
}
|
||
}
|
||
|
||
pub fn selection_feather(&mut self, radius: f32) {
|
||
if let Some(ref mut mask) = self.document.selection_mask {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
hcie_selection::feather_mask(mask, w, h, radius);
|
||
}
|
||
}
|
||
|
||
pub fn selection_invert(&mut self) {
|
||
if let Some(ref mut mask) = self.document.selection_mask {
|
||
hcie_selection::invert_mask(mask);
|
||
} else {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
let mut mask = vec![255u8; (w * h) as usize];
|
||
hcie_selection::invert_mask(&mut mask);
|
||
self.document.selection_mask = Some(mask);
|
||
self.document.selection_active = true;
|
||
}
|
||
}
|
||
|
||
pub fn selection_clear(&mut self) {
|
||
self.document.selection_mask = None;
|
||
self.document.selection_active = false;
|
||
}
|
||
|
||
pub fn selection_all(&mut self) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
self.document.selection_mask = Some(vec![255u8; (w * h) as usize]);
|
||
self.document.selection_active = true;
|
||
}
|
||
|
||
pub fn get_selection_mask(&self) -> Option<&[u8]> {
|
||
self.document.selection_mask.as_deref()
|
||
}
|
||
|
||
pub fn set_selection_mask(&mut self, mask: Vec<u8>) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
if mask.len() == (w * h) as usize {
|
||
self.document.selection_mask = Some(mask);
|
||
self.document.selection_active = true;
|
||
}
|
||
}
|
||
|
||
/// Create a freehand lasso selection from the given point sequence.
|
||
///
|
||
/// **Purpose:** Fills the canvas selection mask with all pixels inside the
|
||
/// polygon formed by `points` using ray-casting (even-odd rule).
|
||
///
|
||
/// **Arguments:**
|
||
/// - `points`: canvas-coordinate vertices collected during the drag.
|
||
pub fn create_selection_lasso(&mut self, points: &[(u32, u32)]) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
let mut mask = vec![0u8; (w * h) as usize];
|
||
if points.len() >= 3 {
|
||
hcie_selection::lasso_fill_mask(&mut mask, w, h, points);
|
||
}
|
||
self.document.selection_mask = Some(mask);
|
||
self.document.selection_active = true;
|
||
log::trace!("create_selection_lasso: {} points, mask size {}x{}", points.len(), w, h);
|
||
}
|
||
|
||
/// Create a polygon selection from explicitly clicked vertices.
|
||
///
|
||
/// **Purpose:** Same ray-casting algorithm as lasso, but the vertex list is
|
||
/// built by discrete clicks (PolygonSelect tool) rather than continuous drag.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `points`: canvas-coordinate polygon vertices.
|
||
pub fn create_selection_polygon(&mut self, points: &[(u32, u32)]) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
let mut mask = vec![0u8; (w * h) as usize];
|
||
if points.len() >= 3 {
|
||
hcie_selection::lasso_fill_mask(&mut mask, w, h, points);
|
||
}
|
||
self.document.selection_mask = Some(mask);
|
||
self.document.selection_active = true;
|
||
log::trace!("create_selection_polygon: {} vertices, mask size {}x{}", points.len(), w, h);
|
||
}
|
||
|
||
/// Magic Wand selection — selects contiguous pixels similar in colour to the seed point.
|
||
///
|
||
/// **Purpose:** BFS flood-fill on the active layer's pixel data. Pixels whose
|
||
/// RGBA delta from the seed is ≤ `tolerance` on every channel are selected.
|
||
///
|
||
/// **Logic & Workflow:**
|
||
/// 1. Fetch the active layer's pixel buffer.
|
||
/// 2. Delegate to `hcie_selection::magic_wand_mask` for the BFS computation.
|
||
/// 3. Store the resulting mask and set `selection_active = true`.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `seed_x`, `seed_y`: clicked canvas coordinates.
|
||
/// - `tolerance`: 0–255 channel delta threshold.
|
||
pub fn create_selection_magic_wand(&mut self, seed_x: u32, seed_y: u32, tolerance: u8) {
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
let pixels = if let Some(layer) = self.document.active_layer() {
|
||
layer.pixels.clone()
|
||
} else {
|
||
log::warn!("create_selection_magic_wand: no active layer");
|
||
return;
|
||
};
|
||
log::trace!(
|
||
"create_selection_magic_wand: seed=({},{}) tol={} canvas={}x{}",
|
||
seed_x, seed_y, tolerance, w, h
|
||
);
|
||
let mask = hcie_selection::magic_wand_mask(&pixels, w, h, seed_x, seed_y, tolerance);
|
||
self.document.selection_mask = Some(mask);
|
||
self.document.selection_active = true;
|
||
}
|
||
|
||
/// Returns whether a selection mask is currently active.
|
||
pub fn is_selection_active(&self) -> bool {
|
||
self.document.selection_active
|
||
}
|
||
|
||
// ── Transform Ops ────────────────────────────────────────────────────
|
||
|
||
pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
||
self.document.crop(x, y, w, h);
|
||
}
|
||
|
||
pub fn resize_canvas(&mut self, w: u32, h: u32) {
|
||
self.document.resize_canvas(w, h);
|
||
self.pre_tile_all_layers();
|
||
}
|
||
|
||
pub fn rotate_layer(&mut self, id: u64, degrees: f32) {
|
||
let _ = (id, degrees);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
|
||
pub fn flip_layer_horizontal(&mut self, id: u64) {
|
||
let _ = id;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
|
||
pub fn flip_layer_vertical(&mut self, id: u64) {
|
||
let _ = id;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
|
||
// ── Filter Ops ───────────────────────────────────────────────────────
|
||
|
||
pub fn apply_filter(&mut self, filter_id: &str, params: serde_json::Value) {
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let before_pixels = layer.pixels.clone();
|
||
let _ = apply_filter(filter_id, ¶ms, &mut layer.pixels, layer.width, layer.height);
|
||
layer.dirty = true;
|
||
self.document.push_draw_snapshot(
|
||
self.document.active_layer,
|
||
before_pixels,
|
||
None,
|
||
format!("Filter: {}", filter_id),
|
||
);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
pub fn apply_filter_preview(&mut self, filter_id: &str, params: serde_json::Value) {
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
let _ = apply_filter(filter_id, ¶ms, &mut layer.pixels, layer.width, layer.height);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
}
|
||
}
|
||
|
||
pub fn filter_preview_begin(&mut self) {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
self.filter_preview_original = Some(layer.pixels.clone());
|
||
}
|
||
}
|
||
|
||
pub fn filter_preview_restore(&mut self) {
|
||
if let Some(ref original) = self.filter_preview_original.take() {
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
layer.pixels.clone_from(original);
|
||
layer.dirty = true;
|
||
}
|
||
self.document.composite_dirty = true;
|
||
}
|
||
}
|
||
|
||
pub fn has_filter_preview(&self) -> bool {
|
||
self.filter_preview_original.is_some()
|
||
}
|
||
|
||
pub fn reset_tool(&mut self) {
|
||
// Reset brush tip and color to factory defaults
|
||
self.current_tip = BrushTip::default();
|
||
self.current_color = [0, 0, 0, 255];
|
||
self.pen_hue = 0.0;
|
||
self.cyclic_color = false;
|
||
self.cyclic_speed = 0.5;
|
||
self.sketch_history.clear();
|
||
self.last_stroke_pos = None;
|
||
log::info!("Tool state reset to defaults");
|
||
}
|
||
|
||
/// Align a layer to a canvas edge or center axis.
|
||
/// Only affects raster layers (shifts pixel data).
|
||
pub fn align_layer(&mut self, id: u64, axis: AlignAxis) {
|
||
let Some(layer) = self.document.get_layer_by_id(id) else { return };
|
||
if layer.layer_type != LayerType::Raster {
|
||
return;
|
||
}
|
||
let (lw, lh, cw, ch) = (layer.width as i32, layer.height as i32,
|
||
self.document.canvas_width as i32,
|
||
self.document.canvas_height as i32);
|
||
let (target_x, target_y) = match axis {
|
||
AlignAxis::Left => (0i32, (ch - lh) / 2),
|
||
AlignAxis::Right => (cw - lw, (ch - lh) / 2),
|
||
AlignAxis::Top => ((cw - lw) / 2, 0i32),
|
||
AlignAxis::Bottom => ((cw - lw) / 2, ch - lh),
|
||
AlignAxis::HCenter => ((cw - lw) / 2, (ch - lh) / 2),
|
||
AlignAxis::VCenter => ((cw - lw) / 2, (ch - lh) / 2),
|
||
};
|
||
// Clamp so we never start from negative
|
||
let target_x = target_x.max(0);
|
||
let target_y = target_y.max(0);
|
||
|
||
// Current layer offset isn't tracked separately in V3 (always starts at 0,0).
|
||
// We treat the layer as already at (0,0) and only shift if target != 0.
|
||
// If target_x or target_y is > 0, we need to shift existing pixels.
|
||
// For simplicity: build a new transparent canvas-sized buffer at target position.
|
||
let cw_u = self.document.canvas_width;
|
||
let ch_u = self.document.canvas_height;
|
||
let mut new_pixels = vec![0u8; (cw_u * ch_u * 4) as usize];
|
||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||
// Copy existing pixels into new buffer at target offset
|
||
for ly in 0..lh.min(ch_u as i32) {
|
||
for lx in 0..lw.min(cw_u as i32) {
|
||
let src_i = ((ly as u32) * l.width + (lx as u32)) as usize * 4;
|
||
let dst_x = (target_x + lx) as u32;
|
||
let dst_y = (target_y + ly) as u32;
|
||
if dst_x < cw_u && dst_y < ch_u {
|
||
let dst_i = ((dst_y * cw_u + dst_x) as usize) * 4;
|
||
new_pixels[dst_i..dst_i + 4].copy_from_slice(
|
||
&l.pixels[src_i..src_i + 4],
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Update layer dimensions & pixels
|
||
l.width = cw_u;
|
||
l.height = ch_u;
|
||
l.pixels = new_pixels;
|
||
l.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
pub fn align_active_layer(&mut self, axis: AlignAxis) {
|
||
let id = self.document.active_layer().map(|l| l.id).unwrap_or(0);
|
||
if id > 0 {
|
||
self.align_layer(id, axis);
|
||
}
|
||
}
|
||
|
||
// ── Text Ops ─────────────────────────────────────────────────────────
|
||
|
||
pub fn add_text(
|
||
&mut self,
|
||
text: &str,
|
||
font: &str,
|
||
size: f32,
|
||
x: f32,
|
||
y: f32,
|
||
color: [u8; 4],
|
||
angle: f32,
|
||
alignment: TextAlignment,
|
||
orientation: TextOrientation,
|
||
effects: &[TextEffect],
|
||
) -> u64 {
|
||
let mut renderer = self.text_renderer.lock().unwrap();
|
||
match renderer.create_text_layer(text, font, size, color, x, y, angle, alignment, orientation, effects) {
|
||
Ok(mut layer) => {
|
||
layer.layer_type = LayerType::Text;
|
||
layer.id = rand::random::<u64>();
|
||
let id = layer.id;
|
||
self.document.layers.push(layer);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
id
|
||
}
|
||
Err(e) => {
|
||
log::error!("Text creation failed: {}", e);
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Update every editable field of an existing text layer and re-rasterize it.
|
||
/// Passing `None` for a field leaves the current value unchanged.
|
||
pub fn update_text_layer(
|
||
&mut self,
|
||
id: u64,
|
||
new_text: Option<&str>,
|
||
new_font: Option<&str>,
|
||
new_size: Option<f32>,
|
||
new_color: Option<[u8; 4]>,
|
||
new_x: Option<f32>,
|
||
new_y: Option<f32>,
|
||
new_angle: Option<f32>,
|
||
new_alignment: Option<TextAlignment>,
|
||
new_orientation: Option<TextOrientation>,
|
||
new_effects: Option<&[TextEffect]>,
|
||
) {
|
||
let idx = self.document.layer_index_by_id(id);
|
||
if idx.is_none() { return; }
|
||
let idx = idx.unwrap();
|
||
let mut needs_refresh = false;
|
||
if let Some(layer) = self.document.layers.get_mut(idx) {
|
||
if let LayerData::Text { ref mut text, ref mut font, ref mut size, ref mut color, ref mut x, ref mut y, ref mut angle, ref mut alignment, ref mut orientation, ref mut effects, .. } = layer.data {
|
||
if let Some(v) = new_text { *text = v.to_string(); needs_refresh = true; }
|
||
if let Some(v) = new_font { *font = v.to_string(); needs_refresh = true; }
|
||
if let Some(v) = new_size { *size = v; needs_refresh = true; }
|
||
if let Some(v) = new_color { *color = v; needs_refresh = true; }
|
||
if let Some(v) = new_x { *x = v; needs_refresh = true; }
|
||
if let Some(v) = new_y { *y = v; needs_refresh = true; }
|
||
if let Some(v) = new_angle { *angle = v; needs_refresh = true; }
|
||
if let Some(v) = new_alignment { *alignment = v; needs_refresh = true; }
|
||
if let Some(v) = new_orientation { *orientation = v; needs_refresh = true; }
|
||
if let Some(v) = new_effects { *effects = v.to_vec(); needs_refresh = true; }
|
||
}
|
||
if needs_refresh {
|
||
let mut renderer = self.text_renderer.lock().unwrap();
|
||
if let Err(e) = renderer.refresh_text_layer(layer) {
|
||
log::error!("Failed to refresh text layer {}: {}", id, e);
|
||
}
|
||
}
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
pub fn set_text_layer_text(&mut self, id: u64, text: String) {
|
||
self.update_text_layer(id, Some(&text), None, None, None, None, None, None, None, None, None);
|
||
}
|
||
pub fn set_text_layer_font(&mut self, id: u64, font: String) {
|
||
self.update_text_layer(id, None, Some(&font), None, None, None, None, None, None, None, None);
|
||
}
|
||
pub fn set_text_layer_size(&mut self, id: u64, size: f32) {
|
||
self.update_text_layer(id, None, None, Some(size), None, None, None, None, None, None, None);
|
||
}
|
||
pub fn set_text_layer_color(&mut self, id: u64, color: [u8; 4]) {
|
||
self.update_text_layer(id, None, None, None, Some(color), None, None, None, None, None, None);
|
||
}
|
||
pub fn set_text_layer_position(&mut self, id: u64, x: f32, y: f32) {
|
||
self.update_text_layer(id, None, None, None, None, Some(x), Some(y), None, None, None, None);
|
||
}
|
||
pub fn set_text_layer_angle(&mut self, id: u64, angle: f32) {
|
||
self.update_text_layer(id, None, None, None, None, None, None, Some(angle), None, None, None);
|
||
}
|
||
pub fn set_text_layer_alignment(&mut self, id: u64, alignment: TextAlignment) {
|
||
self.update_text_layer(id, None, None, None, None, None, None, None, Some(alignment), None, None);
|
||
}
|
||
pub fn set_text_layer_orientation(&mut self, id: u64, orientation: TextOrientation) {
|
||
self.update_text_layer(id, None, None, None, None, None, None, None, None, Some(orientation), None);
|
||
}
|
||
pub fn set_text_layer_effects(&mut self, id: u64, effects: Vec<TextEffect>) {
|
||
self.update_text_layer(id, None, None, None, None, None, None, None, None, None, Some(&effects));
|
||
}
|
||
|
||
/// Retrieve the layout offsets and unrotated dimensions of a text layer.
|
||
pub fn get_text_layer_properties(&self, id: u64) -> Option<(f32, f32, f32, f32)> {
|
||
let idx = self.document.layers.iter().position(|l| l.id == id)?;
|
||
let layer = &self.document.layers[idx];
|
||
if let LayerData::Text { offset_x, offset_y, unrotated_w, unrotated_h, .. } = &layer.data {
|
||
Some((*offset_x, *offset_y, *unrotated_w, *unrotated_h))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
/// Register a raw font with the engine's text renderer under the given name.
|
||
///
|
||
/// The font is then available for text layers via `add_text` / `update_text_layer`.
|
||
/// Returns an error string if the font data cannot be parsed by fontdue.
|
||
pub fn load_font(&mut self, name: &str, data: &[u8]) -> Result<(), String> {
|
||
let mut renderer = self.text_renderer.lock().unwrap();
|
||
renderer.load_font(name, data)
|
||
}
|
||
|
||
/// Returns true if the engine has loaded a font with the given name.
|
||
pub fn has_font(&self, name: &str) -> bool {
|
||
let renderer = self.text_renderer.lock().unwrap();
|
||
renderer.has_font(name)
|
||
}
|
||
|
||
// ── Vector Ops ───────────────────────────────────────────────────────
|
||
|
||
pub fn add_vector_shape(&mut self, shape: VectorShape) {
|
||
// If active layer is already Vector, add shape there
|
||
let layer_idx = self.document.active_layer;
|
||
if layer_idx < self.document.layers.len() {
|
||
if let LayerData::Vector { .. } = &self.document.layers[layer_idx].data {
|
||
// Already a vector layer — add shape and let get_composite_pixels re-render
|
||
let before_shapes = if let LayerData::Vector { shapes } = &self.document.layers[layer_idx].data {
|
||
Some(shapes.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
shapes.push(shape);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
self.document.push_vector_snapshot(layer_idx, before_shapes, "Add Vector Shape".to_string());
|
||
return;
|
||
}
|
||
}
|
||
// Active layer is NOT vector — create a NEW vector layer (matching V2 behavior)
|
||
let new_id = self.document.add_layer(&format!("Vector {}", self.document.layers.len()));
|
||
if let Some(idx) = self.document.layer_index_by_id(new_id) {
|
||
// Set as vector layer
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(new_id) {
|
||
layer.data = LayerData::Vector { shapes: vec![shape] };
|
||
layer.layer_type = LayerType::Vector;
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
self.document.set_active_layer(idx);
|
||
}
|
||
}
|
||
|
||
pub fn get_svg_source(&self, layer_id: u64) -> Option<&[u8]> {
|
||
self.svg_sources.get(&layer_id).map(|v| v.as_slice())
|
||
}
|
||
|
||
pub fn set_layer_pixels(&mut self, layer_id: u64, pixels: Vec<u8>) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
layer.pixels = pixels;
|
||
layer.dirty = false;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
||
if idx < self.tile_layers.len() {
|
||
let layer = &self.document.layers[idx];
|
||
self.tile_layers[idx] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn modify_vector_shape(&mut self, layer_id: u64, shape_idx: usize, handle: VectorEditHandle, dx: f32, dy: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.apply_handle(handle, dx, dy, None);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_color(color);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_fill(&mut self, layer_id: u64, shape_idx: usize, fill: bool) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
if let Some(f) = shape.get_fill_mut() {
|
||
*f = fill;
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_fill_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
if let Some(c) = shape.get_fill_color_mut() {
|
||
*c = color;
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_stroke(&mut self, layer_id: u64, shape_idx: usize, stroke: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_stroke(stroke);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_opacity(&mut self, layer_id: u64, shape_idx: usize, opacity: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_shape_opacity(opacity);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_bounds(&mut self, layer_id: u64, shape_idx: usize, x1: f32, y1: f32, x2: f32, y2: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_bounds(x1, y1, x2, y2);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_hardness(&mut self, layer_id: u64, shape_idx: usize, hardness: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_hardness(hardness);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_line_caps(&mut self, layer_id: u64, shape_idx: usize, cap_start: LineCap, cap_end: LineCap) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
if let VectorShape::Line { cap_start: cs, cap_end: ce, .. } = shape {
|
||
*cs = cap_start;
|
||
*ce = cap_end;
|
||
}
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn active_vector_shapes(&self) -> Option<Vec<VectorShape>> {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
if let LayerData::Vector { shapes } = &layer.data {
|
||
return Some(shapes.clone());
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Find the index of the first vector shape that contains point (px, py).
|
||
/// Returns None if no shape is hit.
|
||
pub fn find_vector_shape_at(&self, px: f32, py: f32) -> Option<usize> {
|
||
if let Some(shapes) = self.active_vector_shapes() {
|
||
// Iterate from the last shape to the first so that shapes drawn on
|
||
// top (added later, rendered last) are selected first when two
|
||
// shapes overlap. Forward iteration always picks the bottom-most
|
||
// shape and makes it impossible to select overlapping shapes drawn
|
||
// above it.
|
||
for (i, shape) in shapes.iter().enumerate().rev() {
|
||
let (left, top, right, bottom) = shape.normalized_bounds();
|
||
if px >= left && px <= right && py >= top && py <= bottom {
|
||
return Some(i);
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub fn get_vector_shape_center(&self, layer_id: u64, shape_idx: usize) -> Option<(f32, f32)> {
|
||
let layer = self.document.get_layer_by_id(layer_id)?;
|
||
if let LayerData::Vector { shapes } = &layer.data {
|
||
if let Some(shape) = shapes.get(shape_idx) {
|
||
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
||
return Some(((x1 + x2) / 2.0, (y1 + y2) / 2.0));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub fn vector_shape_angle(&self, layer_id: u64, shape_idx: usize) -> f32 {
|
||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||
if let LayerData::Vector { shapes } = &layer.data {
|
||
if let Some(shape) = shapes.get(shape_idx) {
|
||
return shape.angle();
|
||
}
|
||
}
|
||
}
|
||
0.0
|
||
}
|
||
|
||
pub fn set_vector_shape_angle(&mut self, layer_id: u64, shape_idx: usize, angle: f32) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if let Some(shape) = shapes.get_mut(shape_idx) {
|
||
shape.set_angle(angle);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn mark_composite_dirty(&mut self) {
|
||
self.document.composite_dirty = true;
|
||
}
|
||
|
||
/// Get mutable reference to shapes on a vector layer for direct manipulation.
|
||
pub fn get_layer_shapes_direct(&mut self, layer_id: u64) -> Option<&mut Vec<VectorShape>> {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
return Some(shapes);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if shape_idx < shapes.len() {
|
||
shapes.remove(shape_idx);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn boolean_vector_shapes(&mut self, layer_id: u64, op: BooleanOp, idx_a: usize, idx_b: usize) -> bool {
|
||
let layer_idx = match self.document.layer_index_by_id(layer_id) {
|
||
Some(i) => i,
|
||
None => return false,
|
||
};
|
||
let before_shapes = if let Some(layer) = self.document.layers.get(layer_idx) {
|
||
if let LayerData::Vector { shapes } = &layer.data {
|
||
Some(shapes.clone())
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.layers.get_mut(layer_idx) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if idx_a >= shapes.len() || idx_b >= shapes.len() || idx_a == idx_b {
|
||
return false;
|
||
}
|
||
let shape_a = shapes[idx_a].clone();
|
||
let shape_b = shapes[idx_b].clone();
|
||
if let Some(result) = boolean_shapes(op, &shape_a, &shape_b) {
|
||
let max = std::cmp::max(idx_a, idx_b);
|
||
let min = std::cmp::min(idx_a, idx_b);
|
||
shapes.remove(max);
|
||
shapes.remove(min);
|
||
shapes.push(result);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
let op_name = match op {
|
||
BooleanOp::Union => "Union",
|
||
BooleanOp::Intersect => "Intersect",
|
||
BooleanOp::Subtract => "Subtract",
|
||
BooleanOp::Xor => "Xor",
|
||
};
|
||
self.document.push_vector_snapshot(layer_idx, before_shapes, format!("Path Boolean ({})", op_name));
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
false
|
||
}
|
||
|
||
// ── File I/O ─────────────────────────────────────────────────────────
|
||
|
||
/// Çizim başlangıcındaki kiremit dönüşüm lag'ini önlemek için tüm katmanları önceden kiremitler.
|
||
pub fn pre_tile_all_layers(&mut self) {
|
||
let count = self.document.layers.len();
|
||
self.tile_layers.resize_with(count, || None);
|
||
let mut built = 0usize;
|
||
for (i, layer) in self.document.layers.iter().enumerate() {
|
||
if !layer.pixels.is_empty() && self.tile_layers[i].is_none() {
|
||
self.tile_layers[i] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||
built += 1;
|
||
}
|
||
}
|
||
log::trace!(
|
||
"[pre_tile_all_layers] built {} new tiles / {} total layers, tile_layers_len={}",
|
||
built, count, self.tile_layers.len()
|
||
);
|
||
|
||
// ── BUFFER & THREADPOOL WARMING ──────────────────────────────
|
||
// 4K çözünürlükteki ilk fırça darbesinde bellek tahsisi ve Rayon
|
||
// iş parçacıklarının (threadpool) tembel başlatılmasından doğan
|
||
// gecikmeleri (100-300ms) tamamen ortadan kaldırmak için, bu yükü
|
||
// dosya yükleme (cold path) veya boyut değiştirme aşamasına kaydırıyoruz.
|
||
let cw = self.document.canvas_width;
|
||
let ch = self.document.canvas_height;
|
||
let buf_size = (cw * ch * 4) as usize;
|
||
let mask_size = (cw * ch) as usize;
|
||
|
||
// 1. composite_scratch tamponunu önceden tahsis et (~33.18 MB)
|
||
if self.composite_scratch.as_ref().map_or(true, |b| b.len() != buf_size) {
|
||
self.composite_scratch = Some(vec![0u8; buf_size]);
|
||
}
|
||
|
||
// 2. below_cache tamponunu önceden tahsis et (~33.18 MB)
|
||
if self.below_cache.as_ref().map_or(true, |b| b.len() != buf_size) {
|
||
self.below_cache = Some(vec![0u8; buf_size]);
|
||
}
|
||
|
||
// 3. active_stroke_mask tamponunu önceden tahsis et (~8.3 MB)
|
||
if self.active_stroke_mask.as_ref().map_or(true, |b| b.len() != mask_size) {
|
||
self.active_stroke_mask = Some(vec![0u8; mask_size]);
|
||
}
|
||
|
||
// 4. Rayon global threadpool'unu ısıt (iş parçacıklarını ayaklandır)
|
||
let mut dummy = [0u8; 64];
|
||
use rayon::prelude::*;
|
||
dummy.par_iter_mut().for_each(|x| *x = 1);
|
||
}
|
||
|
||
pub fn open_image(&mut self, path: &str) -> Result<(), String> {
|
||
// Start from a clean slate so stale default layers do not remain and
|
||
// force callers to delete them (which would mark the document dirty).
|
||
self.clear_all_layers();
|
||
|
||
let layer = load_image(Path::new(path))?;
|
||
let w = layer.width;
|
||
let h = layer.height;
|
||
self.document.layers.push(layer);
|
||
self.document.active_layer = self.document.layers.len() - 1;
|
||
self.document.canvas_width = w;
|
||
self.document.canvas_height = h;
|
||
self.document.initialize_history("Open Image");
|
||
self.document.composite_dirty = true;
|
||
// Opening an existing file is not a user edit; keep the document clean.
|
||
self.document.modified = false;
|
||
self.pre_tile_all_layers();
|
||
Ok(())
|
||
}
|
||
|
||
pub fn import_svg(&mut self, svg_data: &[u8]) -> Result<(), String> {
|
||
let layers = import_svg(svg_data)?;
|
||
if layers.is_empty() {
|
||
return Err("SVG produced no layers".to_string());
|
||
}
|
||
let w = layers.first().map(|l| l.width).unwrap_or(800);
|
||
let h = layers.first().map(|l| l.height).unwrap_or(600);
|
||
// Replace the whole document content cleanly; do not leave stale layers
|
||
// or history behind.
|
||
self.clear_all_layers();
|
||
self.document.canvas_width = w;
|
||
self.document.canvas_height = h;
|
||
self.document.layers = layers;
|
||
self.document.active_layer = 0;
|
||
self.document.initialize_history("Import SVG");
|
||
self.document.composite_dirty = true;
|
||
// Treat SVG opened as a loaded file, not an edit.
|
||
self.document.modified = false;
|
||
Ok(())
|
||
}
|
||
|
||
pub fn save_as(&self, path: &str, format: &str) -> Result<(), String> {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
save_image(layer, Path::new(path), format)
|
||
} else {
|
||
Err("No active layer to save".to_string())
|
||
}
|
||
}
|
||
|
||
pub fn save_native(&self, path: &str) -> Result<(), String> {
|
||
save_native(&self.document.layers, Path::new(path))
|
||
}
|
||
|
||
pub fn load_native(&mut self, path: &str) -> Result<(), String> {
|
||
let layers = load_native(Path::new(path))?;
|
||
if let Some(first) = layers.first() {
|
||
self.document.resize_canvas(first.width, first.height);
|
||
}
|
||
// Replace existing content so callers do not need to delete stale
|
||
// default layers afterwards and inadvertently mark the document dirty.
|
||
self.clear_all_layers();
|
||
for l in layers {
|
||
if !l.effects.is_empty() || !l.styles.is_empty() {
|
||
l.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||
// Snapshot raw pixels before any effect rendering touches layer.pixels.
|
||
// Must happen before pre_tile_all_layers and the first render pass.
|
||
if !l.pixels.is_empty() {
|
||
self.raw_pixel_backup.entry(l.id).or_insert_with(|| l.pixels.clone());
|
||
}
|
||
}
|
||
self.document.layers.push(l);
|
||
}
|
||
// Imported data is not a user edit; keep the document clean so the user can decide to save.
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = false;
|
||
self.pre_tile_all_layers();
|
||
Ok(())
|
||
}
|
||
|
||
pub fn export_layer(&self, layer_id: u64, path: &str, format: &str) -> Result<(), String> {
|
||
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
||
save_image(layer, Path::new(path), format)
|
||
} else {
|
||
Err("Layer not found".to_string())
|
||
}
|
||
}
|
||
|
||
pub fn export_psd(&self, path: &str) -> Result<(), String> {
|
||
let composited = composite_layers(&self.document.layers, self.document.canvas_width, self.document.canvas_height);
|
||
export_psd(&self.document.layers, self.document.canvas_width, self.document.canvas_height, &composited, Path::new(path))
|
||
}
|
||
|
||
pub fn export_kra(&self, path: &str) -> Result<(), String> {
|
||
let composited = composite_layers(&self.document.layers, self.document.canvas_width, self.document.canvas_height);
|
||
export_kra(&self.document.layers, self.document.canvas_width, self.document.canvas_height, &composited, Path::new(path))
|
||
}
|
||
|
||
pub fn import_psd(&mut self, path: &str) -> Result<(), String> {
|
||
let layers = import_psd(Path::new(path))?;
|
||
if let Some(first) = layers.first() {
|
||
self.document.resize_canvas(first.width, first.height);
|
||
}
|
||
// Replace existing content so callers do not need to delete stale
|
||
// default layers afterwards and inadvertently mark the document dirty.
|
||
self.clear_all_layers();
|
||
for l in layers {
|
||
if !l.effects.is_empty() || !l.styles.is_empty() {
|
||
l.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||
// Snapshot raw pixels before any effect rendering touches layer.pixels.
|
||
// Must happen before pre_tile_all_layers and the first render pass.
|
||
if !l.pixels.is_empty() {
|
||
self.raw_pixel_backup.entry(l.id).or_insert_with(|| l.pixels.clone());
|
||
}
|
||
}
|
||
self.document.layers.push(l);
|
||
}
|
||
{
|
||
let mut renderer = self.text_renderer.lock().unwrap();
|
||
for l in &mut self.document.layers {
|
||
if l.layer_type == hcie_protocol::LayerType::Text {
|
||
if let Err(e) = renderer.refresh_text_layer(l) {
|
||
log::error!("Failed to refresh imported PSD text layer: {}", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Imported data is not a user edit; keep the document clean so the user can decide to save.
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = false;
|
||
self.pre_tile_all_layers();
|
||
Ok(())
|
||
}
|
||
|
||
/// Purpose: Import Krita (.kra) document into the engine.
|
||
/// Logic: Loads layers through `hcie_kra::import_kra`. For vector layers, extracts raw SVG bytes
|
||
/// stored temporarily in `l.adjustment_raw` and registers them into `self.svg_sources` mapping,
|
||
/// then resets the temporary field. Flags layer effects dirty if styles are present.
|
||
/// Arguments: path - Path to the KRA file on disk.
|
||
/// Returns: Ok(()) or an error string.
|
||
pub fn import_kra(&mut self, path: &str) -> Result<(), String> {
|
||
let layers = import_kra(Path::new(path))?;
|
||
if let Some(first) = layers.first() {
|
||
self.document.resize_canvas(first.width, first.height);
|
||
}
|
||
// Replace existing content so callers do not need to delete stale
|
||
// default layers afterwards and inadvertently mark the document dirty.
|
||
self.clear_all_layers();
|
||
for mut l in layers {
|
||
if l.layer_type == hcie_protocol::LayerType::Vector {
|
||
if let Some((tag, ref svg_bytes)) = l.adjustment_raw {
|
||
if tag == *b"svg " {
|
||
self.svg_sources.insert(l.id, svg_bytes.clone());
|
||
}
|
||
}
|
||
l.adjustment_raw = None;
|
||
}
|
||
if !l.effects.is_empty() || !l.styles.is_empty() {
|
||
l.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||
// Snapshot raw pixels before any effect rendering touches layer.pixels.
|
||
// Must happen before pre_tile_all_layers and the first render pass.
|
||
if !l.pixels.is_empty() {
|
||
self.raw_pixel_backup.entry(l.id).or_insert_with(|| l.pixels.clone());
|
||
}
|
||
}
|
||
self.document.layers.push(l);
|
||
}
|
||
{
|
||
let mut renderer = self.text_renderer.lock().unwrap();
|
||
for l in &mut self.document.layers {
|
||
if l.layer_type == hcie_protocol::LayerType::Text {
|
||
if let Err(e) = renderer.refresh_text_layer(l) {
|
||
log::error!("Failed to refresh imported Krita text layer: {}", e);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Imported data is not a user edit; keep the document clean so the user can decide to save.
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = false;
|
||
self.pre_tile_all_layers();
|
||
Ok(())
|
||
}
|
||
|
||
|
||
// ── View Ops ─────────────────────────────────────────────────────────
|
||
|
||
pub fn set_zoom(&mut self, zoom: f32) {
|
||
self.document.zoom = zoom.clamp(0.01, 64.0);
|
||
}
|
||
|
||
pub fn get_zoom(&self) -> f32 {
|
||
self.document.zoom
|
||
}
|
||
|
||
pub fn pan_canvas(&mut self, dx: f32, dy: f32) {
|
||
self.document.pan_x += dx;
|
||
self.document.pan_y += dy;
|
||
}
|
||
|
||
pub fn canvas_width(&self) -> u32 {
|
||
self.document.canvas_width
|
||
}
|
||
|
||
pub fn canvas_height(&self) -> u32 {
|
||
self.document.canvas_height
|
||
}
|
||
|
||
pub fn get_canvas_size(&self) -> (u32, u32) {
|
||
(self.document.canvas_width, self.document.canvas_height)
|
||
}
|
||
|
||
// ── Query Ops ────────────────────────────────────────────────────────
|
||
|
||
pub fn get_layer_count(&self) -> usize {
|
||
self.document.layers.len()
|
||
}
|
||
|
||
pub fn get_layer_info(&self, id: u64) -> Option<LayerInfo> {
|
||
self.document.layer_info(id)
|
||
}
|
||
|
||
pub fn layer_infos(&self) -> Vec<LayerInfo> {
|
||
self.document.layer_infos()
|
||
}
|
||
|
||
pub fn active_layer(&self) -> Option<&hcie_protocol::Layer> {
|
||
self.document.active_layer()
|
||
}
|
||
|
||
pub fn active_layer_id(&self) -> u64 {
|
||
self.document.active_layer()
|
||
.map(|l| l.id)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
pub fn active_layer_id_opt(&self) -> Option<u64> {
|
||
self.document.active_layer().map(|l| l.id)
|
||
}
|
||
|
||
pub fn get_all_layer_ids(&self) -> Vec<u64> {
|
||
self.document.all_layer_ids()
|
||
}
|
||
|
||
pub fn is_modified(&self) -> bool {
|
||
self.document.modified
|
||
}
|
||
|
||
pub fn set_modified(&mut self, modified: bool) {
|
||
self.document.modified = modified;
|
||
}
|
||
|
||
pub fn tab_label(&self) -> String {
|
||
self.document.tab_label()
|
||
}
|
||
|
||
pub fn document_info(&self) -> DocumentInfo {
|
||
DocumentInfo {
|
||
name: self.document.name.clone(),
|
||
canvas_width: self.document.canvas_width,
|
||
canvas_height: self.document.canvas_height,
|
||
layer_count: self.document.layers.len(),
|
||
active_layer_id: self.active_layer_id(),
|
||
modified: self.document.modified,
|
||
zoom: self.document.zoom,
|
||
}
|
||
}
|
||
|
||
|
||
// ── Brush / layer state setters (kept in lib.rs) ─────────────────────
|
||
pub fn set_brush_tip(&mut self, mut tip: BrushTip) {
|
||
// Clamp size to a safe minimum to prevent "cannot sample empty range"
|
||
// panics in brush engine random generators (gen_range with 0..0).
|
||
if tip.size < 1.0 {
|
||
tip.size = 1.0;
|
||
}
|
||
self.current_tip = tip;
|
||
}
|
||
|
||
pub fn set_color(&mut self, rgba: [u8; 4]) {
|
||
self.current_color = rgba;
|
||
}
|
||
|
||
pub fn set_eraser(&mut self, is_eraser: bool) {
|
||
self.is_eraser = is_eraser;
|
||
}
|
||
|
||
pub fn set_cyclic_color(&mut self, enabled: bool) {
|
||
self.cyclic_color = enabled;
|
||
}
|
||
|
||
pub fn set_cyclic_speed(&mut self, speed: f32) {
|
||
self.cyclic_speed = speed.clamp(0.01, 5.0);
|
||
}
|
||
|
||
/// Render an accurate brush preview by simulating a stroke on a temporary document.
|
||
///
|
||
/// **Purpose:** Provides a pixel-accurate preview of the current brush tip,
|
||
/// color, and dynamic settings without mutating the active document.
|
||
///
|
||
/// **Logic & Workflow:**
|
||
/// 1. Create a temporary transparent `Engine` sized to `width x height`.
|
||
/// 2. Add a single layer and set the provided `BrushTip` and color.
|
||
/// 3. Draw the supplied stroke points using `draw_stroke`.
|
||
/// 4. Composite the result and return the full RGBA pixel buffer.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `width`, `height`: Dimensions of the preview buffer in pixels.
|
||
/// - `tip`: The `BrushTip` to use for the preview stroke.
|
||
/// - `color`: The RGBA color to use for the stroke.
|
||
/// - `points`: A list of `(x, y, pressure)` stroke samples.
|
||
///
|
||
/// **Returns:** A `Vec<u8>` containing the premultiplied/unpremultiplied RGBA
|
||
/// composite pixels, suitable for upload as an egui texture.
|
||
///
|
||
/// **Side Effects:** None on the calling engine; the temporary engine is dropped
|
||
/// when the function returns.
|
||
pub fn render_brush_preview(width: u32, height: u32, tip: &BrushTip, color: [u8; 4], points: &[(f32, f32, f32)]) -> Vec<u8> {
|
||
let mut preview = Engine::new(width, height);
|
||
let layer_id = preview.add_layer("Preview");
|
||
preview.set_active_layer(layer_id);
|
||
preview.set_color(color);
|
||
preview.set_brush_tip(tip.clone());
|
||
preview.is_eraser = false;
|
||
preview.draw_stroke(points);
|
||
preview.get_composite_pixels()
|
||
}
|
||
|
||
/// Render a single brush-dab stamp into an RGBA buffer for use as a cursor preview.
|
||
///
|
||
/// **Purpose:** Provides a lightweight, pixel-accurate preview of the current brush
|
||
/// tip shape, size, hardness, opacity, and color without mutating any document.
|
||
///
|
||
/// **Logic & Workflow:**
|
||
/// 1. Clamp the requested dimensions to a safe range (1..=256).
|
||
/// 2. Use the brush engine's existing `generate_brush_stamp` to obtain the correct
|
||
/// alpha mask for the brush style (Round, Square, Star, Bitmap, etc.).
|
||
/// 3. For bitmap brushes, scale the imported alpha mask to the requested size.
|
||
/// 4. Multiply the stamp alpha by the color alpha, brush opacity, and a visibility
|
||
/// factor, then write the result into an RGBA buffer.
|
||
///
|
||
/// **Arguments:**
|
||
/// * `width`, `height` — Output buffer dimensions in pixels.
|
||
/// * `tip` — The `BrushTip` describing style, size, hardness, opacity, and bitmap data.
|
||
/// * `color` — The RGBA color to tint the preview.
|
||
///
|
||
/// **Returns:** A `Vec<u8>` of size `width * height * 4` containing RGBA pixels,
|
||
/// suitable for upload as an egui texture.
|
||
///
|
||
/// **Side Effects:** None; only allocates a small temporary stamp buffer.
|
||
pub fn render_brush_stamp_preview(width: u32, height: u32, tip: &BrushTip, color: [u8; 4]) -> Vec<u8> {
|
||
let w = width.clamp(1, 256) as usize;
|
||
let h = height.clamp(1, 256) as usize;
|
||
let mut buf = vec![0u8; w * h * 4];
|
||
|
||
let preview_alpha = (color[3] as f32 / 255.0) * tip.opacity * 0.85;
|
||
|
||
match tip.style {
|
||
BrushStyle::Bitmap if !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;
|
||
for py in 0..h {
|
||
let map_y = (py as f32 / h as f32 * (bh - 1.0))
|
||
.round()
|
||
.clamp(0.0, bh - 1.0) as u32;
|
||
for px in 0..w {
|
||
let map_x = (px as f32 / w as f32 * (bw - 1.0))
|
||
.round()
|
||
.clamp(0.0, bw - 1.0) as u32;
|
||
let mask = tip.bitmap_pixels[(map_y * tip.bitmap_w + map_x) as usize] as f32
|
||
/ 255.0;
|
||
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||
let idx = (py * w + px) * 4;
|
||
buf[idx] = color[0];
|
||
buf[idx + 1] = color[1];
|
||
buf[idx + 2] = color[2];
|
||
buf[idx + 3] = a;
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
// Build a procedural stamp via the brush engine. This correctly handles
|
||
// Round, Square, Star, HardRound, SoftRound, and falls back to Round for
|
||
// procedural styles whose visual identity comes from multi-dab strokes.
|
||
// Convert the protocol BrushTip to the engine's internal type.
|
||
let engine_tip = hcie_brush_engine::BrushTip {
|
||
style: map_brush_style(tip.style),
|
||
size: tip.size,
|
||
opacity: tip.opacity,
|
||
hardness: tip.hardness,
|
||
spacing: tip.spacing,
|
||
flow: tip.flow,
|
||
jitter_amount: tip.jitter_amount,
|
||
scatter_amount: tip.scatter_amount,
|
||
angle: tip.angle,
|
||
roundness: tip.roundness,
|
||
spray_particle_size: tip.spray_particle_size,
|
||
spray_density: tip.spray_density,
|
||
bitmap_pixels: tip.bitmap_pixels.clone(),
|
||
bitmap_w: tip.bitmap_w,
|
||
bitmap_h: tip.bitmap_h,
|
||
color_variant: tip.color_variant,
|
||
variant_amount: tip.variant_amount,
|
||
density: tip.density,
|
||
drawing_angle: false,
|
||
rotation_random: 0.0,
|
||
};
|
||
let stamp = hcie_brush_engine::generate_brush_stamp(&engine_tip);
|
||
let stamp_d = (stamp.len() as f32).sqrt().round() as usize;
|
||
if stamp_d == 0 {
|
||
return buf;
|
||
}
|
||
for py in 0..h {
|
||
let map_y = (py as f32 / h as f32 * (stamp_d as f32 - 1.0))
|
||
.round()
|
||
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||
for px in 0..w {
|
||
let map_x = (px as f32 / w as f32 * (stamp_d as f32 - 1.0))
|
||
.round()
|
||
.clamp(0.0, stamp_d as f32 - 1.0) as usize;
|
||
let mask = stamp[map_y * stamp_d + map_x] as f32 / 255.0;
|
||
let a = (mask * preview_alpha * 255.0).round() as u8;
|
||
let idx = (py * w + px) * 4;
|
||
buf[idx] = color[0];
|
||
buf[idx + 1] = color[1];
|
||
buf[idx + 2] = color[2];
|
||
buf[idx + 3] = a;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
buf
|
||
}
|
||
|
||
pub fn rename_layer(&mut self, id: u64, name: &str) {
|
||
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
||
l.name = name.to_string();
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
|
||
// ── View / Query / Style ops (kept in lib.rs) ─────────────────────────
|
||
|
||
// ── Layer style / thumbnail / dirty query ops (kept in lib.rs) ─────────
|
||
pub fn get_preview_thumbnail(&self, _id: u64, _max_dim: u32) -> Vec<u8> {
|
||
vec![]
|
||
}
|
||
|
||
pub fn get_layer_pixels(&self, id: u64) -> Option<Vec<u8>> {
|
||
self.document.get_layer_by_id(id).map(|l| l.pixels.clone())
|
||
}
|
||
|
||
pub fn get_pixel(&self, id: u64, x: u32, y: u32) -> Option<[u8; 4]> {
|
||
self.document.get_layer_by_id(id).map(|l| l.get_pixel(x, y))
|
||
}
|
||
|
||
/// Returns the layer styles for a given layer ID.
|
||
/// Returns an empty Vec if the layer doesn't exist.
|
||
pub fn get_layer_styles(&self, id: u64) -> Vec<hcie_protocol::LayerStyle> {
|
||
self.document.get_layer_by_id(id)
|
||
.map(|l| {
|
||
let mut combined = l.styles.clone();
|
||
// Map PSD effects so the UI can display and edit them
|
||
for fx in &l.effects {
|
||
if let Some(style) = protocol_effect_to_style(fx) {
|
||
let disc = std::mem::discriminant(&style);
|
||
if !combined.iter().any(|s| std::mem::discriminant(s) == disc) {
|
||
combined.push(style);
|
||
}
|
||
}
|
||
}
|
||
combined
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Returns (short_name, is_enabled) tuples for enabled styles on a layer.
|
||
pub fn get_layer_style_badges(&self, id: u64) -> Vec<(&'static str, bool)> {
|
||
self.document.get_layer_by_id(id)
|
||
.map(|l| l.styles.iter().map(|s| {
|
||
let (name, enabled) = match s {
|
||
LayerStyle::DropShadow { enabled, .. } => ("DS", *enabled),
|
||
LayerStyle::InnerShadow { enabled, .. } => ("IS", *enabled),
|
||
LayerStyle::OuterGlow { enabled, .. } => ("OG", *enabled),
|
||
LayerStyle::InnerGlow { enabled, .. } => ("IG", *enabled),
|
||
LayerStyle::BevelEmboss { enabled, .. } => ("BE", *enabled),
|
||
LayerStyle::Satin { enabled, .. } => ("St", *enabled),
|
||
LayerStyle::ColorOverlay { enabled, .. } => ("CO", *enabled),
|
||
LayerStyle::GradientOverlay { enabled, .. } => ("GO", *enabled),
|
||
LayerStyle::PatternOverlay { enabled, .. } => ("PO", *enabled),
|
||
LayerStyle::Stroke { enabled, .. } => ("Sk", *enabled),
|
||
};
|
||
(name, enabled)
|
||
}).collect())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
pub fn is_layer_dirty(&self, id: u64) -> bool {
|
||
self.document.get_layer_by_id(id).map(|l| l.dirty).unwrap_or(false)
|
||
}
|
||
|
||
/// **Purpose:**
|
||
/// Updates or inserts a specific `LayerStyle` for the layer with the given ID.
|
||
///
|
||
/// **Logic & Workflow:**
|
||
/// 1. Finds the mutable layer reference by `id`.
|
||
/// 2. Iterates over `layer.styles` to check if a style of the same variant exists.
|
||
/// 3. If found, overwrites it with the updated style. Otherwise, appends it.
|
||
/// 4. Marks the layer as dirty, triggers `effects_dirty` to true so the effect engine recomputes it,
|
||
/// and sets `document.composite_dirty` to true to redraw the composited canvas.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `id`: The unique layer identifier `u64`.
|
||
/// - `style`: The `LayerStyle` variant containing the new parameters.
|
||
///
|
||
/// **Returns:**
|
||
/// - None.
|
||
///
|
||
/// **Side Effects / Dependencies:**
|
||
/// - Modifies the inner layer style collection.
|
||
/// - Triggers canvas re-compositing and layer effect rendering on next composite query.
|
||
pub fn update_layer_style(&mut self, id: u64, style: hcie_protocol::LayerStyle) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
// Snapshot the original (pre-effects) pixels on the first style edit for this
|
||
// layer. Subsequent edits restore from this backup before re-applying effects,
|
||
// preventing visual stacking (shadow-on-shadow, glow-on-glow, etc.).
|
||
let backup_entry = self.raw_pixel_backup.entry(id);
|
||
if matches!(backup_entry, std::collections::hash_map::Entry::Vacant(_)) {
|
||
let backup = layer.pixels.clone();
|
||
backup_entry.or_insert(backup);
|
||
}
|
||
|
||
let discriminant = std::mem::discriminant(&style);
|
||
if let Some(idx) = layer.styles.iter().position(|s| std::mem::discriminant(s) == discriminant) {
|
||
layer.styles[idx] = style.clone();
|
||
} else {
|
||
layer.styles.push(style.clone());
|
||
}
|
||
|
||
// Remove matching PSD effect from `layer.effects` to avoid duplicates/conflicts
|
||
layer.effects.retain(|e| {
|
||
use hcie_protocol::effects::LayerEffect as E;
|
||
use hcie_protocol::LayerStyle as S;
|
||
match (&style, e) {
|
||
(S::DropShadow{..}, E::DropShadow{..}) => false,
|
||
(S::InnerShadow{..}, E::InnerShadow{..}) => false,
|
||
(S::OuterGlow{..}, E::OuterGlow{..}) => false,
|
||
(S::InnerGlow{..}, E::InnerGlow{..}) => false,
|
||
(S::BevelEmboss{..}, E::BevelEmboss{..}) => false,
|
||
(S::Satin{..}, E::Satin{..}) => false,
|
||
(S::ColorOverlay{..}, E::ColorOverlay{..}) => false,
|
||
(S::GradientOverlay{..}, E::GradientOverlay{..}) => false,
|
||
(S::PatternOverlay{..}, E::PatternOverlay{..}) => false,
|
||
(S::Stroke{..}, E::Stroke{..}) => false,
|
||
_ => true,
|
||
}
|
||
});
|
||
|
||
// Ensure there is at least one effect in layer.effects if we have active styles,
|
||
// so that hcie-composite's tiled.rs has_effects check evaluates to true.
|
||
let has_enabled_styles = layer.styles.iter().any(|s| {
|
||
match s {
|
||
hcie_protocol::LayerStyle::DropShadow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
|
||
| hcie_protocol::LayerStyle::Satin { enabled, .. }
|
||
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
|
||
}
|
||
});
|
||
|
||
// Remove any previous dummy effects we added
|
||
layer.effects.retain(|e| {
|
||
match e {
|
||
hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0 => false,
|
||
_ => true,
|
||
}
|
||
});
|
||
|
||
if has_enabled_styles && layer.effects.is_empty() {
|
||
layer.effects.push(hcie_protocol::effects::LayerEffect::DropShadow {
|
||
enabled: false,
|
||
blend_mode: "Normal".to_string(),
|
||
color: [0, 0, 0, 0],
|
||
opacity: 0.0,
|
||
angle: 0.0,
|
||
distance: 0.0,
|
||
spread: 0.0,
|
||
size: 0.0,
|
||
noise: -999.0, // Special sentinel
|
||
contour: None,
|
||
});
|
||
}
|
||
|
||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
// Mark the full canvas as dirty so the next render recomposites.
|
||
// Without this, dirty_bounds stays None and partial_composite forces
|
||
// a full-canvas recomposite anyway — but other property ops set this
|
||
// explicitly so we should too for consistency and correctness.
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||
// Invalidate the below-layer composite cache since this layer's
|
||
// pixels changed via the effects pipeline.
|
||
self.below_cache_dirty = true;
|
||
}
|
||
}
|
||
|
||
/// # Purpose
|
||
/// Remove a layer style/effect from a layer by the frontend-provided
|
||
/// `discriminant_index`. Indices map to the declaration order of the
|
||
/// `LayerStyle` enum variants in `hcie_protocol`:
|
||
/// 0 DropShadow, 1 InnerShadow, 2 OuterGlow, 3 InnerGlow, 4 BevelEmboss,
|
||
/// 5 Satin, 6 ColorOverlay, 7 GradientOverlay, 8 PatternOverlay, 9 Stroke.
|
||
///
|
||
/// # Logic & Workflow
|
||
/// 1. Map `discriminant_index` to the corresponding `LayerStyle` discriminant.
|
||
/// 2. If valid, mutably borrow the layer.
|
||
/// 3. Retain styles and effects that do not match the target style type.
|
||
/// 4. If any style was removed, mark layer dirty and track flags to update the document's composite state.
|
||
/// 5. If styles become empty, flag removal from the raw pixel backup.
|
||
/// 6. Perform the state mutation of document and backup outside the layer borrow scope to satisfy the borrow checker.
|
||
///
|
||
/// # Arguments
|
||
/// * `id` - The unique identifier of the target layer.
|
||
/// * `discriminant_index` - The 0-based index of the style/effect type to remove.
|
||
///
|
||
/// # Returns
|
||
/// * None.
|
||
///
|
||
/// # Side Effects / Dependencies
|
||
/// * Updates layer style, effects, and dirty state.
|
||
/// * Modifies `self.document.composite_dirty` and `self.raw_pixel_backup` on modification.
|
||
pub fn remove_layer_style_by_index(&mut self, id: u64, discriminant_index: u32) {
|
||
let target = match discriminant_index {
|
||
0 => Some(std::mem::discriminant(&hcie_protocol::LayerStyle::DropShadow {
|
||
enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new(),
|
||
})),
|
||
1 => Some(std::mem::discriminant(&hcie_protocol::LayerStyle::InnerShadow {
|
||
enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new(),
|
||
})),
|
||
2 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::OuterGlow { enabled: false, opacity: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
)),
|
||
3 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::InnerGlow { enabled: false, opacity: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
)),
|
||
4 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::BevelEmboss {
|
||
enabled: false, depth: 0.0, size: 0.0, angle: 0.0, altitude: 0.0,
|
||
highlight_opacity: 0.0, shadow_opacity: 0.0, direction: String::new(),
|
||
style: String::new(), technique: String::new(), soften: 0.0,
|
||
highlight_blend_mode: String::new(), highlight_color: [0; 4],
|
||
shadow_blend_mode: String::new(), shadow_color: [0; 4],
|
||
},
|
||
)),
|
||
5 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::Satin { enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, size: 0.0, color: [0; 4], invert: false },
|
||
)),
|
||
6 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::ColorOverlay { enabled: false, opacity: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
)),
|
||
7 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::GradientOverlay { enabled: false, opacity: 0.0, blend_mode: String::new(), angle: 0.0, scale: 0.0, gradient_type: 0 },
|
||
)),
|
||
8 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::PatternOverlay { enabled: false, opacity: 0.0, blend_mode: String::new(), scale: 0.0, pattern_name: String::new() },
|
||
)),
|
||
9 => Some(std::mem::discriminant(
|
||
&hcie_protocol::LayerStyle::Stroke { enabled: false, size: 0.0, position: String::new(), opacity: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
)),
|
||
_ => None,
|
||
};
|
||
let mut did_remove = false;
|
||
let mut empty_styles = false;
|
||
if let Some(target_disc) = target {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
let had_style = layer.styles.iter().any(|s| std::mem::discriminant(s) == target_disc);
|
||
if had_style {
|
||
layer.styles.retain(|s| std::mem::discriminant(s) != target_disc);
|
||
layer.effects.retain(|e| {
|
||
use hcie_protocol::effects::LayerEffect as E;
|
||
use hcie_protocol::LayerStyle as S;
|
||
let dummy = match discriminant_index {
|
||
0 => S::DropShadow { enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
1 => S::InnerShadow { enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
2 => S::OuterGlow { enabled: false, opacity: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
3 => S::InnerGlow { enabled: false, opacity: 0.0, spread: 0.0, size: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
4 => S::BevelEmboss { enabled: false, depth: 0.0, size: 0.0, angle: 0.0, altitude: 0.0, highlight_opacity: 0.0, shadow_opacity: 0.0, direction: String::new(), style: String::new(), technique: String::new(), soften: 0.0, highlight_blend_mode: String::new(), highlight_color: [0; 4], shadow_blend_mode: String::new(), shadow_color: [0; 4] },
|
||
5 => S::Satin { enabled: false, opacity: 0.0, angle: 0.0, distance: 0.0, size: 0.0, color: [0; 4], invert: false },
|
||
6 => S::ColorOverlay { enabled: false, opacity: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
7 => S::GradientOverlay { enabled: false, opacity: 0.0, blend_mode: String::new(), angle: 0.0, scale: 0.0, gradient_type: 0 },
|
||
8 => S::PatternOverlay { enabled: false, opacity: 0.0, blend_mode: String::new(), scale: 0.0, pattern_name: String::new() },
|
||
9 => S::Stroke { enabled: false, size: 0.0, position: String::new(), opacity: 0.0, color: [0; 4], blend_mode: String::new() },
|
||
_ => return true,
|
||
};
|
||
match (&dummy, e) {
|
||
(S::DropShadow{..}, E::DropShadow{..}) => false,
|
||
(S::InnerShadow{..}, E::InnerShadow{..}) => false,
|
||
(S::OuterGlow{..}, E::OuterGlow{..}) => false,
|
||
(S::InnerGlow{..}, E::InnerGlow{..}) => false,
|
||
(S::BevelEmboss{..}, E::BevelEmboss{..}) => false,
|
||
(S::Satin{..}, E::Satin{..}) => false,
|
||
(S::ColorOverlay{..}, E::ColorOverlay{..}) => false,
|
||
(S::GradientOverlay{..}, E::GradientOverlay{..}) => false,
|
||
(S::PatternOverlay{..}, E::PatternOverlay{..}) => false,
|
||
(S::Stroke{..}, E::Stroke{..}) => false,
|
||
_ => true,
|
||
}
|
||
});
|
||
// Ensure there is at least one effect in layer.effects if we have active styles,
|
||
// so that hcie-composite's tiled.rs has_effects check evaluates to true.
|
||
let has_enabled_styles = layer.styles.iter().any(|s| {
|
||
match s {
|
||
hcie_protocol::LayerStyle::DropShadow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::InnerShadow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::OuterGlow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::InnerGlow { enabled, .. }
|
||
| hcie_protocol::LayerStyle::BevelEmboss { enabled, .. }
|
||
| hcie_protocol::LayerStyle::Satin { enabled, .. }
|
||
| hcie_protocol::LayerStyle::ColorOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::GradientOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::PatternOverlay { enabled, .. }
|
||
| hcie_protocol::LayerStyle::Stroke { enabled, .. } => *enabled,
|
||
}
|
||
});
|
||
|
||
// Remove any previous dummy effects we added
|
||
layer.effects.retain(|e| {
|
||
match e {
|
||
hcie_protocol::effects::LayerEffect::DropShadow { noise, .. } if *noise == -999.0 => false,
|
||
_ => true,
|
||
}
|
||
});
|
||
|
||
if has_enabled_styles && layer.effects.is_empty() {
|
||
layer.effects.push(hcie_protocol::effects::LayerEffect::DropShadow {
|
||
enabled: false,
|
||
blend_mode: "Normal".to_string(),
|
||
color: [0, 0, 0, 0],
|
||
opacity: 0.0,
|
||
angle: 0.0,
|
||
distance: 0.0,
|
||
spread: 0.0,
|
||
size: 0.0,
|
||
noise: -999.0, // Special sentinel
|
||
contour: None,
|
||
});
|
||
}
|
||
|
||
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
||
layer.dirty = true;
|
||
did_remove = true;
|
||
if layer.styles.is_empty() {
|
||
empty_styles = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if did_remove {
|
||
self.document.composite_dirty = true;
|
||
let w = self.document.canvas_width;
|
||
let h = self.document.canvas_height;
|
||
self.document.dirty_bounds = Some([0, 0, w, h]);
|
||
self.below_cache_dirty = true;
|
||
}
|
||
if empty_styles {
|
||
self.raw_pixel_backup.remove(&id);
|
||
}
|
||
}
|
||
|
||
pub fn is_composite_dirty(&self) -> bool {
|
||
self.document.composite_dirty
|
||
}
|
||
|
||
pub fn clear_dirty_flags(&mut self) {
|
||
self.document.clear_dirty();
|
||
}
|
||
|
||
|
||
// ── Misc drawing/query ops (kept in lib.rs) ───────────────────────────
|
||
pub fn flood_fill(&mut self, x: u32, y: u32, color: [u8; 4], tolerance: u8) {
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
self::flood_fill(layer, x, y, color, tolerance, mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document.push_draw_snapshot(layer_idx, before, None, "Flood Fill".to_string());
|
||
}
|
||
}
|
||
|
||
pub fn is_stroke_active(&self) -> bool {
|
||
self.stroke_before.is_some()
|
||
}
|
||
|
||
// ── AI Ops ───────────────────────────────────────────────────────────
|
||
|
||
// ── AI Ops ───────────────────────────────────────────────────────────
|
||
|
||
/// Send a chat completion request to the configured AI provider.
|
||
///
|
||
/// # Purpose
|
||
/// Wraps the dynamic `hcie_ai` plugin so the GUI can run a conversational
|
||
/// LLM call without seeing internal plugin paths or FFI details.
|
||
///
|
||
/// # Arguments
|
||
/// * `config_json` - JSON string with provider, model, endpoint, api_key etc.
|
||
/// * `messages_json` - JSON array of `{role, content}` messages.
|
||
///
|
||
/// # Returns
|
||
/// UTF-8 decoded assistant response, or an error string.
|
||
pub fn ai_chat_complete(&self, config_json: &str, messages_json: &str) -> Result<String, String> {
|
||
match crate::dynamic_loader::ai::chat_complete(config_json, messages_json) {
|
||
Ok(bytes) => String::from_utf8(bytes).map_err(|e| format!("Invalid UTF-8 response: {}", e)),
|
||
Err(e) => Err(e),
|
||
}
|
||
}
|
||
|
||
/// Create a new AI chat session.
|
||
pub fn ai_session_create(&self, name: &str) -> i64 {
|
||
crate::dynamic_loader::ai::session_create(name)
|
||
}
|
||
|
||
/// Push a user message into an AI chat session.
|
||
pub fn ai_session_push_user(&self, sid: i64, text: &str) -> bool {
|
||
crate::dynamic_loader::ai::session_push_user(sid, text)
|
||
}
|
||
|
||
/// Push an assistant message into an AI chat session.
|
||
pub fn ai_session_push_assistant(&self, sid: i64, text: &str) -> bool {
|
||
crate::dynamic_loader::ai::session_push_assistant(sid, text)
|
||
}
|
||
|
||
/// Clear all messages in an AI chat session.
|
||
pub fn ai_session_clear(&self, sid: i64) {
|
||
let _ = crate::dynamic_loader::ai::session_clear(sid);
|
||
}
|
||
|
||
/// Destroy an AI chat session and free its resources.
|
||
pub fn ai_session_destroy(&self, sid: i64) {
|
||
crate::dynamic_loader::ai::session_destroy(sid);
|
||
}
|
||
|
||
/// Serialize the chat session to a JSON string.
|
||
pub fn ai_session_serialize(&self, sid: i64) -> Option<String> {
|
||
crate::dynamic_loader::ai::session_serialize(sid)
|
||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||
}
|
||
|
||
/// List names of all registered AI templates.
|
||
pub fn ai_template_names(&self) -> Vec<String> {
|
||
crate::dynamic_loader::ai::template_names()
|
||
.map(|bytes| {
|
||
String::from_utf8(bytes)
|
||
.ok()
|
||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||
.unwrap_or_default()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Fetch a single AI template definition as JSON.
|
||
pub fn ai_template_get(&self, name: &str) -> Option<String> {
|
||
crate::dynamic_loader::ai::template_get(name)
|
||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||
}
|
||
|
||
/// Search templates by object name query.
|
||
pub fn ai_template_search(&self, query: &str) -> Vec<String> {
|
||
crate::dynamic_loader::ai::template_search(query)
|
||
.map(|bytes| {
|
||
String::from_utf8(bytes)
|
||
.ok()
|
||
.and_then(|s| serde_json::from_str::<Vec<String>>(&s).ok())
|
||
.unwrap_or_default()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// Execute an AI template with the given JSON parameters.
|
||
pub fn ai_template_execute(&self, name: &str, params_json: &str) -> Option<String> {
|
||
crate::dynamic_loader::ai::template_execute(name, params_json)
|
||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||
}
|
||
|
||
/// Run a JSON array of AI actions through the engine.
|
||
pub fn execute_ai_actions(&mut self, actions: serde_json::Value) -> Result<String, String> {
|
||
let arr = actions.as_array().ok_or("Expected array of actions")?;
|
||
for action in arr {
|
||
let obj = action.as_object().ok_or("Each action must be an object")?;
|
||
for (key, val) in obj {
|
||
match key.as_str() {
|
||
"CreateLayer" => {
|
||
let name = val["name"].as_str().unwrap_or("Layer");
|
||
self.add_layer(name);
|
||
}
|
||
"DeleteLayer" => {
|
||
if let Some(name) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
self.delete_layer(id);
|
||
}
|
||
} else {
|
||
for info in &self.document.layer_infos() {
|
||
if info.name == name {
|
||
self.delete_layer(info.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SelectLayer" => {
|
||
if let Some(name) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
self.set_active_layer(id);
|
||
}
|
||
} else {
|
||
for info in &self.document.layer_infos() {
|
||
if info.name == name {
|
||
self.set_active_layer(info.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SetForegroundColor" => {
|
||
if let Some(hex) = val["hex"].as_str() {
|
||
let hex = hex.trim_start_matches('#');
|
||
if hex.len() >= 6 {
|
||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||
let a = if hex.len() >= 8 { u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) } else { 255 };
|
||
self.set_color([r, g, b, a]);
|
||
}
|
||
}
|
||
}
|
||
"PaintStroke" => {
|
||
let color = val["color"].as_str().unwrap_or("#000000");
|
||
let _brush_style = val["brush_style"].as_str().unwrap_or("round");
|
||
let size = val["size"].as_f64().unwrap_or(20.0) as f32;
|
||
let opacity = val["opacity"].as_f64().unwrap_or(1.0) as f32;
|
||
let spacing = val["spacing"].as_f64().unwrap_or(0.1) as f32;
|
||
let hardness = val["hardness"].as_f64().unwrap_or(0.8) as f32;
|
||
|
||
let hex = color.trim_start_matches('#');
|
||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||
self.set_color([r, g, b, 255]);
|
||
|
||
let tip = hcie_protocol::BrushTip {
|
||
style: hcie_protocol::BrushStyle::Round,
|
||
size,
|
||
opacity,
|
||
hardness,
|
||
spacing,
|
||
..Default::default()
|
||
};
|
||
self.set_brush_tip(tip);
|
||
|
||
if let Some(pts) = val["points"].as_array() {
|
||
let stroke_pts: Vec<(f32, f32, f32)> = pts.iter()
|
||
.filter_map(|p| p.as_array())
|
||
.filter(|p| p.len() >= 2)
|
||
.map(|p| {
|
||
let x = p[0].as_f64().unwrap_or(0.0) as f32;
|
||
let y = p[1].as_f64().unwrap_or(0.0) as f32;
|
||
(x, y, 1.0)
|
||
})
|
||
.collect();
|
||
if !stroke_pts.is_empty() {
|
||
self.draw_stroke(&stroke_pts);
|
||
}
|
||
}
|
||
}
|
||
"SetBrush" => {
|
||
let mut tip = self.current_tip.clone();
|
||
if let Some(_s) = val["style"].as_str() {
|
||
tip.style = hcie_protocol::BrushStyle::Round;
|
||
}
|
||
if let Some(sz) = val["size"].as_f64() {
|
||
tip.size = sz as f32;
|
||
}
|
||
if let Some(o) = val["opacity"].as_f64() {
|
||
tip.opacity = o as f32;
|
||
}
|
||
if let Some(sp) = val["spacing"].as_f64() {
|
||
tip.spacing = sp as f32;
|
||
}
|
||
if let Some(h) = val["hardness"].as_f64() {
|
||
tip.hardness = h as f32;
|
||
}
|
||
self.set_brush_tip(tip);
|
||
}
|
||
"FillRasterRect" => {
|
||
let x1 = val["x1"].as_f64().unwrap_or(0.0) as f32;
|
||
let y1 = val["y1"].as_f64().unwrap_or(0.0) as f32;
|
||
let x2 = val["x2"].as_f64().unwrap_or(1.0) as f32;
|
||
let y2 = val["y2"].as_f64().unwrap_or(1.0) as f32;
|
||
let color = val["color"].as_str().unwrap_or("#000000");
|
||
let hex = color.trim_start_matches('#');
|
||
let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(0);
|
||
let a = if hex.len() >= 8 { u8::from_str_radix(&hex[6..8], 16).unwrap_or(255) } else { 255 };
|
||
|
||
let mask_ref = self.cached_selection_mask.as_deref();
|
||
let layer_idx = self.document.active_layer;
|
||
let before_pixels = if layer_idx < self.document.layers.len() {
|
||
Some(self.document.layers[layer_idx].pixels.clone())
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
draw_filled_rect(layer, x1, y1, x2, y2, [r, g, b, a], mask_ref);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
if let Some(before) = before_pixels {
|
||
self.document.push_draw_snapshot(layer_idx, before, None, "AI Fill Rect".to_string());
|
||
}
|
||
}
|
||
"AddVectorPath" => {
|
||
let closed = val["closed"].as_bool().unwrap_or(true);
|
||
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
||
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
||
let stroke_width = val["stroke_width"].as_f64().unwrap_or(1.0) as f32;
|
||
|
||
let fc = [u8::from_str_radix(&fill_hex.trim_start_matches('#')[0..2], 16).unwrap_or(0),
|
||
u8::from_str_radix(&fill_hex.trim_start_matches('#')[2..4], 16).unwrap_or(0),
|
||
u8::from_str_radix(&fill_hex.trim_start_matches('#')[4..6], 16).unwrap_or(0), 255];
|
||
let sc = [u8::from_str_radix(&stroke_hex.trim_start_matches('#')[0..2], 16).unwrap_or(0),
|
||
u8::from_str_radix(&stroke_hex.trim_start_matches('#')[2..4], 16).unwrap_or(0),
|
||
u8::from_str_radix(&stroke_hex.trim_start_matches('#')[4..6], 16).unwrap_or(0), 255];
|
||
|
||
if let Some(pts) = val["points"].as_array() {
|
||
let path_pts: Vec<[f32; 2]> = pts.iter()
|
||
.filter_map(|p| p.as_array())
|
||
.filter(|p| p.len() >= 2)
|
||
.map(|p| [p[0].as_f64().unwrap_or(0.0) as f32, p[1].as_f64().unwrap_or(0.0) as f32])
|
||
.collect();
|
||
if !path_pts.is_empty() {
|
||
let shape = hcie_protocol::VectorShape::FreePath {
|
||
pts: path_pts, closed, stroke: stroke_width, color: sc,
|
||
fill_color: fc, fill: true, opacity: 1.0, hardness: 0.8,
|
||
};
|
||
self.add_vector_shape(shape);
|
||
}
|
||
}
|
||
}
|
||
"RenameLayer" => {
|
||
let new_name = val["new_name"].as_str().unwrap_or("Layer");
|
||
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
self.rename_layer(id, new_name);
|
||
}
|
||
} else {
|
||
for info in &self.document.layer_infos() {
|
||
if info.name == name_or_idx {
|
||
self.rename_layer(info.id, new_name);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SetLayerOpacity" => {
|
||
let opacity = val["opacity"].as_f64().unwrap_or(1.0) as f32;
|
||
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
self.set_layer_opacity(id, opacity);
|
||
}
|
||
} else {
|
||
for info in &self.document.layer_infos() {
|
||
if info.name == name_or_idx {
|
||
self.set_layer_opacity(info.id, opacity);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SetLayerBlendMode" => {
|
||
let mode_str = val["blend_mode"].as_str().unwrap_or("Normal");
|
||
let mode = blend_mode_from_label(mode_str);
|
||
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
self.set_layer_blend_mode(id, mode);
|
||
}
|
||
} else {
|
||
for info in &self.document.layer_infos() {
|
||
if info.name == name_or_idx {
|
||
self.set_layer_blend_mode(info.id, mode);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"ToggleLayerVisibility" => {
|
||
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
||
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
||
let ids = self.document.all_layer_ids();
|
||
if let Some(&id) = ids.get(idx) {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
layer.visible = !layer.visible;
|
||
self.document.composite_dirty = true;
|
||
}
|
||
}
|
||
} else {
|
||
let infos = self.document.layer_infos();
|
||
let _target_name = name_or_idx.to_string();
|
||
let target_id = infos.iter().find(|info| info.name == name_or_idx).map(|info| info.id);
|
||
drop(infos);
|
||
if let Some(id) = target_id {
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
layer.visible = !layer.visible;
|
||
self.document.composite_dirty = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"AddEllipse" => {
|
||
let cx = val["cx"].as_f64().unwrap_or(0.0) as f32;
|
||
let cy = val["cy"].as_f64().unwrap_or(0.5) as f32;
|
||
let rx = val["rx"].as_f64().unwrap_or(0.15) as f32;
|
||
let ry = val["ry"].as_f64().unwrap_or(0.15) as f32;
|
||
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
||
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
||
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
||
let fc = parse_hex_color(fill_hex);
|
||
let sc = parse_hex_color(stroke_hex);
|
||
let shape = VectorShape::Circle {
|
||
x1: cx - rx, y1: cy - ry, x2: cx + rx, y2: cy + ry,
|
||
stroke: stroke_width, color: sc, fill_color: fc, fill: true, angle: 0.0, opacity: 1.0, hardness: 1.0,
|
||
};
|
||
self.add_vector_shape(shape);
|
||
}
|
||
"AddRect" => {
|
||
let x1 = val["x1"].as_f64().unwrap_or(0.0) as f32;
|
||
let y1 = val["y1"].as_f64().unwrap_or(0.0) as f32;
|
||
let x2 = val["x2"].as_f64().unwrap_or(1.0) as f32;
|
||
let y2 = val["y2"].as_f64().unwrap_or(1.0) as f32;
|
||
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
||
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
||
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
||
let radius = val["radius"].as_f64().unwrap_or(0.0) as f32;
|
||
let fc = parse_hex_color(fill_hex);
|
||
let sc = parse_hex_color(stroke_hex);
|
||
let shape = VectorShape::Rect {
|
||
x1, y1, x2, y2,
|
||
stroke: stroke_width, color: sc, fill_color: fc, fill: true, radius, angle: 0.0, opacity: 1.0, hardness: 1.0,
|
||
};
|
||
self.add_vector_shape(shape);
|
||
}
|
||
"DeleteShape" => {
|
||
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
||
if shape_idx < shapes.len() {
|
||
shapes.remove(shape_idx);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SetShapeColor" => {
|
||
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
||
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
||
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
||
let sc = parse_hex_color(stroke_hex);
|
||
let fc = parse_hex_color(fill_hex);
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
||
if shape_idx < shapes.len() {
|
||
let shape = &mut shapes[shape_idx];
|
||
set_shape_color(shape, sc, fc);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"MoveShape" => {
|
||
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
||
let dx = val["dx"].as_f64().unwrap_or(0.0) as f32;
|
||
let dy = val["dy"].as_f64().unwrap_or(0.0) as f32;
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
||
if shape_idx < shapes.len() {
|
||
move_shape(shapes[shape_idx].clone(), dx, dy, &mut shapes[shape_idx]);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"ScaleShape" => {
|
||
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
||
let scale_x = val["scale_x"].as_f64().unwrap_or(1.0) as f32;
|
||
let scale_y = val["scale_y"].as_f64().unwrap_or(1.0) as f32;
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
||
if shape_idx < shapes.len() {
|
||
scale_shape(&mut shapes[shape_idx], scale_x, scale_y);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
"SelectTool" => {
|
||
log::info!("AI SelectTool action received — tool selection should be handled by GUI layer");
|
||
}
|
||
"SearchTemplatesDb" => {
|
||
let query = val["object_name"].as_str().unwrap_or("");
|
||
let matches = ai_templates::search_templates(query);
|
||
if matches.is_empty() {
|
||
log::info!("[AI] search_templates_db: no matches for '{}'", query);
|
||
} else {
|
||
for m in &matches {
|
||
log::info!("[AI] search_templates_db match: '{}' — {}", m.name, m.description);
|
||
}
|
||
}
|
||
}
|
||
"DrawTemplate" => {
|
||
let template_name = val["template"].as_str().unwrap_or("house");
|
||
let cx = val["cx"].as_f64().unwrap_or(0.5) as f32;
|
||
let cy = val["cy"].as_f64().unwrap_or(0.5) as f32;
|
||
let scale = val["scale"].as_f64().unwrap_or(0.4) as f32;
|
||
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
||
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
||
|
||
let sc = parse_hex_color(stroke_hex);
|
||
|
||
let mut params = std::collections::HashMap::new();
|
||
if let Some(p_obj) = val["params"].as_object() {
|
||
for (k, v) in p_obj {
|
||
if let Some(s) = v.as_str() {
|
||
params.insert(k.clone(), s.to_string());
|
||
} else if let Some(n) = v.as_f64() {
|
||
params.insert(k.clone(), n.to_string());
|
||
}
|
||
}
|
||
}
|
||
|
||
match ai_templates::get_template_def(template_name) {
|
||
Some(tmpl_def) => {
|
||
let (w, h) = (self.document.canvas_width as f32, self.document.canvas_height as f32);
|
||
let components = ai_templates::eval_template(&tmpl_def, ¶ms);
|
||
let cx_px = cx * w;
|
||
let cy_px = cy * h;
|
||
let s = scale * w.min(h);
|
||
for comp in &components {
|
||
let px_pts: Vec<[f32; 2]> = comp.pts.iter().map(|p| [
|
||
cx_px + (p[0] - 0.5) * s,
|
||
cy_px + (p[1] - 0.5) * s,
|
||
]).collect();
|
||
let fc = if comp.fill && comp.fill_color != "none" {
|
||
parse_hex_color(&comp.fill_color)
|
||
} else {
|
||
[0u8; 4]
|
||
};
|
||
let shape = VectorShape::FreePath {
|
||
pts: px_pts,
|
||
closed: comp.closed,
|
||
stroke: stroke_width,
|
||
color: sc,
|
||
fill_color: fc,
|
||
fill: comp.fill,
|
||
opacity: 1.0,
|
||
hardness: 1.0,
|
||
};
|
||
self.add_vector_shape(shape);
|
||
}
|
||
log::info!("[AI] draw_template '{}' at ({:.2},{:.2}) scale={:.2} → {} paths", template_name, cx, cy, scale, components.len());
|
||
}
|
||
None => {
|
||
let available = ai_templates::all_names().join(", ");
|
||
log::warn!("[AI] draw_template: unknown template '{}'. Available: {}", template_name, available);
|
||
}
|
||
}
|
||
}
|
||
"SetCanvasSize" => {
|
||
let w = val["width"].as_u64().unwrap_or(self.document.canvas_width as u64) as u32;
|
||
let h = val["height"].as_u64().unwrap_or(self.document.canvas_height as u64) as u32;
|
||
self.resize_canvas(w, h);
|
||
log::info!("[AI] set_canvas_size {}x{}", w, h);
|
||
}
|
||
"CropCanvas" => {
|
||
let x = val["x"].as_u64().unwrap_or(0) as u32;
|
||
let y = val["y"].as_u64().unwrap_or(0) as u32;
|
||
let w = val["width"].as_u64().unwrap_or(self.document.canvas_width as u64) as u32;
|
||
let h = val["height"].as_u64().unwrap_or(self.document.canvas_height as u64) as u32;
|
||
self.crop(x, y, w, h);
|
||
log::info!("[AI] crop_canvas x={} y={} w={} h={}", x, y, w, h);
|
||
}
|
||
"Finish" | "Unknown" => {}
|
||
_ => log::warn!("Unknown AI action variant: {}", key),
|
||
}
|
||
}
|
||
}
|
||
Ok("Executed".to_string())
|
||
}
|
||
|
||
// ── AI Vision Ops ─────────────────────────────────────────────────
|
||
|
||
pub fn vision_background_removal(&self, pixels: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||
crate::dynamic_loader::vision::background_removal(pixels, w, h)
|
||
}
|
||
|
||
pub fn vision_object_segment(
|
||
&self,
|
||
pixels: &[u8],
|
||
w: u32,
|
||
h: u32,
|
||
px: u32,
|
||
py: u32,
|
||
bx0: u32,
|
||
by0: u32,
|
||
bx1: u32,
|
||
by1: u32,
|
||
model_path: &str,
|
||
) -> Option<Vec<u8>> {
|
||
crate::dynamic_loader::vision::object_segment(pixels, w, h, px, py, bx0, by0, bx1, by1, model_path)
|
||
}
|
||
|
||
pub fn vision_super_resolution(&self, pixels: &[u8], w: u32, h: u32) -> Option<Vec<u8>> {
|
||
crate::dynamic_loader::vision::super_resolution(pixels, w, h)
|
||
}
|
||
|
||
pub fn vision_inpaint(&self, pixels: &mut [u8], w: u32, h: u32, mask: &[u8], radius: i32) -> bool {
|
||
crate::dynamic_loader::vision::inpaint(pixels, w, h, mask, radius)
|
||
}
|
||
|
||
/// Set the vision backend preference (0 = CPU, 1 = GPU).
|
||
///
|
||
/// The preference is forwarded to the dynamic `hcie-vision` plugin if it
|
||
/// is already loaded, otherwise it is applied when the plugin is first
|
||
/// initialised.
|
||
pub fn vision_set_backend(&self, backend: u8) {
|
||
crate::dynamic_loader::vision::set_backend(backend);
|
||
}
|
||
|
||
// ── Clipboard Ops ─────────────────────────────────────────────────
|
||
|
||
pub fn get_active_layer_pixels(&self) -> Option<Vec<u8>> {
|
||
self.document.active_layer().map(|l| l.pixels.clone())
|
||
}
|
||
|
||
pub fn get_active_layer_size(&self) -> Option<(u32, u32)> {
|
||
self.document.active_layer().map(|l| (l.width, l.height))
|
||
}
|
||
|
||
pub fn clear_active_layer_pixels(&mut self) {
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
layer.pixels.fill(0);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
/// Clear pixels only within the active selection mask. If no selection, clears all.
|
||
pub fn clear_selection_pixels(&mut self) {
|
||
let mask = self.cached_selection_mask.clone();
|
||
if let Some(ref mask_data) = mask {
|
||
let w = self.document.canvas_width as usize;
|
||
let h = self.document.canvas_height as usize;
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let idx = y * w + x;
|
||
if idx < mask_data.len() && mask_data[idx] > 0 {
|
||
let px = idx * 4;
|
||
if px + 3 < layer.pixels.len() {
|
||
layer.pixels[px + 3] = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
} else {
|
||
self.clear_active_layer_pixels();
|
||
}
|
||
}
|
||
|
||
pub fn set_active_layer_pixels(&mut self, pixels: Vec<u8>) {
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
layer.pixels = pixels;
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
|
||
pub fn create_layer_from_data(&mut self, name: &str, pixels: Vec<u8>, width: u32, height: u32) -> u64 {
|
||
let id = self.add_layer(name);
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
layer.pixels = pixels;
|
||
layer.width = width;
|
||
layer.height = height;
|
||
layer.dirty = true;
|
||
}
|
||
self.document.composite_dirty = true;
|
||
id
|
||
}
|
||
|
||
/// Create a vector layer from rasterized pixel data.
|
||
/// Used when importing SVG shapes (e.g., from Krita shapelayer) where the
|
||
/// layer should be editable as vector but initially rendered to pixels.
|
||
pub fn create_vector_layer_from_pixels(
|
||
&mut self,
|
||
name: &str,
|
||
pixels: Vec<u8>,
|
||
width: u32,
|
||
height: u32,
|
||
) -> u64 {
|
||
let id = self.document.add_layer(name);
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
layer.pixels = pixels;
|
||
layer.width = width;
|
||
layer.height = height;
|
||
layer.layer_type = hcie_protocol::LayerType::Vector;
|
||
layer.data = hcie_protocol::LayerData::Vector { shapes: vec![] };
|
||
layer.dirty = false;
|
||
}
|
||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||
let layer = &self.document.layers[idx];
|
||
if idx < self.tile_layers.len() {
|
||
self.tile_layers[idx] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||
}
|
||
}
|
||
self.document.composite_dirty = true;
|
||
id
|
||
}
|
||
|
||
/// Create a vector layer with both rasterized pixels and parsed VectorShapes.
|
||
/// The pixels serve as a pre-rendered backup; shapes enable editing.
|
||
pub fn create_vector_layer_with_shapes(
|
||
&mut self,
|
||
name: &str,
|
||
pixels: Vec<u8>,
|
||
width: u32,
|
||
height: u32,
|
||
shapes: Vec<VectorShape>,
|
||
svg_bytes: Option<Vec<u8>>,
|
||
) -> u64 {
|
||
let id = self.document.add_layer(name);
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
||
layer.pixels = pixels;
|
||
layer.width = width;
|
||
layer.height = height;
|
||
layer.layer_type = hcie_protocol::LayerType::Vector;
|
||
layer.data = hcie_protocol::LayerData::Vector { shapes };
|
||
layer.dirty = false;
|
||
}
|
||
if let Some(svg) = svg_bytes {
|
||
self.svg_sources.insert(id, svg);
|
||
}
|
||
if let Some(idx) = self.document.layer_index_by_id(id) {
|
||
let layer = &self.document.layers[idx];
|
||
if idx < self.tile_layers.len() {
|
||
self.tile_layers[idx] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
||
}
|
||
}
|
||
self.document.composite_dirty = true;
|
||
id
|
||
}
|
||
|
||
// ── Batch Execution ────────────────────────────────────────────────────
|
||
|
||
pub fn execute_batch(&mut self, commands: &[EngineCommand]) -> Vec<EngineEvent> {
|
||
let mut events = Vec::new();
|
||
for cmd in commands {
|
||
match cmd {
|
||
EngineCommand::AddLayer { name } => {
|
||
let id = self.add_layer(name);
|
||
events.push(EngineEvent::LayerAdded { id, name: name.clone(), index: self.get_layer_count() - 1 });
|
||
}
|
||
EngineCommand::DeleteLayer { id } => {
|
||
self.delete_layer(*id);
|
||
events.push(EngineEvent::LayerDeleted { id: *id });
|
||
}
|
||
EngineCommand::SelectLayer { id } => {
|
||
self.set_active_layer(*id);
|
||
events.push(EngineEvent::LayerSelected { id: *id });
|
||
}
|
||
EngineCommand::Undo => {
|
||
if self.undo() {
|
||
events.push(EngineEvent::UndoPerformed { current_step: self.history_current() });
|
||
}
|
||
}
|
||
EngineCommand::Redo => {
|
||
if self.redo() {
|
||
events.push(EngineEvent::RedoPerformed { current_step: self.history_current() });
|
||
}
|
||
}
|
||
EngineCommand::JumpToHistory { index } => {
|
||
self.jump_to_history(*index);
|
||
events.push(EngineEvent::HistoryJumped { target_step: *index });
|
||
}
|
||
EngineCommand::SetZoom(z) => {
|
||
self.set_zoom(*z);
|
||
events.push(EngineEvent::ZoomChanged(*z));
|
||
}
|
||
EngineCommand::PanCanvas(dx, dy) => {
|
||
self.pan_canvas(*dx, *dy);
|
||
events.push(EngineEvent::PanChanged(self.document.pan_x, self.document.pan_y));
|
||
}
|
||
EngineCommand::Crop { x, y, w, h } => {
|
||
self.crop(*x, *y, *w, *h);
|
||
events.push(EngineEvent::CanvasResized { w: self.canvas_width(), h: self.canvas_height() });
|
||
}
|
||
EngineCommand::ResizeCanvas { w, h } => {
|
||
self.resize_canvas(*w, *h);
|
||
events.push(EngineEvent::CanvasResized { w: *w, h: *h });
|
||
}
|
||
EngineCommand::OpenImage(path) => {
|
||
match self.open_image(path) {
|
||
Ok(()) => events.push(EngineEvent::ImageOpened { path: path.clone() }),
|
||
Err(e) => log::error!("Open image failed: {}", e),
|
||
}
|
||
}
|
||
EngineCommand::SaveAs { path, format } => {
|
||
match self.save_as(path, format) {
|
||
Ok(()) => events.push(EngineEvent::Saved { path: path.clone(), success: true }),
|
||
Err(e) => log::error!("Save failed: {}", e),
|
||
}
|
||
}
|
||
EngineCommand::SaveNative(path) => {
|
||
match self.save_native(&path) {
|
||
Ok(()) => events.push(EngineEvent::Saved { path: path.clone(), success: true }),
|
||
Err(e) => log::error!("Native save failed: {}", e),
|
||
}
|
||
}
|
||
EngineCommand::ExportPsd(path) => {
|
||
match self.export_psd(&path) {
|
||
Ok(()) => events.push(EngineEvent::Saved { path: path.clone(), success: true }),
|
||
Err(e) => log::error!("PSD export failed: {}", e),
|
||
}
|
||
}
|
||
EngineCommand::ExportKra(path) => {
|
||
match self.export_kra(&path) {
|
||
Ok(()) => events.push(EngineEvent::Saved { path: path.clone(), success: true }),
|
||
Err(e) => log::error!("KRA export failed: {}", e),
|
||
}
|
||
}
|
||
EngineCommand::SetBrushTip(tip) => {
|
||
self.set_brush_tip(tip.clone());
|
||
}
|
||
EngineCommand::SetColor(rgba) => {
|
||
self.set_color(*rgba);
|
||
}
|
||
EngineCommand::DrawStroke { points } => {
|
||
self.draw_stroke(points);
|
||
}
|
||
EngineCommand::BeginStroke { x, y } => {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
self.begin_stroke(layer.id, *x, *y);
|
||
}
|
||
}
|
||
EngineCommand::StrokeTo { points } => {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
let id = layer.id;
|
||
for &(x, y, pressure) in points {
|
||
self.stroke_to(id, x, y, pressure);
|
||
}
|
||
}
|
||
}
|
||
EngineCommand::EndStroke => {
|
||
if let Some(layer) = self.document.active_layer() {
|
||
self.end_stroke(layer.id);
|
||
}
|
||
}
|
||
EngineCommand::FloodFill { x, y, color, tolerance } => {
|
||
self.flood_fill(*x, *y, *color, *tolerance);
|
||
}
|
||
EngineCommand::CreateSelectionRect { x1, y1, x2, y2 } => {
|
||
self.create_selection_rect(*x1, *y1, *x2, *y2);
|
||
}
|
||
EngineCommand::CreateSelectionEllipse { cx, cy, rx, ry } => {
|
||
self.create_selection_ellipse(*cx, *cy, *rx, *ry);
|
||
}
|
||
EngineCommand::CreateSelectionMagicWand { x, y, tolerance } => {
|
||
self.create_selection_magic_wand(*x, *y, *tolerance);
|
||
}
|
||
EngineCommand::CreateSelectionPolygon { points } => {
|
||
let pts: Vec<(u32, u32)> = points
|
||
.iter()
|
||
.map(|p| (p[0] as u32, p[1] as u32))
|
||
.collect();
|
||
self.create_selection_polygon(&pts);
|
||
}
|
||
EngineCommand::SelectionLasso { points } => {
|
||
let pts: Vec<(u32, u32)> = points
|
||
.iter()
|
||
.map(|p| (p[0] as u32, p[1] as u32))
|
||
.collect();
|
||
self.create_selection_lasso(&pts);
|
||
}
|
||
EngineCommand::SelectionGrow { px } => {
|
||
self.selection_grow(*px);
|
||
}
|
||
EngineCommand::SelectionShrink { px } => {
|
||
self.selection_shrink(*px);
|
||
}
|
||
EngineCommand::SelectionFeather { radius } => {
|
||
self.selection_feather(*radius);
|
||
}
|
||
EngineCommand::SelectionInvert => {
|
||
self.selection_invert();
|
||
}
|
||
EngineCommand::SelectionClear => {
|
||
self.selection_clear();
|
||
}
|
||
EngineCommand::SelectionAll => {
|
||
self.selection_all();
|
||
}
|
||
EngineCommand::AddVectorShape(shape) => {
|
||
self.add_vector_shape(shape.clone());
|
||
}
|
||
EngineCommand::ModifyVectorShape { layer_id, shape_idx, handle, dx, dy } => {
|
||
self.modify_vector_shape(*layer_id, *shape_idx, *handle, *dx, *dy);
|
||
}
|
||
EngineCommand::SetVectorShapeColor { layer_id, shape_idx, color } => {
|
||
self.set_vector_shape_color(*layer_id, *shape_idx, *color);
|
||
}
|
||
EngineCommand::SetVectorShapeOpacity { layer_id, shape_idx, opacity } => {
|
||
self.set_vector_shape_opacity(*layer_id, *shape_idx, *opacity);
|
||
}
|
||
EngineCommand::SetVectorShapeFill { layer_id, shape_idx, fill } => {
|
||
self.set_vector_shape_fill(*layer_id, *shape_idx, *fill);
|
||
}
|
||
EngineCommand::SetVectorShapeFillColor { layer_id, shape_idx, color } => {
|
||
self.set_vector_shape_fill_color(*layer_id, *shape_idx, *color);
|
||
}
|
||
EngineCommand::SetVectorShapeStroke { layer_id, shape_idx, stroke } => {
|
||
self.set_vector_shape_stroke(*layer_id, *shape_idx, *stroke);
|
||
}
|
||
EngineCommand::SetVectorShapeHardness { layer_id, shape_idx, hardness } => {
|
||
self.set_vector_shape_hardness(*layer_id, *shape_idx, *hardness);
|
||
}
|
||
EngineCommand::SetVectorShapeBounds { layer_id, shape_idx, x1, y1, x2, y2 } => {
|
||
self.set_vector_shape_bounds(*layer_id, *shape_idx, *x1, *y1, *x2, *y2);
|
||
}
|
||
EngineCommand::SetVectorShapeAngle { layer_id, shape_idx, angle } => {
|
||
self.set_vector_shape_angle(*layer_id, *shape_idx, *angle);
|
||
}
|
||
EngineCommand::SetVectorShapeLineCaps { layer_id, shape_idx, cap_start, cap_end } => {
|
||
self.set_vector_shape_line_caps(*layer_id, *shape_idx, *cap_start, *cap_end);
|
||
}
|
||
EngineCommand::DeleteVectorShape { layer_id, shape_idx } => {
|
||
self.delete_vector_shape(*layer_id, *shape_idx);
|
||
}
|
||
EngineCommand::UpdateLayerStyle { layer_id, style } => {
|
||
if let Some(idx) = self.document.layer_index_by_id(*layer_id) {
|
||
let before_pixels = self.document.get_layer_by_id(*layer_id).map(|l| l.pixels.clone());
|
||
let _before_styles = self.get_layer_styles(*layer_id);
|
||
self.update_layer_style(*layer_id, style.clone());
|
||
let after_pixels = self.document.get_layer_by_id(*layer_id).map(|l| l.pixels.clone());
|
||
if let (Some(before), Some(after)) = (before_pixels, after_pixels) {
|
||
if before != after {
|
||
self.document.push_draw_snapshot(idx, before, None, format!("Update layer style"));
|
||
}
|
||
}
|
||
events.push(EngineEvent::LayerModified { id: *layer_id });
|
||
}
|
||
}
|
||
EngineCommand::RemoveLayerStyle { layer_id, discriminant_index } => {
|
||
if let Some(idx) = self.document.layer_index_by_id(*layer_id) {
|
||
let before_pixels = self.document.get_layer_by_id(*layer_id).map(|l| l.pixels.clone());
|
||
self.remove_layer_style_by_index(*layer_id, *discriminant_index);
|
||
let after_pixels = self.document.get_layer_by_id(*layer_id).map(|l| l.pixels.clone());
|
||
if let (Some(before), Some(after)) = (before_pixels, after_pixels) {
|
||
if before != after {
|
||
self.document.push_draw_snapshot(idx, before, None, format!("Remove layer style"));
|
||
}
|
||
}
|
||
events.push(EngineEvent::LayerModified { id: *layer_id });
|
||
}
|
||
}
|
||
EngineCommand::MergeDown => {
|
||
self.merge_down();
|
||
events.push(EngineEvent::LayersMerged);
|
||
}
|
||
EngineCommand::FlattenImage => {
|
||
self.flatten_image();
|
||
events.push(EngineEvent::ImageFlattened);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
events.push(EngineEvent::CanvasRendered { width: self.canvas_width(), height: self.canvas_height() });
|
||
events
|
||
}
|
||
}
|
||
|
||
fn parse_hex_color(hex: &str) -> [u8; 4] {
|
||
let h = hex.trim_start_matches('#');
|
||
if h.len() >= 6 {
|
||
let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0);
|
||
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0);
|
||
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0);
|
||
let a = if h.len() >= 8 { u8::from_str_radix(&h[6..8], 16).unwrap_or(255) } else { 255 };
|
||
[r, g, b, a]
|
||
} else {
|
||
[0, 0, 0, 255]
|
||
}
|
||
}
|
||
|
||
fn set_shape_color(shape: &mut hcie_protocol::VectorShape, stroke_color: [u8; 4], fill_color: [u8; 4]) {
|
||
use hcie_protocol::VectorShape::*;
|
||
match shape {
|
||
Line { color, .. } => *color = stroke_color,
|
||
Rect { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Circle { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Arrow { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Star { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Polygon { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Rhombus { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Cylinder { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Heart { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Bubble { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Gear { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Cross { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Crescent { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Bolt { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
Arrow4 { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
FreePath { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
||
}
|
||
}
|
||
|
||
fn move_shape(_original: hcie_protocol::VectorShape, dx: f32, dy: f32, shape: &mut hcie_protocol::VectorShape) {
|
||
use hcie_protocol::VectorShape::*;
|
||
match shape {
|
||
Line { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Rect { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Circle { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Arrow { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Star { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Polygon { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Rhombus { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Cylinder { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Heart { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Bubble { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Gear { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Cross { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Crescent { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Bolt { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
Arrow4 { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
||
FreePath { pts, .. } => { for p in pts.iter_mut() { p[0] += dx; p[1] += dy; } }
|
||
}
|
||
}
|
||
|
||
fn scale_shape(shape: &mut hcie_protocol::VectorShape, scale_x: f32, scale_y: f32) {
|
||
use hcie_protocol::VectorShape::*;
|
||
match shape {
|
||
Line { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Rect { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Circle { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Arrow { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Star { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Polygon { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Rhombus { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Cylinder { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Heart { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Bubble { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Gear { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Cross { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Crescent { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Bolt { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
Arrow4 { x1, y1, x2, y2, .. } => {
|
||
let cx = (*x1 + *x2) / 2.0;
|
||
let cy = (*y1 + *y2) / 2.0;
|
||
*x1 = cx + (*x1 - cx) * scale_x;
|
||
*y1 = cy + (*y1 - cy) * scale_y;
|
||
*x2 = cx + (*x2 - cx) * scale_x;
|
||
*y2 = cy + (*y2 - cy) * scale_y;
|
||
}
|
||
FreePath { pts, .. } => {
|
||
if pts.is_empty() { return; }
|
||
let cx = pts.iter().map(|p| p[0]).sum::<f32>() / pts.len() as f32;
|
||
let cy = pts.iter().map(|p| p[1]).sum::<f32>() / pts.len() as f32;
|
||
for p in pts.iter_mut() {
|
||
p[0] = cx + (p[0] - cx) * scale_x;
|
||
p[1] = cy + (p[1] - cy) * scale_y;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn blend_mode_from_label(label: &str) -> hcie_protocol::BlendMode {
|
||
use hcie_protocol::BlendMode::*;
|
||
match label.to_lowercase().as_str() {
|
||
"normal" => Normal,
|
||
"dissolve" => Dissolve,
|
||
"darken" => Darken,
|
||
"multiply" => Multiply,
|
||
"color burn" | "colorburn" => ColorBurn,
|
||
"linear burn" | "linearburn" => LinearBurn,
|
||
"darker color" | "darkercolor" => DarkerColor,
|
||
"lighten" => Lighten,
|
||
"screen" => Screen,
|
||
"color dodge" | "colordodge" => ColorDodge,
|
||
"linear dodge" | "lineardodge" => LinearDodge,
|
||
"lighter color" | "lightercolor" => LighterColor,
|
||
"overlay" => Overlay,
|
||
"soft light" | "softlight" => SoftLight,
|
||
"hard light" | "hardlight" => HardLight,
|
||
"vivid light" | "vividlight" => VividLight,
|
||
"linear light" | "linearlight" => LinearLight,
|
||
"pin light" | "pinlight" => PinLight,
|
||
"hard mix" | "hardmix" => HardMix,
|
||
"difference" => Difference,
|
||
"exclusion" => Exclusion,
|
||
"subtract" => Subtract,
|
||
"divide" => Divide,
|
||
"hue" => Hue,
|
||
"saturation" => Saturation,
|
||
"color" => Color,
|
||
"luminosity" => Luminosity,
|
||
_ => Normal,
|
||
}
|
||
}
|
||
|
||
|