2d5b7a37e8
- Introduced `SharedCompositePixels` for stable full-canvas pixel storage, enabling efficient pipeline creation and recovery. - Added `TextureUpdate` struct for tightly packed dirty rectangle uploads, reducing unnecessary data copying. - Refactored `upload_dirty_region` to utilize `TextureUpdate`, improving performance by eliminating full buffer scans. - Implemented `encode_selection_texture` to generate an encoded R8 selection mask, optimizing selection rendering. - Updated shader to sample selection texture only once, reducing GPU workload. - Added comprehensive tests for texture updates, selection encoding, and performance diagnostics. - Documented design decisions and validation sequences to ensure future performance stability.
4732 lines
186 KiB
Rust
4732 lines
186 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;
|
||
pub mod shape_catalog;
|
||
pub mod svg_editor;
|
||
|
||
// Performance-critical engine paths isolated into dedicated modules.
|
||
mod layer_property_ops;
|
||
pub mod partial_composite;
|
||
pub mod stroke_brush;
|
||
mod stroke_cache;
|
||
|
||
use crate::dynamic_loader::svg_import::import_svg;
|
||
pub use crate::dynamic_loader::vector::BooleanOp as VectorBooleanOp;
|
||
use crate::dynamic_loader::vector::{boolean_shapes, BooleanOp};
|
||
use crate::dynamic_loader::{
|
||
apply_filter, composite_layers, draw_filled_rect, export_kra, export_psd, flood_fill,
|
||
import_kra, import_psd, load_image, load_native, map_brush_style, save_image, save_native,
|
||
};
|
||
use hcie_document::Document;
|
||
use hcie_text::TextRenderer;
|
||
use hcie_tile::TiledLayer;
|
||
use std::path::Path;
|
||
use std::sync::Mutex;
|
||
|
||
pub use crate::shape_catalog::{ShapeCatalog, ShapeEntry};
|
||
pub use crate::svg_editor::{SvgEditable, SvgNode};
|
||
pub use hcie_blend::Adjustment;
|
||
pub use hcie_brush_engine::presets::BrushPreset;
|
||
pub use hcie_protocol::thumbnail_nearest;
|
||
pub use hcie_protocol::tools::{self, Tool};
|
||
pub use hcie_protocol::tools::{TextAlignment, TextEffect, TextOrientation};
|
||
pub use hcie_protocol::{all_features, version_string};
|
||
pub use hcie_protocol::{canvas_presets, presets};
|
||
pub use hcie_protocol::{
|
||
AlignAxis, BlendMode, BrushStyle, BrushTip, DocumentInfo, EngineCommand, EngineEvent,
|
||
FilterType, LayerData, LayerInfo, LayerStyle, LayerType, LineCap, ModulePanel, ToolConfigs,
|
||
VectorEditHandle, VectorShape, WidgetDescription, ZOOM_MAX, ZOOM_MIN,
|
||
};
|
||
pub mod brush {
|
||
pub use hcie_brush_engine::{BrushStyle, BrushTip};
|
||
}
|
||
|
||
/// 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,
|
||
contour,
|
||
..
|
||
} => {
|
||
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,
|
||
contour: contour
|
||
.as_ref()
|
||
.map(|c| {
|
||
// Convert ContourCurve back to a name for the protocol LayerStyle
|
||
// For now, we'll use a simplified mapping
|
||
if c.points.len() <= 2 {
|
||
"Linear".to_string()
|
||
} else if c.points.len() == 3 && c.points[1].1 > 0.5 {
|
||
"Cone".to_string()
|
||
} else {
|
||
"Linear".to_string()
|
||
}
|
||
})
|
||
.unwrap_or_else(|| "Linear".to_string()),
|
||
})
|
||
}
|
||
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>>>,
|
||
/// Shape catalog for disk‑based SVG shapes.
|
||
pub shape_catalog: crate::shape_catalog::ShapeCatalog,
|
||
}
|
||
|
||
/// Create a `VectorShape::SvgShape` from a shape kind name and parameter map.
|
||
///
|
||
/// The `kind` string must be one of: "arrow", "star", "rhombus", "cylinder",
|
||
/// "heart", "bubble", "gear", "cross", "crescent", "bolt", "arrow4".
|
||
///
|
||
/// Shape-specific parameters (e.g. `points`, `inner_radius`, `thick`) are passed
|
||
/// via `params`.
|
||
pub fn create_vector_shape(
|
||
kind: &str,
|
||
x1: f32,
|
||
y1: f32,
|
||
x2: f32,
|
||
y2: f32,
|
||
params: &std::collections::HashMap<String, f32>,
|
||
) -> VectorShape {
|
||
let svg = hcie_vector::svg_templates::create_svg(kind, params);
|
||
VectorShape::SvgShape {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
kind: kind.to_string(),
|
||
svg,
|
||
stroke: 2.0,
|
||
color: [0, 0, 0, 255],
|
||
fill_color: [255, 255, 255, 255],
|
||
fill: true,
|
||
angle: 0.0,
|
||
opacity: 1.0,
|
||
hardness: 1.0,
|
||
}
|
||
}
|
||
|
||
/// Create a `VectorShape::SvgShape` from an arbitrary SVG string.
|
||
///
|
||
/// Used for custom/user‑supplied shapes loaded from disk.
|
||
/// The `kind` is the filename stem (e.g. `"my_shape"`).
|
||
pub fn create_vector_shape_from_svg(
|
||
kind: &str,
|
||
svg: &str,
|
||
x1: f32,
|
||
y1: f32,
|
||
x2: f32,
|
||
y2: f32,
|
||
) -> VectorShape {
|
||
VectorShape::SvgShape {
|
||
name: String::new(),
|
||
x1,
|
||
y1,
|
||
x2,
|
||
y2,
|
||
kind: kind.to_string(),
|
||
svg: svg.to_string(),
|
||
stroke: 2.0,
|
||
color: [0, 0, 0, 255],
|
||
fill_color: [255, 255, 255, 255],
|
||
fill: true,
|
||
angle: 0.0,
|
||
opacity: 1.0,
|
||
hardness: 1.0,
|
||
}
|
||
}
|
||
|
||
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())),
|
||
shape_catalog: {
|
||
let base = dirs::data_local_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
|
||
let shapes_dir = base.join("hcie").join("shapes");
|
||
crate::shape_catalog::ShapeCatalog::new(shapes_dir)
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 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);
|
||
}
|
||
}
|
||
|
||
/// Create a border from the current selection.
|
||
///
|
||
/// **Purpose:** Extracts only the edge pixels of the selection, keeping
|
||
/// pixels that are selected but have at least one unselected neighbor.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `px`: Border thickness in pixels.
|
||
pub fn selection_border(&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::border_mask(mask, w, h, px);
|
||
}
|
||
}
|
||
|
||
/// Smooth the current selection edges.
|
||
///
|
||
/// **Purpose:** Softens jagged selection edges by applying a box blur
|
||
/// within the specified radius.
|
||
///
|
||
/// **Arguments:**
|
||
/// - `px`: Blur radius in pixels.
|
||
pub fn selection_smooth(&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::smooth_mask(mask, w, h, px);
|
||
}
|
||
}
|
||
|
||
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) {
|
||
// Clone the selection mask before borrowing the layer mutably, to avoid
|
||
// borrow-checker conflict between `active_layer_mut()` and `selection_mask`.
|
||
let mask = self.document.selection_mask.clone();
|
||
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,
|
||
);
|
||
// When a selection is active, restore original pixels for unselected
|
||
// areas so the filter is confined to the selected region.
|
||
if let Some(ref mask) = mask {
|
||
let w = layer.width as usize;
|
||
let h = layer.height as usize;
|
||
if mask.len() == w * h {
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let idx = y * w + x;
|
||
if mask[idx] == 0 {
|
||
let pidx = idx * 4;
|
||
if pidx + 3 < layer.pixels.len() && pidx + 3 < before_pixels.len() {
|
||
layer.pixels[pidx] = before_pixels[pidx];
|
||
layer.pixels[pidx + 1] = before_pixels[pidx + 1];
|
||
layer.pixels[pidx + 2] = before_pixels[pidx + 2];
|
||
layer.pixels[pidx + 3] = before_pixels[pidx + 3];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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) {
|
||
let mask = self.document.selection_mask.clone();
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
// Save the current pixels before applying the filter, so we can
|
||
// restore unselected areas afterwards.
|
||
let saved_before = layer.pixels.clone();
|
||
let _ = apply_filter(
|
||
filter_id,
|
||
¶ms,
|
||
&mut layer.pixels,
|
||
layer.width,
|
||
layer.height,
|
||
);
|
||
// When a selection is active, restore original pixels for unselected
|
||
// areas so the filter preview is confined to the selected region.
|
||
if let Some(ref mask) = mask {
|
||
let w = layer.width as usize;
|
||
let h = layer.height as usize;
|
||
if mask.len() == w * h {
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let idx = y * w + x;
|
||
if mask[idx] == 0 {
|
||
let pidx = idx * 4;
|
||
if pidx + 3 < layer.pixels.len() && pidx + 3 < saved_before.len() {
|
||
layer.pixels[pidx] = saved_before[pidx];
|
||
layer.pixels[pidx + 1] = saved_before[pidx + 1];
|
||
layer.pixels[pidx + 2] = saved_before[pidx + 2];
|
||
layer.pixels[pidx + 3] = saved_before[pidx + 3];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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
|
||
};
|
||
let desc = format!(
|
||
"Add Vector Shape ({})",
|
||
vector_shape_name_or_kind(&shape)
|
||
);
|
||
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, desc);
|
||
return;
|
||
}
|
||
}
|
||
// Create the final vector layer before recording history. This keeps the first shape and
|
||
// its auto-created layer in one atomic Undo/Redo entry instead of capturing an empty
|
||
// intermediate raster layer.
|
||
let desc = format!("Add Vector Shape ({})", vector_shape_name_or_kind(&shape));
|
||
self.document.add_vector_layer_with_shape(
|
||
format!("Vector {}", self.document.layers.len()),
|
||
shape,
|
||
&desc,
|
||
);
|
||
}
|
||
|
||
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]) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!("Set Stroke Color ({})", vector_shape_name_or_kind(shape)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_fill(&mut self, layer_id: u64, shape_idx: usize, fill: bool) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!("Toggle Fill ({})", vector_shape_name_or_kind(shape)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_fill_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!("Set Fill Color ({})", vector_shape_name_or_kind(shape)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_stroke(&mut self, layer_id: u64, shape_idx: usize, stroke: f32) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!("Set Stroke Width ({})", vector_shape_name_or_kind(shape)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_opacity(&mut self, layer_id: u64, shape_idx: usize, opacity: f32) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!("Set Shape Opacity ({})", vector_shape_name_or_kind(shape)),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_bounds(
|
||
&mut self,
|
||
layer_id: u64,
|
||
shape_idx: usize,
|
||
x1: f32,
|
||
y1: f32,
|
||
x2: f32,
|
||
y2: f32,
|
||
) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!(
|
||
"Resize Vector Shape ({})",
|
||
vector_shape_name_or_kind(shape)
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_hardness(&mut self, layer_id: u64, shape_idx: usize, hardness: f32) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!(
|
||
"Set Shape Hardness ({})",
|
||
vector_shape_name_or_kind(shape)
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
if let Some(shape) = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => shapes.get(shape_idx),
|
||
_ => None,
|
||
})
|
||
{
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
format!(
|
||
"Rotate Vector Shape ({})",
|
||
vector_shape_name_or_kind(shape)
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_vector_shape_svg(&mut self, layer_id: u64, shape_idx: usize, new_svg: &str) {
|
||
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::SvgShape { ref mut svg, .. } = shape {
|
||
*svg = new_svg.to_string();
|
||
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.
|
||
/// Marks the layer dirty so the composite is regenerated on the next render pass.
|
||
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 {
|
||
layer.dirty = true;
|
||
return Some(shapes);
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
pub fn reorder_vector_shape(&mut self, layer_id: u64, from_idx: usize, to_idx: usize) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
||
if let LayerData::Vector { ref mut shapes } = layer.data {
|
||
if from_idx < shapes.len() && to_idx < shapes.len() {
|
||
let shape = shapes.remove(from_idx);
|
||
shapes.insert(to_idx, shape);
|
||
layer.dirty = true;
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
self.document.push_vector_snapshot(
|
||
idx,
|
||
Some(before),
|
||
"Reorder Vector Shape".to_string(),
|
||
);
|
||
}
|
||
}
|
||
|
||
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
|
||
let layer_idx = self.document.layer_index_by_id(layer_id);
|
||
let before_shapes = self
|
||
.document
|
||
.get_layer_by_id(layer_id)
|
||
.and_then(|l| match &l.data {
|
||
LayerData::Vector { shapes } => Some(shapes.clone()),
|
||
_ => None,
|
||
});
|
||
let removed_kind = before_shapes.as_ref().and_then(|shapes| {
|
||
shapes.get(shape_idx).map(vector_shape_name_or_kind)
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
if let (Some(idx), Some(before)) = (layer_idx, before_shapes) {
|
||
let desc = removed_kind.map_or_else(
|
||
|| "Delete Vector Shape".to_string(),
|
||
|kind| format!("Delete Vector Shape ({})", kind),
|
||
);
|
||
self.document.push_vector_snapshot(idx, Some(before), desc);
|
||
}
|
||
}
|
||
|
||
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],
|
||
contour: "Linear".to_string(),
|
||
},
|
||
)),
|
||
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],
|
||
contour: "Linear".to_string(),
|
||
},
|
||
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 {
|
||
name: String::new(),
|
||
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 {
|
||
name: String::new(),
|
||
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 {
|
||
name: String::new(),
|
||
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 {
|
||
name: String::new(),
|
||
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.
|
||
/// Records a full-layer draw snapshot so the operation is undoable.
|
||
pub fn clear_selection_pixels(&mut self) {
|
||
let mask = self.document.selection_mask.clone();
|
||
let active_idx = self.document.active_layer;
|
||
log::debug!(
|
||
"[Engine::clear_selection_pixels] active_layer={}, selection_active={}, document_mask_len={:?}, stroke_cache_mask_len={:?}",
|
||
active_idx,
|
||
self.document.selection_active,
|
||
mask.as_ref().map(Vec::len),
|
||
self.cached_selection_mask.as_ref().map(Vec::len),
|
||
);
|
||
if let Some(ref mask_data) = mask {
|
||
let w = self.document.canvas_width as usize;
|
||
let h = self.document.canvas_height as usize;
|
||
let selected_pixels = mask_data.iter().filter(|coverage| **coverage > 0).count();
|
||
log::debug!(
|
||
"[Engine::clear_selection_pixels] clearing selected region: canvas={}x{}, selected_pixels={}",
|
||
w,
|
||
h,
|
||
selected_pixels,
|
||
);
|
||
let before_pixels = self
|
||
.document
|
||
.active_layer()
|
||
.map(|layer| layer.pixels.clone())
|
||
.unwrap_or_default();
|
||
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.push_draw_snapshot(
|
||
active_idx,
|
||
before_pixels,
|
||
None,
|
||
"Clear Selection".to_string(),
|
||
);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
} else {
|
||
log::debug!(
|
||
"[Engine::clear_selection_pixels] no active document mask; clearing the entire active layer"
|
||
);
|
||
self.clear_active_layer_pixels_undoable();
|
||
}
|
||
}
|
||
|
||
/// Clears the entire active layer and records an undo snapshot.
|
||
pub fn clear_active_layer_pixels_undoable(&mut self) {
|
||
let active_idx = self.document.active_layer;
|
||
let before_pixels = self
|
||
.document
|
||
.active_layer()
|
||
.map(|layer| layer.pixels.clone())
|
||
.unwrap_or_default();
|
||
if let Some(layer) = self.document.active_layer_mut() {
|
||
layer.pixels.fill(0);
|
||
layer.dirty = true;
|
||
}
|
||
self.document.push_draw_snapshot(
|
||
active_idx,
|
||
before_pixels,
|
||
None,
|
||
"Clear Layer".to_string(),
|
||
);
|
||
self.document.composite_dirty = true;
|
||
self.document.modified = true;
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
/// Returns a short label for the shape kind, used in history descriptions.
|
||
pub fn vector_shape_kind(shape: &VectorShape) -> String {
|
||
match shape {
|
||
VectorShape::Line { .. } => "Line".to_string(),
|
||
VectorShape::Rect { .. } => "Rect".to_string(),
|
||
VectorShape::Circle { .. } => "Circle".to_string(),
|
||
VectorShape::Arrow { .. } => "Arrow".to_string(),
|
||
VectorShape::Star { .. } => "Star".to_string(),
|
||
VectorShape::Polygon { .. } => "Polygon".to_string(),
|
||
VectorShape::Rhombus { .. } => "Rhombus".to_string(),
|
||
VectorShape::Cylinder { .. } => "Cylinder".to_string(),
|
||
VectorShape::Heart { .. } => "Heart".to_string(),
|
||
VectorShape::Bubble { .. } => "Bubble".to_string(),
|
||
VectorShape::Gear { .. } => "Gear".to_string(),
|
||
VectorShape::Cross { .. } => "Cross".to_string(),
|
||
VectorShape::Crescent { .. } => "Crescent".to_string(),
|
||
VectorShape::Bolt { .. } => "Bolt".to_string(),
|
||
VectorShape::Arrow4 { .. } => "Arrow4".to_string(),
|
||
VectorShape::SvgShape { kind, .. } => kind.clone(),
|
||
VectorShape::FreePath { .. } => "Path".to_string(),
|
||
}
|
||
}
|
||
|
||
/// Returns the shape's name if non-empty, otherwise its kind label.
|
||
pub fn vector_shape_name_or_kind(shape: &VectorShape) -> String {
|
||
let name = match shape {
|
||
VectorShape::Line { name, .. }
|
||
| VectorShape::Rect { name, .. }
|
||
| VectorShape::Circle { name, .. }
|
||
| VectorShape::Arrow { name, .. }
|
||
| VectorShape::Star { name, .. }
|
||
| VectorShape::Polygon { name, .. }
|
||
| VectorShape::Rhombus { name, .. }
|
||
| VectorShape::Cylinder { name, .. }
|
||
| VectorShape::Heart { name, .. }
|
||
| VectorShape::Bubble { name, .. }
|
||
| VectorShape::Gear { name, .. }
|
||
| VectorShape::Cross { name, .. }
|
||
| VectorShape::Crescent { name, .. }
|
||
| VectorShape::Bolt { name, .. }
|
||
| VectorShape::Arrow4 { name, .. }
|
||
| VectorShape::SvgShape { name, .. }
|
||
| VectorShape::FreePath { name, .. } => name.as_str(),
|
||
};
|
||
if name.is_empty() {
|
||
vector_shape_kind(shape).to_string()
|
||
} else {
|
||
name.to_string()
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
SvgShape {
|
||
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;
|
||
}
|
||
SvgShape { 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;
|
||
}
|
||
SvgShape { 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,
|
||
}
|
||
}
|