3434 lines
158 KiB
Rust
3434 lines
158 KiB
Rust
|
|
//! 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;
|
|||
|
|
|
|||
|
|
use hcie_protocol::*;
|
|||
|
|
use hcie_document::Document;
|
|||
|
|
use crate::dynamic_loader::{
|
|||
|
|
draw_brush_stroke, draw_filled_rect, draw_filled_ellipse, draw_line, flood_fill,
|
|||
|
|
draw_specialized_stroke,
|
|||
|
|
composite_layers, tiled,
|
|||
|
|
apply_filter,
|
|||
|
|
load_image, save_image, import_psd, import_kra, save_native, load_native, export_psd, export_kra,
|
|||
|
|
};
|
|||
|
|
use crate::dynamic_loader::svg_import::import_svg;
|
|||
|
|
use hcie_tile::TiledLayer;
|
|||
|
|
use crate::dynamic_loader::vector::{render_vector_shapes, boolean_shapes, BooleanOp};
|
|||
|
|
pub use crate::dynamic_loader::vector::BooleanOp as VectorBooleanOp;
|
|||
|
|
use hcie_text::TextRenderer;
|
|||
|
|
use std::path::Path;
|
|||
|
|
use std::sync::Mutex;
|
|||
|
|
|
|||
|
|
pub use hcie_protocol::{BlendMode, BrushTip, EngineCommand, EngineEvent, LayerInfo, DocumentInfo, ModulePanel, WidgetDescription, VectorShape, VectorEditHandle, LineCap, LayerType, BrushStyle, ToolConfigs, FilterType, LayerStyle, LayerData, ZOOM_MIN, ZOOM_MAX, AlignAxis};
|
|||
|
|
pub use hcie_protocol::{all_features, version_string};
|
|||
|
|
pub use hcie_protocol::{presets, canvas_presets};
|
|||
|
|
pub use hcie_protocol::thumbnail_nearest;
|
|||
|
|
pub use hcie_protocol::tools::{self, Tool};
|
|||
|
|
pub use hcie_blend::Adjustment;
|
|||
|
|
pub use hcie_brush_engine::presets::BrushPreset;
|
|||
|
|
pub mod brush {
|
|||
|
|
pub use hcie_brush_engine::{BrushTip, BrushStyle};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Helper to convert a PSD `LayerEffect` to a GUI `LayerStyle`
|
|||
|
|
fn protocol_effect_to_style(fx: &hcie_protocol::effects::LayerEffect) -> Option<hcie_protocol::LayerStyle> {
|
|||
|
|
use hcie_protocol::effects::LayerEffect as E;
|
|||
|
|
use hcie_protocol::LayerStyle as S;
|
|||
|
|
match fx {
|
|||
|
|
E::DropShadow { enabled, opacity, angle, distance, spread, size, color, blend_mode, .. } => {
|
|||
|
|
Some(S::DropShadow { enabled: *enabled, opacity: *opacity, angle: *angle, distance: *distance, spread: *spread, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
|||
|
|
}
|
|||
|
|
E::InnerShadow { enabled, opacity, angle, distance, choke, size, color, blend_mode, .. } => {
|
|||
|
|
Some(S::InnerShadow { enabled: *enabled, opacity: *opacity, angle: *angle, distance: *distance, spread: *choke, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
|||
|
|
}
|
|||
|
|
E::OuterGlow { enabled, opacity, spread, size, color, blend_mode, .. } => {
|
|||
|
|
Some(S::OuterGlow { enabled: *enabled, opacity: *opacity, spread: *spread, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
|||
|
|
}
|
|||
|
|
E::InnerGlow { enabled, opacity, choke, size, color, blend_mode, .. } => {
|
|||
|
|
Some(S::InnerGlow { enabled: *enabled, opacity: *opacity, spread: *choke, size: *size, color: *color, blend_mode: blend_mode.clone() })
|
|||
|
|
}
|
|||
|
|
E::BevelEmboss { enabled, depth, size, angle, altitude, highlight_opacity, shadow_opacity, direction, style, .. } => {
|
|||
|
|
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(),
|
|||
|
|
};
|
|||
|
|
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 })
|
|||
|
|
}
|
|||
|
|
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, .. } => {
|
|||
|
|
Some(S::GradientOverlay { enabled: *enabled, opacity: *opacity, blend_mode: blend_mode.clone(), angle: *angle, scale: *scale })
|
|||
|
|
}
|
|||
|
|
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`: **NOT YET POOLED.** Currently `layer.pixels.clone()`
|
|||
|
|
/// allocates a fresh ~33MB buffer on every `begin_stroke()`. This buffer is
|
|||
|
|
/// then passed to `push_draw_snapshot()` on `end_stroke()` and owned by the
|
|||
|
|
/// history system. Pooling this buffer requires changes to both `Engine` and
|
|||
|
|
/// `hcie-history` (the snapshot stores the buffer permanently, so a pool
|
|||
|
|
/// would need a take-and-return mechanism). See PROGRESS.md item #4.
|
|||
|
|
///
|
|||
|
|
/// ## 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).
|
|||
|
|
///
|
|||
|
|
/// Allocated as `layer.pixels.clone()` (~33MB on 4K) on every `begin_stroke()`.
|
|||
|
|
/// Consumed by `push_draw_snapshot()` on `end_stroke()`. NOT pooled — see
|
|||
|
|
/// struct-level doc for pooling roadmap.
|
|||
|
|
stroke_before: Option<(u64, Vec<u8>, Option<Vec<VectorShape>>)>,
|
|||
|
|
tile_layers: Vec<Option<TiledLayer>>,
|
|||
|
|
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
|
|||
|
|
filter_preview_original: Option<Vec<u8>>,
|
|||
|
|
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>>,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
impl Engine {
|
|||
|
|
pub fn new(w: u32, h: u32) -> Self {
|
|||
|
|
Self {
|
|||
|
|
document: Document::new_blank("Untitled", w, h, true),
|
|||
|
|
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,
|
|||
|
|
tile_layers: Vec::new(),
|
|||
|
|
svg_sources: std::collections::HashMap::new(),
|
|||
|
|
filter_preview_original: 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(),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Layer Ops ──────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn delete_layer(&mut self, id: u64) {
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(id) {
|
|||
|
|
self.document.delete_layer(idx);
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.svg_sources.remove(&id);
|
|||
|
|
for layer in &mut self.document.layers {
|
|||
|
|
layer.dirty = true;
|
|||
|
|
}
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn add_layer(&mut self, name: &str) -> u64 {
|
|||
|
|
let id = self.document.add_layer(name);
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn add_group(&mut self, name: &str) -> u64 {
|
|||
|
|
let id = self.document.add_layer(name);
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.layer_type = hcie_protocol::LayerType::Group;
|
|||
|
|
}
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
id
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn add_raw_layer(&mut self, layer: hcie_protocol::Layer) {
|
|||
|
|
self.document.layers.push(layer);
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_layer_parent(&mut self, id: u64, parent_id: Option<u64>) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.parent_id = parent_id;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
// Parent hierarchy changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Switch the active (selected) layer by ID.
|
|||
|
|
///
|
|||
|
|
/// **Purpose:**
|
|||
|
|
/// Updates the document's active layer index and invalidates all caches that
|
|||
|
|
/// depend on the active layer position — most critically `below_cache`, which
|
|||
|
|
/// holds a pre-composited snapshot of layers *below* the active layer.
|
|||
|
|
///
|
|||
|
|
/// **Why cache invalidation is required:**
|
|||
|
|
/// The `below_cache` is built during `begin_stroke()` for the current active
|
|||
|
|
/// layer index. If the user switches to a different layer, the cached
|
|||
|
|
/// composite is stale (wrong set of below-layers). Without invalidation,
|
|||
|
|
/// the next stroke would render with incorrect below-layer content, and
|
|||
|
|
/// previous drawings would appear to "disappear."
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `id` — The layer ID to make active.
|
|||
|
|
///
|
|||
|
|
/// **Side Effects:**
|
|||
|
|
/// - Clears `below_cache` and `below_cache_active_idx`.
|
|||
|
|
/// - Marks `composite_dirty = true` and sets `dirty_bounds` to full canvas.
|
|||
|
|
pub fn set_active_layer(&mut self, id: u64) {
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(id) {
|
|||
|
|
log::trace!(
|
|||
|
|
"[set_active_layer] switching from layer idx={} to idx={} (id={})",
|
|||
|
|
self.document.active_layer, idx, id
|
|||
|
|
);
|
|||
|
|
self.document.set_active_layer(idx);
|
|||
|
|
// Active layer change invalidates the below-layer composite cache
|
|||
|
|
// (it was built for the previous active layer's position).
|
|||
|
|
// Also mark composite dirty so the next emit produces correct output.
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
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]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_layer_opacity(&mut self, id: u64, opacity: f32) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.opacity = opacity.clamp(0.0, 1.0);
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
// Opacity changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// **Purpose:**
|
|||
|
|
/// Sets the visibility state of a layer by its ID and immediately rebuilds its tile cache.
|
|||
|
|
///
|
|||
|
|
/// **Logic & Workflow:**
|
|||
|
|
/// 1. Finds the layer's index in the document's layer vector.
|
|||
|
|
/// 2. If found, sets the `visible` and `dirty` flags to `true`, and marks the document as composite-dirty and modified.
|
|||
|
|
/// 3. Rebuilds the tile cache (`self.tile_layers`) for this specific layer using `TiledLayer::from_dense` from the dense pixel array. This ensures that the tile cache is fully populated with all pixels for the toggled layer, preventing partial tiles or stale rendering during subsequent paint strokes.
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `id` — The numeric ID of the layer.
|
|||
|
|
/// - `visible` — The desired visibility state (`true` for visible, `false` for hidden).
|
|||
|
|
///
|
|||
|
|
/// **Returns:**
|
|||
|
|
/// - `()`
|
|||
|
|
///
|
|||
|
|
/// **Side Effects / Dependencies:**
|
|||
|
|
/// - Updates visibility and dirty flags on the layer.
|
|||
|
|
/// - Modifies the engine's `self.tile_layers` cache.
|
|||
|
|
pub fn set_layer_visible(&mut self, id: u64, visible: bool) {
|
|||
|
|
log::trace!("[set_layer_visible] ===== START id={} visible={} =====", id, visible);
|
|||
|
|
log::trace!(
|
|||
|
|
"[set_layer_visible] BEFORE: active_layer={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}",
|
|||
|
|
self.document.active_layer,
|
|||
|
|
self.document.composite_dirty,
|
|||
|
|
self.document.dirty_bounds,
|
|||
|
|
self.below_cache.is_some(),
|
|||
|
|
self.below_cache_active_idx,
|
|||
|
|
self.tile_layers.len()
|
|||
|
|
);
|
|||
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|||
|
|
log::trace!(
|
|||
|
|
"[set_layer_visible] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?} pixels_len={}",
|
|||
|
|
i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode, l.pixels.len()
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(id) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.set_layer_visible(idx, visible);
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
log::trace!(
|
|||
|
|
"[set_layer_visible] AFTER: tile_layers_len={}, composite_dirty={}, dirty_bounds={:?}, below_cache={}, below_cache_active_idx={:?}",
|
|||
|
|
self.tile_layers.len(),
|
|||
|
|
self.document.composite_dirty,
|
|||
|
|
self.document.dirty_bounds,
|
|||
|
|
self.below_cache.is_some(),
|
|||
|
|
self.below_cache_active_idx
|
|||
|
|
);
|
|||
|
|
for (i, tl) in self.tile_layers.iter().enumerate() {
|
|||
|
|
let tile_count = tl.as_ref().map(|t| t.tile_count()).unwrap_or(0);
|
|||
|
|
log::trace!("[set_layer_visible] tile_layers[{}] tile_count={}", i, tile_count);
|
|||
|
|
}
|
|||
|
|
log::trace!("[set_layer_visible] ===== END id={} =====", id);
|
|||
|
|
} else {
|
|||
|
|
log::trace!("[set_layer_visible] layer id={} NOT FOUND", id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_layer_locked(&mut self, id: u64, locked: bool) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.locked = locked;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn is_layer_locked(&self, id: u64) -> bool {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id(id) {
|
|||
|
|
l.locked
|
|||
|
|
} else {
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_layer_blend_mode(&mut self, id: u64, mode: BlendMode) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.blend_mode = mode;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
// Blend mode changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn move_layer(&mut self, id: u64, new_index: usize) {
|
|||
|
|
if let Some(from) = self.document.layer_index_by_id(id) {
|
|||
|
|
self.document.move_layer(from, new_index);
|
|||
|
|
}
|
|||
|
|
// Layer order changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn toggle_group_collapsed(&mut self, id: u64) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.collapsed = !l.collapsed;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
// Group collapse changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_clipping_mask(&mut self, id: u64, enabled: bool) {
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
l.clipping_mask = enabled;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
// Clipping mask changes affect the global composite of *all* layers.
|
|||
|
|
// Stale tiles in other layers cause subsequent partial compositing
|
|||
|
|
// to render incorrectly. Force a complete tile cache rebuild.
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.dirty_bounds = Some([0, 0, w, h]);
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
self.composite_scratch = None;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── History Ops ──────────────────────────────────────────────────────
|
|||
|
|
pub fn undo(&mut self) -> bool {
|
|||
|
|
if self.document.can_undo() {
|
|||
|
|
self.document.undo();
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
true
|
|||
|
|
} else {
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn redo(&mut self) -> bool {
|
|||
|
|
if self.document.can_redo() {
|
|||
|
|
self.document.redo();
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
true
|
|||
|
|
} else {
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn can_undo(&self) -> bool { self.document.can_undo() }
|
|||
|
|
pub fn can_redo(&self) -> bool { self.document.can_redo() }
|
|||
|
|
pub fn history_len(&self) -> usize { self.document.history_len() }
|
|||
|
|
pub fn history_current(&self) -> i32 { self.document.history_current() }
|
|||
|
|
pub fn history_description(&self, idx: usize) -> Option<String> {
|
|||
|
|
self.document.history_description(idx)
|
|||
|
|
}
|
|||
|
|
pub fn jump_to_history(&mut self, idx: i32) {
|
|||
|
|
self.document.jump_to_history(idx);
|
|||
|
|
self.tile_layers.clear();
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
}
|
|||
|
|
// ── Drawing Ops ────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn set_brush_tip(&mut self, tip: BrushTip) {
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Check if the given brush style needs specialized procedural rendering.
|
|||
|
|
/// Basic stamp-based styles (Round, Square, HardRound, SoftRound) return false.
|
|||
|
|
fn is_specialized_style(s: BrushStyle) -> bool {
|
|||
|
|
matches!(s, BrushStyle::Oil | BrushStyle::Charcoal | BrushStyle::Watercolor |
|
|||
|
|
BrushStyle::Calligraphy | BrushStyle::Marker | BrushStyle::Glow | BrushStyle::Airbrush |
|
|||
|
|
BrushStyle::Spray |
|
|||
|
|
BrushStyle::Pencil | BrushStyle::Crayon | BrushStyle::Tree | BrushStyle::Meadow |
|
|||
|
|
BrushStyle::Rock | BrushStyle::Clouds | BrushStyle::Dirt | BrushStyle::Star |
|
|||
|
|
BrushStyle::Bristle | BrushStyle::Wood | BrushStyle::Sketch | BrushStyle::Hatch |
|
|||
|
|
BrushStyle::Square | BrushStyle::WetPaint | BrushStyle::Leaf | BrushStyle::Mixer)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Stroke API (brush drawing)
|
|||
|
|
|
|||
|
|
/// Returns `true` if a stroke is currently in progress.
|
|||
|
|
/// A stroke is active between `begin_stroke()` and `end_stroke()`.
|
|||
|
|
pub fn is_stroke_active(&self) -> bool {
|
|||
|
|
self.stroke_before.is_some()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Begin a new brush stroke on the given layer.
|
|||
|
|
///
|
|||
|
|
/// # Allocation Behaviour
|
|||
|
|
///
|
|||
|
|
/// This function performs two allocations per call:
|
|||
|
|
///
|
|||
|
|
/// 1. `active_stroke_mask` — **Pooled**: if the existing buffer matches
|
|||
|
|
/// `layer_size`, it is zeroed in-place with `fill(0)`. Only if the
|
|||
|
|
/// canvas dimensions changed does a fresh `vec![0u8; layer_size]`
|
|||
|
|
/// allocation occur (~8MB on 4K). The buffer is retained across strokes.
|
|||
|
|
///
|
|||
|
|
/// 2. `stroke_before` — **NOT pooled**: `layer.pixels.clone()` allocates a
|
|||
|
|
/// fresh ~33MB buffer on every call. This buffer is consumed by
|
|||
|
|
/// `push_draw_snapshot()` during `end_stroke()` and becomes owned by
|
|||
|
|
/// the undo history. Pooling would require the history crate to
|
|||
|
|
/// participate in a buffer return mechanism. See struct-level doc.
|
|||
|
|
///
|
|||
|
|
/// # Risk
|
|||
|
|
///
|
|||
|
|
/// If `active_stroke_mask` is accidentally set to `None` at any point
|
|||
|
|
/// after `end_stroke()` (e.g. a partial merge revert), the pooling
|
|||
|
|
/// optimization is silently negated and ~8MB will be allocated per stroke.
|
|||
|
|
/// The merge artifact cleanup of 2026-05-28 removed exactly this regression.
|
|||
|
|
pub fn begin_stroke(&mut self, layer_id: u64, x: f32, y: f32) {
|
|||
|
|
self.last_stroke_pos = Some((x, y, 1.0));
|
|||
|
|
self.sketch_history.clear();
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
|||
|
|
let layer_size = (layer.width * layer.height) as usize;
|
|||
|
|
|
|||
|
|
// Pool active_stroke_mask: reuse buffer if size matches, otherwise allocate.
|
|||
|
|
// This avoids a ~16MB allocation per stroke on a 4K canvas.
|
|||
|
|
match &mut self.active_stroke_mask {
|
|||
|
|
Some(mask) if mask.len() == layer_size => {
|
|||
|
|
// Reuse existing buffer — just zero it
|
|||
|
|
mask.fill(0);
|
|||
|
|
}
|
|||
|
|
_ => {
|
|||
|
|
// Size changed (different document/layer) — allocate fresh
|
|||
|
|
self.active_stroke_mask = Some(vec![0u8; layer_size]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Initialize last_stroke_bounds with brush radius around first point
|
|||
|
|
// Used for sub-rect snapshot in end_stroke (avoid 33MB clone on 4K)
|
|||
|
|
let tip = &self.current_tip;
|
|||
|
|
let dr = ((tip.size.max(2.0) * 1.3) as i32).max(1);
|
|||
|
|
let ix = x as i32;
|
|||
|
|
let iy = y as i32;
|
|||
|
|
let x0 = (ix - dr).max(0) as u32;
|
|||
|
|
let y0 = (iy - dr).max(0) as u32;
|
|||
|
|
let x1 = ((ix + dr + 1).min(layer.width as i32 - 1)).max(0) as u32;
|
|||
|
|
let y1 = ((iy + dr + 1).min(layer.height as i32 - 1)).max(0) as u32;
|
|||
|
|
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
|||
|
|
|
|||
|
|
let before = layer.pixels.clone();
|
|||
|
|
let before_shapes = if let LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
Some(shapes.clone())
|
|||
|
|
} else {
|
|||
|
|
None
|
|||
|
|
};
|
|||
|
|
self.stroke_before = Some((layer_id, before, before_shapes));
|
|||
|
|
|
|||
|
|
// ── Below-layer composite cache ──────────────────────────────
|
|||
|
|
let active_idx = self.document.active_layer;
|
|||
|
|
let cw = self.document.canvas_width;
|
|||
|
|
let ch = self.document.canvas_height;
|
|||
|
|
log::trace!(
|
|||
|
|
"[begin_stroke] ===== BUILDING below_cache: active_idx={}, cw={}, ch={}, layers_count={}, tile_layers_len={}, existing_below_cache={}, existing_active_idx={:?} =====",
|
|||
|
|
active_idx, cw, ch, self.document.layers.len(), self.tile_layers.len(),
|
|||
|
|
self.below_cache.is_some(), self.below_cache_active_idx
|
|||
|
|
);
|
|||
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|||
|
|
let tl_info = self.tile_layers.get(i).and_then(|t| t.as_ref().map(|tl| tl.tile_count())).unwrap_or(0);
|
|||
|
|
log::trace!(
|
|||
|
|
"[begin_stroke] layer[{}] id={} visible={} dirty={} opacity={} tile_count={}",
|
|||
|
|
i, l.id, l.visible, l.dirty, l.opacity, tl_info
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
if active_idx > 0 {
|
|||
|
|
let lcount = self.document.layers.len();
|
|||
|
|
if self.tile_layers.len() < lcount {
|
|||
|
|
self.tile_layers.resize_with(lcount, || None);
|
|||
|
|
}
|
|||
|
|
let mut tiles_built = 0usize;
|
|||
|
|
for (ti, tl) in self.document.layers.iter().enumerate() {
|
|||
|
|
if ti >= active_idx { break; }
|
|||
|
|
if !tl.pixels.is_empty() && self.tile_layers[ti].is_none() {
|
|||
|
|
self.tile_layers[ti] = Some(TiledLayer::from_dense(
|
|||
|
|
&tl.pixels, tl.width, tl.height,
|
|||
|
|
));
|
|||
|
|
tiles_built += 1;
|
|||
|
|
log::trace!("[begin_stroke] built tile for below layer[{}] id={}", ti, tl.id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let buf_size = (cw * ch * 4) as usize;
|
|||
|
|
let mut cache = match self.below_cache.take() {
|
|||
|
|
Some(mut b) if b.len() == buf_size => {
|
|||
|
|
b.fill(0);
|
|||
|
|
b
|
|||
|
|
}
|
|||
|
|
_ => vec![0u8; buf_size],
|
|||
|
|
};
|
|||
|
|
let tile_slice_len = active_idx.min(self.tile_layers.len());
|
|||
|
|
let visible_below: Vec<(usize, bool)> = self.document.layers[..active_idx]
|
|||
|
|
.iter().enumerate().map(|(i, l)| (i, l.visible)).collect();
|
|||
|
|
log::trace!(
|
|||
|
|
"[begin_stroke] compositing below_cache: {} below_layers, tile_slice_len={}, visible_below={:?}, tiles_built={}",
|
|||
|
|
active_idx, tile_slice_len, visible_below, tiles_built
|
|||
|
|
);
|
|||
|
|
tiled::composite_tiled_into(
|
|||
|
|
&self.document.layers[..active_idx],
|
|||
|
|
&self.tile_layers[..tile_slice_len],
|
|||
|
|
cw, ch, 0, 0, cw, ch,
|
|||
|
|
&mut cache,
|
|||
|
|
);
|
|||
|
|
let non_zero = cache.iter().filter(|&&b| b != 0).count();
|
|||
|
|
log::trace!(
|
|||
|
|
"[begin_stroke] below_cache built: {} bytes, non-zero bytes={}, active_idx={}",
|
|||
|
|
cache.len(), non_zero, active_idx
|
|||
|
|
);
|
|||
|
|
self.below_cache = Some(cache);
|
|||
|
|
self.below_cache_active_idx = Some(active_idx);
|
|||
|
|
} else {
|
|||
|
|
log::trace!("[begin_stroke] active_idx=0 (bottom layer), no below_cache");
|
|||
|
|
self.below_cache = None;
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn stroke_to(&mut self, layer_id: u64, x: f32, y: f32, pressure: f32) {
|
|||
|
|
let w = self.document.canvas_width as f32;
|
|||
|
|
let h = self.document.canvas_height as f32;
|
|||
|
|
let inside = x >= 0.0 && y >= 0.0 && x < w && y < h;
|
|||
|
|
if !inside {
|
|||
|
|
self.last_stroke_pos = None;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let tip = self.current_tip.clone();
|
|||
|
|
let color = self.apply_cyclic_color(self.current_color);
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
|
|||
|
|
if Self::is_specialized_style(tip.style) {
|
|||
|
|
if let Some((lx, ly, lp)) = self.last_stroke_pos {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
draw_specialized_stroke(
|
|||
|
|
&mut layer.pixels,
|
|||
|
|
layer.width,
|
|||
|
|
layer.height,
|
|||
|
|
&[(lx, ly, lp), (x, y, pressure)],
|
|||
|
|
tip.style,
|
|||
|
|
tip.size,
|
|||
|
|
tip.hardness,
|
|||
|
|
color,
|
|||
|
|
tip.opacity,
|
|||
|
|
tip.spacing,
|
|||
|
|
self.is_eraser,
|
|||
|
|
if tip.style == BrushStyle::Sketch { Some(&mut self.sketch_history) } else { None },
|
|||
|
|
tip.spray_particle_size,
|
|||
|
|
tip.spray_density,
|
|||
|
|
mask_clone.as_deref(),
|
|||
|
|
self.active_stroke_mask.as_deref_mut(),
|
|||
|
|
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
|||
|
|
);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
|||
|
|
let dirty_r = match tip.style {
|
|||
|
|
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
|||
|
|
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
|||
|
|
_ => tip.size.max(2.0),
|
|||
|
|
};
|
|||
|
|
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
|||
|
|
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
|||
|
|
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
|||
|
|
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
} else {
|
|||
|
|
self.last_stroke_pos = Some((x, y, pressure));
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
draw_brush_stroke(
|
|||
|
|
layer,
|
|||
|
|
&[(x, y, pressure)],
|
|||
|
|
color,
|
|||
|
|
&tip,
|
|||
|
|
self.is_eraser,
|
|||
|
|
mask_clone.as_deref(),
|
|||
|
|
self.active_stroke_mask.as_deref_mut(),
|
|||
|
|
self.stroke_before.as_ref().map(|(_, px, _)| px.as_slice()),
|
|||
|
|
);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
|||
|
|
let dirty_r = tip.size.max(2.0);
|
|||
|
|
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
|||
|
|
self.document.expand_dirty(lx as u32, ly as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
self.document.expand_dirty(x as u32, y as u32, dirty_r);
|
|||
|
|
if let Some((lx, ly, _)) = self.last_stroke_pos {
|
|||
|
|
self.expand_stroke_bounds(lx as u32, ly as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
self.expand_stroke_bounds(x as u32, y as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
self.last_stroke_pos = Some((x, y, pressure));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Pen tool stroke — draws a true anti-aliased line segment.
|
|||
|
|
/// Uses begin_stroke/end_stroke for undo.
|
|||
|
|
pub fn draw_pen_segment(&mut self, layer_id: u64, x0: f32, y0: f32, x1: f32, y1: f32, pressure: f32) {
|
|||
|
|
let tip = self.current_tip.clone();
|
|||
|
|
let color = self.apply_cyclic_color(self.current_color);
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
let w = self.document.canvas_width as f32;
|
|||
|
|
let h = self.document.canvas_height as f32;
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
let px_color = [color[0], color[1], color[2], (color[3] as f32 * tip.opacity * pressure).round() as u8];
|
|||
|
|
draw_line(layer, x0, y0, x1, y1, px_color, tip.size, mask_clone.as_deref());
|
|||
|
|
layer.dirty = true;
|
|||
|
|
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
|||
|
|
let dirty_r = tip.size.max(2.0);
|
|||
|
|
if x0 >= 0.0 && y0 >= 0.0 && x0 < w && y0 < h {
|
|||
|
|
self.document.expand_dirty(x0 as u32, y0 as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
if x1 >= 0.0 && y1 >= 0.0 && x1 < w && y1 < h {
|
|||
|
|
self.document.expand_dirty(x1 as u32, y1 as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Expand the stroke bounds to include the given point with radius.
|
|||
|
|
fn expand_stroke_bounds(&mut self, x: u32, y: u32, radius: f32) {
|
|||
|
|
let r = (radius as i32).max(1);
|
|||
|
|
let x0 = (x as i32 - r).max(0) as u32;
|
|||
|
|
let y0 = (y as i32 - r).max(0) as u32;
|
|||
|
|
let x1 = ((x as i32 + r + 1).min(self.document.canvas_width as i32 - 1)).max(0) as u32;
|
|||
|
|
let y1 = ((y as i32 + r + 1).min(self.document.canvas_height as i32 - 1)).max(0) as u32;
|
|||
|
|
match &mut self.last_stroke_bounds {
|
|||
|
|
Some(b) => {
|
|||
|
|
b[0] = b[0].min(x0);
|
|||
|
|
b[1] = b[1].min(y0);
|
|||
|
|
b[2] = b[2].max(x1);
|
|||
|
|
b[3] = b[3].max(y1);
|
|||
|
|
}
|
|||
|
|
None => {
|
|||
|
|
self.last_stroke_bounds = Some([x0, y0, x1, y1]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// End the current brush stroke and commit a sub-rect undo snapshot.
|
|||
|
|
///
|
|||
|
|
/// Uses `last_stroke_bounds` to extract only the changed region (~100KB on a
|
|||
|
|
/// 4K canvas with a typical brush) instead of cloning the entire 33MB layer.
|
|||
|
|
/// The full `stroke_before` buffer is still used for draw_brush_stroke
|
|||
|
|
/// reference, but the history snapshot is sub-rect.
|
|||
|
|
pub fn end_stroke(&mut self, layer_id: u64) {
|
|||
|
|
log::trace!(
|
|||
|
|
"[end_stroke] layer_id={}, stroke_before={}, below_cache={}, below_cache_active_idx={:?}, last_stroke_bounds={:?}",
|
|||
|
|
layer_id,
|
|||
|
|
self.stroke_before.is_some(),
|
|||
|
|
self.below_cache.is_some(),
|
|||
|
|
self.below_cache_active_idx,
|
|||
|
|
self.last_stroke_bounds
|
|||
|
|
);
|
|||
|
|
if let Some((id, before, before_shapes)) = self.stroke_before.take() {
|
|||
|
|
if id == layer_id {
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
|||
|
|
if let Some([sx0, sy0, sx1, sy1]) = self.last_stroke_bounds {
|
|||
|
|
if sx0 < sx1 && sy0 < sy1 {
|
|||
|
|
let layer = &self.document.layers[idx];
|
|||
|
|
let lw = layer.width;
|
|||
|
|
let rw = sx1 - sx0;
|
|||
|
|
let rh = sy1 - sy0;
|
|||
|
|
let rect_size = (rw * rh * 4) as usize;
|
|||
|
|
let mut before_rect = vec![0u8; rect_size];
|
|||
|
|
let mut after_rect = vec![0u8; rect_size];
|
|||
|
|
for row in 0..rh {
|
|||
|
|
let src_start = (((sy0 + row) * lw + sx0) * 4) as usize;
|
|||
|
|
let dst_start = (row * rw * 4) as usize;
|
|||
|
|
let len = (rw * 4) as usize;
|
|||
|
|
before_rect[dst_start..dst_start + len]
|
|||
|
|
.copy_from_slice(&before[src_start..src_start + len]);
|
|||
|
|
after_rect[dst_start..dst_start + len]
|
|||
|
|
.copy_from_slice(&layer.pixels[src_start..src_start + len]);
|
|||
|
|
}
|
|||
|
|
self.document.push_draw_snapshot_subrect(
|
|||
|
|
idx, before_rect, after_rect,
|
|||
|
|
(sx0, sy0, sx1, sy1),
|
|||
|
|
"Brush Stroke".to_string(),
|
|||
|
|
);
|
|||
|
|
// Stroke changed layer.pixels — invalidate effects backup so
|
|||
|
|
// the next style edit captures the post-stroke raw pixels.
|
|||
|
|
self.raw_pixel_backup.remove(&layer_id);
|
|||
|
|
self.last_stroke_bounds = None;
|
|||
|
|
self.last_stroke_pos = None;
|
|||
|
|
if let Some(ref mut cache) = self.below_cache {
|
|||
|
|
cache.fill(0);
|
|||
|
|
}
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
log::trace!("[end_stroke] sub-rect snapshot done, below_cache cleared");
|
|||
|
|
if let Some(mask) = &mut self.active_stroke_mask {
|
|||
|
|
mask.fill(0);
|
|||
|
|
}
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
self.document.push_draw_snapshot(
|
|||
|
|
idx, before, before_shapes,
|
|||
|
|
"Brush Stroke".to_string(),
|
|||
|
|
);
|
|||
|
|
// Stroke changed layer.pixels — invalidate effects backup.
|
|||
|
|
self.raw_pixel_backup.remove(&layer_id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
self.last_stroke_pos = None;
|
|||
|
|
self.last_stroke_bounds = None;
|
|||
|
|
if let Some(ref mut cache) = self.below_cache {
|
|||
|
|
cache.fill(0);
|
|||
|
|
}
|
|||
|
|
self.below_cache_active_idx = None;
|
|||
|
|
log::trace!("[end_stroke] fallback cleanup done, below_cache_active_idx=None");
|
|||
|
|
if let Some(mask) = &mut self.active_stroke_mask {
|
|||
|
|
mask.fill(0);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Update hue for cyclic color and return the new RGBA color.
|
|||
|
|
fn apply_cyclic_color(&mut self, base_color: [u8; 4]) -> [u8; 4] {
|
|||
|
|
if !self.cyclic_color {
|
|||
|
|
return base_color;
|
|||
|
|
}
|
|||
|
|
self.pen_hue = (self.pen_hue + self.cyclic_speed) % 360.0;
|
|||
|
|
let (r, g, b) = Self::hsl_to_rgb(self.pen_hue);
|
|||
|
|
[r, g, b, base_color[3]]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Simple HSL to RGB: S=100%, L=50% (full saturation, half luminance)
|
|||
|
|
fn hsl_to_rgb(h: f32) -> (u8, u8, u8) {
|
|||
|
|
let h = h % 360.0;
|
|||
|
|
let c = 1.0; // S=100%
|
|||
|
|
let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs());
|
|||
|
|
let m = 0.0; // L=50%, c=1.0 => m=0
|
|||
|
|
let (r, g, b) = if h < 60.0 {
|
|||
|
|
(c, x, 0.0)
|
|||
|
|
} else if h < 120.0 {
|
|||
|
|
(x, c, 0.0)
|
|||
|
|
} else if h < 180.0 {
|
|||
|
|
(0.0, c, x)
|
|||
|
|
} else if h < 240.0 {
|
|||
|
|
(0.0, x, c)
|
|||
|
|
} else if h < 300.0 {
|
|||
|
|
(x, 0.0, c)
|
|||
|
|
} else {
|
|||
|
|
(c, 0.0, x)
|
|||
|
|
};
|
|||
|
|
(
|
|||
|
|
((r + m) * 255.0).round() as u8,
|
|||
|
|
((g + m) * 255.0).round() as u8,
|
|||
|
|
((b + m) * 255.0).round() as u8,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn draw_stroke(&mut self, points: &[(f32, f32, f32)]) {
|
|||
|
|
let color = self.apply_cyclic_color(self.current_color);
|
|||
|
|
let tip = self.current_tip.clone();
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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() {
|
|||
|
|
if Self::is_specialized_style(tip.style) {
|
|||
|
|
draw_specialized_stroke(
|
|||
|
|
&mut layer.pixels,
|
|||
|
|
layer.width,
|
|||
|
|
layer.height,
|
|||
|
|
points,
|
|||
|
|
tip.style,
|
|||
|
|
tip.size,
|
|||
|
|
tip.hardness,
|
|||
|
|
color,
|
|||
|
|
tip.opacity,
|
|||
|
|
tip.spacing,
|
|||
|
|
self.is_eraser,
|
|||
|
|
if tip.style == BrushStyle::Sketch { Some(&mut self.sketch_history) } else { None },
|
|||
|
|
tip.spray_particle_size,
|
|||
|
|
tip.spray_density,
|
|||
|
|
mask_clone.as_deref(),
|
|||
|
|
self.active_stroke_mask.as_deref_mut(),
|
|||
|
|
before_pixels.as_ref().map(|px| px.as_slice()),
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
draw_brush_stroke(
|
|||
|
|
layer,
|
|||
|
|
points,
|
|||
|
|
color,
|
|||
|
|
&tip,
|
|||
|
|
self.is_eraser,
|
|||
|
|
mask_clone.as_deref(),
|
|||
|
|
self.active_stroke_mask.as_deref_mut(),
|
|||
|
|
before_pixels.as_ref().map(|px| px.as_slice()),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
let dirty_r = match tip.style {
|
|||
|
|
BrushStyle::Star | BrushStyle::Spray => tip.size * 3.2,
|
|||
|
|
BrushStyle::Clouds | BrushStyle::Tree => tip.size * 2.0,
|
|||
|
|
_ => tip.size.max(2.0),
|
|||
|
|
};
|
|||
|
|
for &(px, py, _) in points {
|
|||
|
|
if px >= 0.0 && py >= 0.0 && px < self.document.canvas_width as f32 && py < self.document.canvas_height as f32 {
|
|||
|
|
self.document.expand_dirty(px as u32, py as u32, dirty_r);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(
|
|||
|
|
layer_idx, before, None,
|
|||
|
|
"Brush Stroke".to_string(),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn draw_line(&mut self, x0: f32, y0: f32, x1: f32, y1: f32) {
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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() {
|
|||
|
|
let color = [255, 0, 0, 255];
|
|||
|
|
draw_line(layer, x0, y0, x1, y1, color, 2.0, mask_clone.as_deref());
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(layer_idx, before, None, "Line".to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn draw_rect(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) {
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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() {
|
|||
|
|
let color = [255, 0, 0, 255];
|
|||
|
|
draw_filled_rect(layer, x1, y1, x2, y2, color, mask_clone.as_deref());
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(layer_idx, before, None, "Rectangle".to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn draw_filled_rect_rgba(&mut self, x1: u32, y1: u32, x2: u32, y2: u32, color: [u8; 4]) {
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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() {
|
|||
|
|
let fx1 = x1.min(x2) as f32;
|
|||
|
|
let fy1 = y1.min(y2) as f32;
|
|||
|
|
let fx2 = x2.max(x1) as f32;
|
|||
|
|
let fy2 = y2.max(y1) as f32;
|
|||
|
|
draw_filled_rect(layer, fx1, fy1, fx2, fy2, color, mask_clone.as_deref());
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(layer_idx, before, None, "Fill Rect".to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn draw_ellipse(&mut self, cx: f32, cy: f32, rx: f32, ry: f32) {
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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() {
|
|||
|
|
let color = [255, 0, 0, 255];
|
|||
|
|
draw_filled_ellipse(layer, cx, cy, rx, ry, color, mask_clone.as_deref());
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(layer_idx, before, None, "Ellipse".to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn flood_fill(&mut self, x: u32, y: u32, color: [u8; 4], tolerance: u8) {
|
|||
|
|
let mask_clone = self.document.selection_mask.clone();
|
|||
|
|
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_clone.as_deref());
|
|||
|
|
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());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Selection Ops ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn create_selection_rect(&mut self, x1: u32, y1: u32, x2: u32, y2: u32) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.selection_mask = Some(hcie_selection::create_rect_mask(w, h, x1, y1, x2, y2));
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn create_selection_ellipse(&mut self, cx: u32, cy: u32, rx: u32, ry: u32) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.selection_mask = Some(hcie_selection::create_ellipse_mask(w, h, cx, cy, rx, ry));
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_grow(&mut self, px: u32) {
|
|||
|
|
if let Some(ref mut mask) = self.document.selection_mask {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
hcie_selection::grow_mask(mask, w, h, px);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_shrink(&mut self, px: u32) {
|
|||
|
|
if let Some(ref mut mask) = self.document.selection_mask {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
hcie_selection::shrink_mask(mask, w, h, px);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_feather(&mut self, radius: f32) {
|
|||
|
|
if let Some(ref mut mask) = self.document.selection_mask {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
hcie_selection::feather_mask(mask, w, h, radius);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_invert(&mut self) {
|
|||
|
|
if let Some(ref mut mask) = self.document.selection_mask {
|
|||
|
|
hcie_selection::invert_mask(mask);
|
|||
|
|
} else {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
let mut mask = vec![255u8; (w * h) as usize];
|
|||
|
|
hcie_selection::invert_mask(&mut mask);
|
|||
|
|
self.document.selection_mask = Some(mask);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_clear(&mut self) {
|
|||
|
|
self.document.selection_mask = None;
|
|||
|
|
self.document.selection_active = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn selection_all(&mut self) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
self.document.selection_mask = Some(vec![255u8; (w * h) as usize]);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn get_selection_mask(&self) -> Option<&[u8]> {
|
|||
|
|
self.document.selection_mask.as_deref()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_selection_mask(&mut self, mask: Vec<u8>) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
if mask.len() == (w * h) as usize {
|
|||
|
|
self.document.selection_mask = Some(mask);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Create a freehand lasso selection from the given point sequence.
|
|||
|
|
///
|
|||
|
|
/// **Purpose:** Fills the canvas selection mask with all pixels inside the
|
|||
|
|
/// polygon formed by `points` using ray-casting (even-odd rule).
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `points`: canvas-coordinate vertices collected during the drag.
|
|||
|
|
pub fn create_selection_lasso(&mut self, points: &[(u32, u32)]) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
let mut mask = vec![0u8; (w * h) as usize];
|
|||
|
|
if points.len() >= 3 {
|
|||
|
|
hcie_selection::lasso_fill_mask(&mut mask, w, h, points);
|
|||
|
|
}
|
|||
|
|
self.document.selection_mask = Some(mask);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
log::trace!("create_selection_lasso: {} points, mask size {}x{}", points.len(), w, h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Create a polygon selection from explicitly clicked vertices.
|
|||
|
|
///
|
|||
|
|
/// **Purpose:** Same ray-casting algorithm as lasso, but the vertex list is
|
|||
|
|
/// built by discrete clicks (PolygonSelect tool) rather than continuous drag.
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `points`: canvas-coordinate polygon vertices.
|
|||
|
|
pub fn create_selection_polygon(&mut self, points: &[(u32, u32)]) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
let mut mask = vec![0u8; (w * h) as usize];
|
|||
|
|
if points.len() >= 3 {
|
|||
|
|
hcie_selection::lasso_fill_mask(&mut mask, w, h, points);
|
|||
|
|
}
|
|||
|
|
self.document.selection_mask = Some(mask);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
log::trace!("create_selection_polygon: {} vertices, mask size {}x{}", points.len(), w, h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Magic Wand selection — selects contiguous pixels similar in colour to the seed point.
|
|||
|
|
///
|
|||
|
|
/// **Purpose:** BFS flood-fill on the active layer's pixel data. Pixels whose
|
|||
|
|
/// RGBA delta from the seed is ≤ `tolerance` on every channel are selected.
|
|||
|
|
///
|
|||
|
|
/// **Logic & Workflow:**
|
|||
|
|
/// 1. Fetch the active layer's pixel buffer.
|
|||
|
|
/// 2. Delegate to `hcie_selection::magic_wand_mask` for the BFS computation.
|
|||
|
|
/// 3. Store the resulting mask and set `selection_active = true`.
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `seed_x`, `seed_y`: clicked canvas coordinates.
|
|||
|
|
/// - `tolerance`: 0–255 channel delta threshold.
|
|||
|
|
pub fn create_selection_magic_wand(&mut self, seed_x: u32, seed_y: u32, tolerance: u8) {
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
let pixels = if let Some(layer) = self.document.active_layer() {
|
|||
|
|
layer.pixels.clone()
|
|||
|
|
} else {
|
|||
|
|
log::warn!("create_selection_magic_wand: no active layer");
|
|||
|
|
return;
|
|||
|
|
};
|
|||
|
|
log::trace!(
|
|||
|
|
"create_selection_magic_wand: seed=({},{}) tol={} canvas={}x{}",
|
|||
|
|
seed_x, seed_y, tolerance, w, h
|
|||
|
|
);
|
|||
|
|
let mask = hcie_selection::magic_wand_mask(&pixels, w, h, seed_x, seed_y, tolerance);
|
|||
|
|
self.document.selection_mask = Some(mask);
|
|||
|
|
self.document.selection_active = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Returns whether a selection mask is currently active.
|
|||
|
|
pub fn is_selection_active(&self) -> bool {
|
|||
|
|
self.document.selection_active
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Transform Ops ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn crop(&mut self, x: u32, y: u32, w: u32, h: u32) {
|
|||
|
|
self.document.crop(x, y, w, h);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn resize_canvas(&mut self, w: u32, h: u32) {
|
|||
|
|
self.document.resize_canvas(w, h);
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn rotate_layer(&mut self, id: u64, degrees: f32) {
|
|||
|
|
let _ = (id, degrees);
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn flip_layer_horizontal(&mut self, id: u64) {
|
|||
|
|
let _ = id;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn flip_layer_vertical(&mut self, id: u64) {
|
|||
|
|
let _ = id;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Filter Ops ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn apply_filter(&mut self, filter_id: &str, params: serde_json::Value) {
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
let before_pixels = layer.pixels.clone();
|
|||
|
|
let _ = apply_filter(filter_id, ¶ms, &mut layer.pixels, layer.width, layer.height);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.push_draw_snapshot(
|
|||
|
|
self.document.active_layer,
|
|||
|
|
before_pixels,
|
|||
|
|
None,
|
|||
|
|
format!("Filter: {}", filter_id),
|
|||
|
|
);
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn apply_filter_preview(&mut self, filter_id: &str, params: serde_json::Value) {
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
let _ = apply_filter(filter_id, ¶ms, &mut layer.pixels, layer.width, layer.height);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn filter_preview_begin(&mut self) {
|
|||
|
|
if let Some(layer) = self.document.active_layer() {
|
|||
|
|
self.filter_preview_original = Some(layer.pixels.clone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn filter_preview_restore(&mut self) {
|
|||
|
|
if let Some(ref original) = self.filter_preview_original.take() {
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
layer.pixels.clone_from(original);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn has_filter_preview(&self) -> bool {
|
|||
|
|
self.filter_preview_original.is_some()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn reset_tool(&mut self) {
|
|||
|
|
// Reset brush tip and color to factory defaults
|
|||
|
|
self.current_tip = BrushTip::default();
|
|||
|
|
self.current_color = [0, 0, 0, 255];
|
|||
|
|
self.pen_hue = 0.0;
|
|||
|
|
self.cyclic_color = false;
|
|||
|
|
self.cyclic_speed = 0.5;
|
|||
|
|
self.sketch_history.clear();
|
|||
|
|
self.last_stroke_pos = None;
|
|||
|
|
log::info!("Tool state reset to defaults");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Align a layer to a canvas edge or center axis.
|
|||
|
|
/// Only affects raster layers (shifts pixel data).
|
|||
|
|
pub fn align_layer(&mut self, id: u64, axis: AlignAxis) {
|
|||
|
|
let Some(layer) = self.document.get_layer_by_id(id) else { return };
|
|||
|
|
if layer.layer_type != LayerType::Raster {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
let (lw, lh, cw, ch) = (layer.width as i32, layer.height as i32,
|
|||
|
|
self.document.canvas_width as i32,
|
|||
|
|
self.document.canvas_height as i32);
|
|||
|
|
let (target_x, target_y) = match axis {
|
|||
|
|
AlignAxis::Left => (0i32, (ch - lh) / 2),
|
|||
|
|
AlignAxis::Right => (cw - lw, (ch - lh) / 2),
|
|||
|
|
AlignAxis::Top => ((cw - lw) / 2, 0i32),
|
|||
|
|
AlignAxis::Bottom => ((cw - lw) / 2, ch - lh),
|
|||
|
|
AlignAxis::HCenter => ((cw - lw) / 2, (ch - lh) / 2),
|
|||
|
|
AlignAxis::VCenter => ((cw - lw) / 2, (ch - lh) / 2),
|
|||
|
|
};
|
|||
|
|
// Clamp so we never start from negative
|
|||
|
|
let target_x = target_x.max(0);
|
|||
|
|
let target_y = target_y.max(0);
|
|||
|
|
|
|||
|
|
// Current layer offset isn't tracked separately in V3 (always starts at 0,0).
|
|||
|
|
// We treat the layer as already at (0,0) and only shift if target != 0.
|
|||
|
|
// If target_x or target_y is > 0, we need to shift existing pixels.
|
|||
|
|
// For simplicity: build a new transparent canvas-sized buffer at target position.
|
|||
|
|
let cw_u = self.document.canvas_width;
|
|||
|
|
let ch_u = self.document.canvas_height;
|
|||
|
|
let mut new_pixels = vec![0u8; (cw_u * ch_u * 4) as usize];
|
|||
|
|
if let Some(l) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
// Copy existing pixels into new buffer at target offset
|
|||
|
|
for ly in 0..lh.min(ch_u as i32) {
|
|||
|
|
for lx in 0..lw.min(cw_u as i32) {
|
|||
|
|
let src_i = ((ly as u32) * l.width + (lx as u32)) as usize * 4;
|
|||
|
|
let dst_x = (target_x + lx) as u32;
|
|||
|
|
let dst_y = (target_y + ly) as u32;
|
|||
|
|
if dst_x < cw_u && dst_y < ch_u {
|
|||
|
|
let dst_i = ((dst_y * cw_u + dst_x) as usize) * 4;
|
|||
|
|
new_pixels[dst_i..dst_i + 4].copy_from_slice(
|
|||
|
|
&l.pixels[src_i..src_i + 4],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Update layer dimensions & pixels
|
|||
|
|
l.width = cw_u;
|
|||
|
|
l.height = ch_u;
|
|||
|
|
l.pixels = new_pixels;
|
|||
|
|
l.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn align_active_layer(&mut self, axis: AlignAxis) {
|
|||
|
|
let id = self.document.active_layer().map(|l| l.id).unwrap_or(0);
|
|||
|
|
if id > 0 {
|
|||
|
|
self.align_layer(id, axis);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Text Ops ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn add_text(&mut self, text: &str, font: &str, size: f32, _x: f32, _y: f32, color: [u8; 4]) {
|
|||
|
|
let mut renderer = self.text_renderer.lock().unwrap();
|
|||
|
|
match renderer.create_text_layer(text, font, size, color) {
|
|||
|
|
Ok(mut layer) => {
|
|||
|
|
layer.layer_type = LayerType::Text;
|
|||
|
|
self.document.layers.push(layer);
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
Err(e) => {
|
|||
|
|
log::error!("Text creation failed: {}", e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Vector Ops ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn add_vector_shape(&mut self, shape: VectorShape) {
|
|||
|
|
// If active layer is already Vector, add shape there
|
|||
|
|
let layer_idx = self.document.active_layer;
|
|||
|
|
if layer_idx < self.document.layers.len() {
|
|||
|
|
if let LayerData::Vector { .. } = &self.document.layers[layer_idx].data {
|
|||
|
|
// Already a vector layer — add shape and let get_composite_pixels re-render
|
|||
|
|
let before_shapes = if let LayerData::Vector { shapes } = &self.document.layers[layer_idx].data {
|
|||
|
|
Some(shapes.clone())
|
|||
|
|
} else {
|
|||
|
|
None
|
|||
|
|
};
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
shapes.push(shape);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
self.document.push_vector_snapshot(layer_idx, before_shapes, "Add Vector Shape".to_string());
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// Active layer is NOT vector — create a NEW vector layer (matching V2 behavior)
|
|||
|
|
let new_id = self.document.add_layer(&format!("Vector {}", self.document.layers.len()));
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(new_id) {
|
|||
|
|
// Set as vector layer
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(new_id) {
|
|||
|
|
layer.data = LayerData::Vector { shapes: vec![shape] };
|
|||
|
|
layer.layer_type = LayerType::Vector;
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
self.document.set_active_layer(idx);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn get_svg_source(&self, layer_id: u64) -> Option<&[u8]> {
|
|||
|
|
self.svg_sources.get(&layer_id).map(|v| v.as_slice())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_layer_pixels(&mut self, layer_id: u64, pixels: Vec<u8>) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
layer.pixels = pixels;
|
|||
|
|
layer.dirty = false;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(idx) = self.document.layer_index_by_id(layer_id) {
|
|||
|
|
if idx < self.tile_layers.len() {
|
|||
|
|
let layer = &self.document.layers[idx];
|
|||
|
|
self.tile_layers[idx] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn modify_vector_shape(&mut self, layer_id: u64, shape_idx: usize, handle: VectorEditHandle, dx: f32, dy: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.apply_handle(handle, dx, dy, None);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_color(color);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_fill(&mut self, layer_id: u64, shape_idx: usize, fill: bool) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
if let Some(f) = shape.get_fill_mut() {
|
|||
|
|
*f = fill;
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_fill_color(&mut self, layer_id: u64, shape_idx: usize, color: [u8; 4]) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
if let Some(c) = shape.get_fill_color_mut() {
|
|||
|
|
*c = color;
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_stroke(&mut self, layer_id: u64, shape_idx: usize, stroke: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_stroke(stroke);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_opacity(&mut self, layer_id: u64, shape_idx: usize, opacity: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_shape_opacity(opacity);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_bounds(&mut self, layer_id: u64, shape_idx: usize, x1: f32, y1: f32, x2: f32, y2: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_bounds(x1, y1, x2, y2);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_hardness(&mut self, layer_id: u64, shape_idx: usize, hardness: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_hardness(hardness);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_line_caps(&mut self, layer_id: u64, shape_idx: usize, cap_start: LineCap, cap_end: LineCap) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
if let VectorShape::Line { cap_start: cs, cap_end: ce, .. } = shape {
|
|||
|
|
*cs = cap_start;
|
|||
|
|
*ce = cap_end;
|
|||
|
|
}
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn active_vector_shapes(&self) -> Option<Vec<VectorShape>> {
|
|||
|
|
if let Some(layer) = self.document.active_layer() {
|
|||
|
|
if let LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
return Some(shapes.clone());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Find the index of the first vector shape that contains point (px, py).
|
|||
|
|
/// Returns None if no shape is hit.
|
|||
|
|
pub fn find_vector_shape_at(&self, px: f32, py: f32) -> Option<usize> {
|
|||
|
|
if let Some(shapes) = self.active_vector_shapes() {
|
|||
|
|
for (i, shape) in shapes.iter().enumerate() {
|
|||
|
|
let (left, top, right, bottom) = shape.normalized_bounds();
|
|||
|
|
if px >= left && px <= right && py >= top && py <= bottom {
|
|||
|
|
return Some(i);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn get_vector_shape_center(&self, layer_id: u64, shape_idx: usize) -> Option<(f32, f32)> {
|
|||
|
|
let layer = self.document.get_layer_by_id(layer_id)?;
|
|||
|
|
if let LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
if let Some(shape) = shapes.get(shape_idx) {
|
|||
|
|
let (x1, y1, x2, y2) = shape.normalized_bounds();
|
|||
|
|
return Some(((x1 + x2) / 2.0, (y1 + y2) / 2.0));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn vector_shape_angle(&self, layer_id: u64, shape_idx: usize) -> f32 {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id(layer_id) {
|
|||
|
|
if let LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
if let Some(shape) = shapes.get(shape_idx) {
|
|||
|
|
return shape.angle();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
0.0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_vector_shape_angle(&mut self, layer_id: u64, shape_idx: usize, angle: f32) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if let Some(shape) = shapes.get_mut(shape_idx) {
|
|||
|
|
shape.set_angle(angle);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn mark_composite_dirty(&mut self) {
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Get mutable reference to shapes on a vector layer for direct manipulation.
|
|||
|
|
pub fn get_layer_shapes_direct(&mut self, layer_id: u64) -> Option<&mut Vec<VectorShape>> {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
return Some(shapes);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn delete_vector_shape(&mut self, layer_id: u64, shape_idx: usize) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if shape_idx < shapes.len() {
|
|||
|
|
shapes.remove(shape_idx);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn boolean_vector_shapes(&mut self, layer_id: u64, op: BooleanOp, idx_a: usize, idx_b: usize) -> bool {
|
|||
|
|
let layer_idx = match self.document.layer_index_by_id(layer_id) {
|
|||
|
|
Some(i) => i,
|
|||
|
|
None => return false,
|
|||
|
|
};
|
|||
|
|
let before_shapes = if let Some(layer) = self.document.layers.get(layer_idx) {
|
|||
|
|
if let LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
Some(shapes.clone())
|
|||
|
|
} else {
|
|||
|
|
None
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
None
|
|||
|
|
};
|
|||
|
|
if let Some(layer) = self.document.layers.get_mut(layer_idx) {
|
|||
|
|
if let LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if idx_a >= shapes.len() || idx_b >= shapes.len() || idx_a == idx_b {
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
let shape_a = shapes[idx_a].clone();
|
|||
|
|
let shape_b = shapes[idx_b].clone();
|
|||
|
|
if let Some(result) = boolean_shapes(op, &shape_a, &shape_b) {
|
|||
|
|
let max = std::cmp::max(idx_a, idx_b);
|
|||
|
|
let min = std::cmp::min(idx_a, idx_b);
|
|||
|
|
shapes.remove(max);
|
|||
|
|
shapes.remove(min);
|
|||
|
|
shapes.push(result);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
let op_name = match op {
|
|||
|
|
BooleanOp::Union => "Union",
|
|||
|
|
BooleanOp::Intersect => "Intersect",
|
|||
|
|
BooleanOp::Subtract => "Subtract",
|
|||
|
|
BooleanOp::Xor => "Xor",
|
|||
|
|
};
|
|||
|
|
self.document.push_vector_snapshot(layer_idx, before_shapes, format!("Path Boolean ({})", op_name));
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── File I/O ─────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
/// Çizim başlangıcındaki kiremit dönüşüm lag'ini önlemek için tüm katmanları önceden kiremitler.
|
|||
|
|
pub fn pre_tile_all_layers(&mut self) {
|
|||
|
|
let count = self.document.layers.len();
|
|||
|
|
self.tile_layers.resize_with(count, || None);
|
|||
|
|
let mut built = 0usize;
|
|||
|
|
for (i, layer) in self.document.layers.iter().enumerate() {
|
|||
|
|
if !layer.pixels.is_empty() && self.tile_layers[i].is_none() {
|
|||
|
|
self.tile_layers[i] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
|||
|
|
built += 1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
log::trace!(
|
|||
|
|
"[pre_tile_all_layers] built {} new tiles / {} total layers, tile_layers_len={}",
|
|||
|
|
built, count, self.tile_layers.len()
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// ── BUFFER & THREADPOOL WARMING ──────────────────────────────
|
|||
|
|
// 4K çözünürlükteki ilk fırça darbesinde bellek tahsisi ve Rayon
|
|||
|
|
// iş parçacıklarının (threadpool) tembel başlatılmasından doğan
|
|||
|
|
// gecikmeleri (100-300ms) tamamen ortadan kaldırmak için, bu yükü
|
|||
|
|
// dosya yükleme (cold path) veya boyut değiştirme aşamasına kaydırıyoruz.
|
|||
|
|
let cw = self.document.canvas_width;
|
|||
|
|
let ch = self.document.canvas_height;
|
|||
|
|
let buf_size = (cw * ch * 4) as usize;
|
|||
|
|
let mask_size = (cw * ch) as usize;
|
|||
|
|
|
|||
|
|
// 1. composite_scratch tamponunu önceden tahsis et (~33.18 MB)
|
|||
|
|
if self.composite_scratch.as_ref().map_or(true, |b| b.len() != buf_size) {
|
|||
|
|
self.composite_scratch = Some(vec![0u8; buf_size]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 2. below_cache tamponunu önceden tahsis et (~33.18 MB)
|
|||
|
|
if self.below_cache.as_ref().map_or(true, |b| b.len() != buf_size) {
|
|||
|
|
self.below_cache = Some(vec![0u8; buf_size]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. active_stroke_mask tamponunu önceden tahsis et (~8.3 MB)
|
|||
|
|
if self.active_stroke_mask.as_ref().map_or(true, |b| b.len() != mask_size) {
|
|||
|
|
self.active_stroke_mask = Some(vec![0u8; mask_size]);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. Rayon global threadpool'unu ısıt (iş parçacıklarını ayaklandır)
|
|||
|
|
let mut dummy = [0u8; 64];
|
|||
|
|
use rayon::prelude::*;
|
|||
|
|
dummy.par_iter_mut().for_each(|x| *x = 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn open_image(&mut self, path: &str) -> Result<(), String> {
|
|||
|
|
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;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
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);
|
|||
|
|
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;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
self.pre_tile_all_layers();
|
|||
|
|
Ok(())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
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()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// **Purpose:**
|
|||
|
|
/// Computes the flat composite RGBA pixel buffer of all layers in the document.
|
|||
|
|
///
|
|||
|
|
/// **Logic & Workflow:**
|
|||
|
|
/// 1. Iterates over all document layers and renders vector shapes for dirty vector layers.
|
|||
|
|
/// 2. Iterates over all document layers and applies layer effects/styles if they are dirty, caching the result.
|
|||
|
|
/// 3. Synchronizes the tile cache (`self.tile_layers`) for any dirty layers by calling `self.sync_dirty_tiles()`. This prevents the tile cache from becoming stale when dirty flags are subsequently cleared.
|
|||
|
|
/// 4. Resets the document's and layers' dirty tracking flags.
|
|||
|
|
/// 5. Blends/composites all layers (dense pixel paths) into a flat caller-allocated buffer using Rayon-parallelized composition.
|
|||
|
|
///
|
|||
|
|
/// **Arguments:**
|
|||
|
|
/// - `&mut self` — Mutable reference to the Engine state.
|
|||
|
|
///
|
|||
|
|
/// **Returns:**
|
|||
|
|
/// - `Vec<u8>` — A flat vector of RGBA bytes (`width * height * 4`) representing the final composite image.
|
|||
|
|
///
|
|||
|
|
/// **Side Effects / Dependencies:**
|
|||
|
|
/// - Modifies layer pixels (renders vector shapes).
|
|||
|
|
/// - Modifies layer effects caches.
|
|||
|
|
/// - Synchronizes/rebuilds `self.tile_layers` for any dirty layers.
|
|||
|
|
/// - Clears dirty flags on the document and all layers.
|
|||
|
|
pub fn get_composite_pixels(&mut self) -> Vec<u8> {
|
|||
|
|
log::trace!("[get_composite_pixels] ===== START =====");
|
|||
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|||
|
|
log::trace!(
|
|||
|
|
"[get_composite_pixels] layer[{}] id={} name='{}' visible={} dirty={} opacity={} blend={:?}",
|
|||
|
|
i, l.id, l.name, l.visible, l.dirty, l.opacity, l.blend_mode
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
for layer in &mut self.document.layers {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
if !shapes.is_empty() && layer.dirty {
|
|||
|
|
layer.pixels.fill(0);
|
|||
|
|
render_vector_shapes(layer);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// ── Effects pipeline (two-pass) ─────────────────────────────────────────────
|
|||
|
|
// Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
|
|||
|
|
// have a backup. This must happen BEFORE pass 2 so that PSD-loaded layers
|
|||
|
|
// (whose pixels are still untouched) are captured before apply_layer_effects
|
|||
|
|
// overwrites layer.pixels with the rendered buffer.
|
|||
|
|
{
|
|||
|
|
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self.document.layers.iter()
|
|||
|
|
.filter(|l| {
|
|||
|
|
(!l.effects.is_empty() || !l.styles.is_empty())
|
|||
|
|
&& !l.pixels.is_empty()
|
|||
|
|
&& !self.raw_pixel_backup.contains_key(&l.id)
|
|||
|
|
})
|
|||
|
|
.map(|l| (l.id, l.pixels.clone()))
|
|||
|
|
.collect();
|
|||
|
|
for (id, pixels) in ids_needing_backup {
|
|||
|
|
self.raw_pixel_backup.insert(id, pixels);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pass 2: Apply layer effects. Restores from backup first so that each
|
|||
|
|
// slider edit always applies on top of the original untouched pixels.
|
|||
|
|
for layer in &mut self.document.layers {
|
|||
|
|
if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
|
|||
|
|
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
|
|||
|
|
// Restore original (pre-effects) pixels from backup before re-applying.
|
|||
|
|
// This prevents stacking: each edit applies on the untouched source.
|
|||
|
|
if let Some(raw) = self.raw_pixel_backup.get(&layer.id) {
|
|||
|
|
if raw.len() == layer.pixels.len() {
|
|||
|
|
layer.pixels.copy_from_slice(raw);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let mut effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter()
|
|||
|
|
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
|
|||
|
|
.collect();
|
|||
|
|
effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
|||
|
|
if effects.is_empty() { continue; }
|
|||
|
|
let processed = hcie_fx::apply_layer_effects(
|
|||
|
|
&layer.pixels,
|
|||
|
|
layer.width,
|
|||
|
|
layer.height,
|
|||
|
|
&effects,
|
|||
|
|
layer.fill_opacity,
|
|||
|
|
);
|
|||
|
|
// Write into effects_cache for non-tiled compositing path
|
|||
|
|
*layer.effects_cache.lock().unwrap() = Some(hcie_protocol::LayerEffects {
|
|||
|
|
rendered: processed.clone(),
|
|||
|
|
width: layer.width,
|
|||
|
|
height: layer.height,
|
|||
|
|
});
|
|||
|
|
// Also propagate effects-rendered pixels into layer.pixels so that
|
|||
|
|
// sync_dirty_tiles() populates tile storage with the effects-applied buffer,
|
|||
|
|
// ensuring composite_tiled_into renders layer styles correctly.
|
|||
|
|
layer.pixels = processed;
|
|||
|
|
layer.dirty = true;
|
|||
|
|
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Force synchronization of the tile cache for any dirty layers before we clear the dirty flags.
|
|||
|
|
// This ensures the tiled cache remains consistent with the dense layer pixels for subsequent strokes.
|
|||
|
|
self.sync_dirty_tiles();
|
|||
|
|
|
|||
|
|
self.document.clear_dirty();
|
|||
|
|
let output = composite_layers(
|
|||
|
|
&self.document.layers,
|
|||
|
|
self.document.canvas_width,
|
|||
|
|
self.document.canvas_height,
|
|||
|
|
);
|
|||
|
|
let non_zero_alpha = output.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
|||
|
|
let total_pixels = output.len() / 4;
|
|||
|
|
log::trace!(
|
|||
|
|
"[get_composite_pixels] completed full composite: size={} bytes, non_zero_alpha={}/{} ({}%), first_pixel={:?}",
|
|||
|
|
output.len(),
|
|||
|
|
non_zero_alpha,
|
|||
|
|
total_pixels,
|
|||
|
|
if total_pixels > 0 { non_zero_alpha * 100 / total_pixels } else { 0 },
|
|||
|
|
if output.len() >= 4 { [output[0], output[1], output[2], output[3]] } else { [0; 4] }
|
|||
|
|
);
|
|||
|
|
// Log per-layer pixel content summary
|
|||
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|||
|
|
let opaque_count = l.pixels.iter().skip(3).step_by(4).filter(|&&a| a > 0).count();
|
|||
|
|
let total = l.pixels.len() / 4;
|
|||
|
|
log::trace!(
|
|||
|
|
"[get_composite_pixels] LAYER CONTENT: layer[{}] id={} name='{}' visible={} pixels_total={} opaque_pixels={}/{} ({}%)",
|
|||
|
|
i, l.id, l.name, l.visible, l.pixels.len(),
|
|||
|
|
opaque_count, total,
|
|||
|
|
if total > 0 { opaque_count * 100 / total } else { 0 }
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
output
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn render_composite(&mut self) -> Vec<u8> {
|
|||
|
|
self.get_composite_pixels()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Composite the canvas, downscale to a 256px thumbnail, encode as PNG,
|
|||
|
|
/// and return as base64 string. Used by AI vision to capture the current canvas.
|
|||
|
|
pub fn capture_canvas_png(&mut self) -> Option<String> {
|
|||
|
|
let pixels = self.get_composite_pixels();
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
|
|||
|
|
let scale = 256.0 / (w.max(h) as f32);
|
|||
|
|
let (sw, sh) = if scale < 1.0 {
|
|||
|
|
((w as f32 * scale) as u32, (h as f32 * scale) as u32)
|
|||
|
|
} else {
|
|||
|
|
(w, h)
|
|||
|
|
};
|
|||
|
|
let sw = sw.max(1);
|
|||
|
|
let sh = sh.max(1);
|
|||
|
|
|
|||
|
|
let scaled = if sw != w || sh != h {
|
|||
|
|
let mut out = vec![0u8; (sw * sh * 4) as usize];
|
|||
|
|
for dy in 0..sh {
|
|||
|
|
for dx in 0..sw {
|
|||
|
|
let sx = (dx as f32 * w as f32 / sw as f32) as u32;
|
|||
|
|
let sy = (dy as f32 * h as f32 / sh as f32) as u32;
|
|||
|
|
let src = ((sy * w + sx) * 4) as usize;
|
|||
|
|
let dst = ((dy * sw + dx) * 4) as usize;
|
|||
|
|
if src + 3 < pixels.len() && dst + 3 < out.len() {
|
|||
|
|
out[dst..dst+4].copy_from_slice(&pixels[src..src+4]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
out
|
|||
|
|
} else {
|
|||
|
|
pixels
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
let mut png_bytes = Vec::new();
|
|||
|
|
{
|
|||
|
|
use image::{ImageEncoder, ColorType};
|
|||
|
|
let enc = image::codecs::png::PngEncoder::new(&mut png_bytes);
|
|||
|
|
if enc.write_image(&scaled, sw, sh, ColorType::Rgba8.into()).is_err() {
|
|||
|
|
return None;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Some(base64_encode(&png_bytes))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Composite only the dirty region into a pooled internal scratch buffer.
|
|||
|
|
/// Returns the dirty bounds `[x0, y0, x1, y1]` that were updated,
|
|||
|
|
/// or `None` if nothing was dirty (full composite needed).
|
|||
|
|
///
|
|||
|
|
/// The scratch buffer pointer is returned for the pixel-bridge to extract
|
|||
|
|
/// the dirty rect without allocating its own buffer.
|
|||
|
|
pub fn render_composite_region(&mut self) -> (Option<[u32; 4]>, *const u8, usize) {
|
|||
|
|
let has_dirty = self.document.composite_dirty;
|
|||
|
|
let dirty = self.document.dirty_bounds;
|
|||
|
|
if !has_dirty && dirty.is_none() {
|
|||
|
|
log::trace!("[render_composite_region] nothing dirty — returning None");
|
|||
|
|
return (None, std::ptr::null(), 0);
|
|||
|
|
}
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] ===== START composite_dirty={}, dirty_bounds={:?} =====",
|
|||
|
|
has_dirty, dirty
|
|||
|
|
);
|
|||
|
|
let w = self.document.canvas_width;
|
|||
|
|
let h = self.document.canvas_height;
|
|||
|
|
let buf_size = (w * h * 4) as usize;
|
|||
|
|
|
|||
|
|
// Render vector shapes before compositing — only dirty vector layers
|
|||
|
|
for layer in &mut self.document.layers {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { shapes } = &layer.data {
|
|||
|
|
if !shapes.is_empty() && layer.dirty {
|
|||
|
|
layer.pixels.fill(0);
|
|||
|
|
render_vector_shapes(layer);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Effects pipeline (two-pass) ─────────────────────────────────────────────
|
|||
|
|
// Pass 1: Snapshot raw pixels for any effect-bearing layer that does NOT yet
|
|||
|
|
// have a backup. This must happen BEFORE pass 2 so that PSD-loaded layers
|
|||
|
|
// (whose pixels are still untouched) are captured before apply_layer_effects
|
|||
|
|
// overwrites layer.pixels with the rendered buffer.
|
|||
|
|
{
|
|||
|
|
let ids_needing_backup: Vec<(u64, Vec<u8>)> = self.document.layers.iter()
|
|||
|
|
.filter(|l| {
|
|||
|
|
(!l.effects.is_empty() || !l.styles.is_empty())
|
|||
|
|
&& !l.pixels.is_empty()
|
|||
|
|
&& !self.raw_pixel_backup.contains_key(&l.id)
|
|||
|
|
})
|
|||
|
|
.map(|l| (l.id, l.pixels.clone()))
|
|||
|
|
.collect();
|
|||
|
|
for (id, pixels) in ids_needing_backup {
|
|||
|
|
self.raw_pixel_backup.insert(id, pixels);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Pass 2: Apply layer effects. Restores from backup first so that each
|
|||
|
|
// slider edit always applies on top of the original untouched pixels.
|
|||
|
|
for layer in &mut self.document.layers {
|
|||
|
|
if layer.effects.is_empty() && layer.styles.is_empty() { continue; }
|
|||
|
|
if layer.effects_dirty.load(std::sync::atomic::Ordering::Acquire) {
|
|||
|
|
// Restore original (pre-effects) pixels from backup before re-applying.
|
|||
|
|
// This prevents stacking: each edit applies on the untouched source.
|
|||
|
|
if let Some(raw) = self.raw_pixel_backup.get(&layer.id) {
|
|||
|
|
if raw.len() == layer.pixels.len() {
|
|||
|
|
layer.pixels.copy_from_slice(raw);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let mut effects: Vec<hcie_fx::LayerEffect> = layer.effects.iter()
|
|||
|
|
.map(|e| hcie_fx::protocol_to_hcie_fx_effect(e))
|
|||
|
|
.collect();
|
|||
|
|
effects.extend(layer.styles.iter().filter_map(|s| hcie_fx::layer_style_to_effect(s)));
|
|||
|
|
if effects.is_empty() { continue; }
|
|||
|
|
let processed = hcie_fx::apply_layer_effects(
|
|||
|
|
&layer.pixels,
|
|||
|
|
layer.width,
|
|||
|
|
layer.height,
|
|||
|
|
&effects,
|
|||
|
|
layer.fill_opacity,
|
|||
|
|
);
|
|||
|
|
// Write into effects_cache for non-tiled compositing path
|
|||
|
|
*layer.effects_cache.lock().unwrap() = Some(hcie_protocol::LayerEffects {
|
|||
|
|
rendered: processed.clone(),
|
|||
|
|
width: layer.width,
|
|||
|
|
height: layer.height,
|
|||
|
|
});
|
|||
|
|
// Also update layer.pixels with the effects-rendered buffer so that
|
|||
|
|
// sync_dirty_tiles() picks up the correct pixels and composite_tiled_into
|
|||
|
|
// (which checks layer.pixels via tile storage) renders effects correctly.
|
|||
|
|
layer.pixels = processed;
|
|||
|
|
layer.effects_dirty.store(false, std::sync::atomic::Ordering::Release);
|
|||
|
|
// Force dirty so sync_dirty_tiles rebuilds tiles with effects-applied pixels
|
|||
|
|
layer.dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Incremental tile sync: only update tiles overlapping the dirty region
|
|||
|
|
// MUST happen before we borrow composite_scratch to avoid aliasing issues.
|
|||
|
|
self.sync_dirty_tiles();
|
|||
|
|
|
|||
|
|
let (x0, y0, x1, y1) = match dirty {
|
|||
|
|
Some([x0, y0, x1, y1]) => {
|
|||
|
|
(x0.min(w), y0.min(h), x1.min(w), y1.min(h))
|
|||
|
|
}
|
|||
|
|
None => (0, 0, w, h),
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Now safe to borrow composite_scratch — no more mutable calls to self
|
|||
|
|
// that could touch this buffer until we return.
|
|||
|
|
if self.composite_scratch.as_ref().map_or(true, |b| b.len() != buf_size) {
|
|||
|
|
self.composite_scratch = Some(vec![0u8; buf_size]);
|
|||
|
|
}
|
|||
|
|
let buf = self.composite_scratch.as_mut().unwrap();
|
|||
|
|
|
|||
|
|
if x1 > x0 && y1 > y0 {
|
|||
|
|
let wu = w as usize;
|
|||
|
|
let x0u = x0 as usize;
|
|||
|
|
|
|||
|
|
let active_idx = self.document.active_layer;
|
|||
|
|
let cache_valid = self.below_cache.is_some()
|
|||
|
|
&& self.below_cache_active_idx == Some(active_idx)
|
|||
|
|
&& active_idx > 0;
|
|||
|
|
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] BEFORE composite: active_idx={}, cache_valid={}, below_cache={}, below_cache_active_idx={:?}, tile_layers_len={}, dirty_rect=[{},{},{},{}]",
|
|||
|
|
active_idx, cache_valid, self.below_cache.is_some(), self.below_cache_active_idx, self.tile_layers.len(), x0, y0, x1, y1
|
|||
|
|
);
|
|||
|
|
for (i, l) in self.document.layers.iter().enumerate() {
|
|||
|
|
let tc = self.tile_layers.get(i).and_then(|t| t.as_ref().map(|tl| tl.tile_count())).unwrap_or(0);
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] layer[{}] id={} visible={} dirty={} opacity={} blend={:?} tile_count={}",
|
|||
|
|
i, l.id, l.visible, l.dirty, l.opacity, l.blend_mode, tc
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if cache_valid {
|
|||
|
|
let cache = self.below_cache.as_ref().unwrap();
|
|||
|
|
let below_non_zero = cache.iter().filter(|&&b| b != 0).count();
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] CACHE HIT: using below_cache ({} non-zero bytes), compositing layers[{}..{}] on top",
|
|||
|
|
below_non_zero, active_idx, self.document.layers.len()
|
|||
|
|
);
|
|||
|
|
for y in y0..y1 {
|
|||
|
|
let start = (y as usize * wu + x0u) * 4;
|
|||
|
|
let end = start + ((x1 - x0) as usize) * 4;
|
|||
|
|
if end <= cache.len() && end <= buf.len() {
|
|||
|
|
buf[start..end].copy_from_slice(&cache[start..end]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
let tile_start = active_idx.min(self.tile_layers.len());
|
|||
|
|
tiled::composite_tiled_into(
|
|||
|
|
&self.document.layers[active_idx..],
|
|||
|
|
&self.tile_layers[tile_start..],
|
|||
|
|
w, h,
|
|||
|
|
x0, y0, x1, y1,
|
|||
|
|
buf,
|
|||
|
|
);
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] cache hit DONE: {} above layers composited, dirty_rect=[{},{},{},{}]",
|
|||
|
|
self.document.layers.len() - active_idx, x0, y0, x1, y1
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] CACHE MISS: full composite of all {} layers, dirty_rect=[{},{},{},{}]",
|
|||
|
|
self.document.layers.len(), x0, y0, x1, y1
|
|||
|
|
);
|
|||
|
|
for y in y0..y1 {
|
|||
|
|
let start = (y as usize * wu + x0u) * 4;
|
|||
|
|
let end = start + ((x1 - x0) as usize) * 4;
|
|||
|
|
buf[start..end].fill(0);
|
|||
|
|
}
|
|||
|
|
tiled::composite_tiled_into(
|
|||
|
|
&self.document.layers,
|
|||
|
|
&self.tile_layers,
|
|||
|
|
w, h,
|
|||
|
|
x0, y0, x1, y1,
|
|||
|
|
buf,
|
|||
|
|
);
|
|||
|
|
log::trace!(
|
|||
|
|
"[render_composite_region] full composite DONE, dirty_rect=[{},{},{},{}]",
|
|||
|
|
x0, y0, x1, y1
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
self.document.clear_dirty();
|
|||
|
|
let ptr = buf.as_ptr();
|
|||
|
|
(Some([x0, y0, x1, y1]), ptr, buf_size)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Incremental tile cache update.
|
|||
|
|
/// For dirty layers, only re-tile the tiles overlapping the global
|
|||
|
|
/// dirty_bounds region instead of scanning the entire 33MB dense buffer.
|
|||
|
|
/// Falls back to full from_dense() when dirty_bounds is None.
|
|||
|
|
fn sync_dirty_tiles(&mut self) {
|
|||
|
|
let count = self.document.layers.len();
|
|||
|
|
if self.tile_layers.len() < count {
|
|||
|
|
self.tile_layers.resize_with(count, || None);
|
|||
|
|
}
|
|||
|
|
let db = self.document.dirty_bounds;
|
|||
|
|
let mut synced_count = 0usize;
|
|||
|
|
for (i, layer) in self.document.layers.iter().enumerate() {
|
|||
|
|
if !layer.dirty || layer.pixels.is_empty() { continue; }
|
|||
|
|
if let Some([dx0, dy0, dx1, dy1]) = db {
|
|||
|
|
if let Some(ref mut tl) = self.tile_layers[i] {
|
|||
|
|
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
|||
|
|
log::trace!(
|
|||
|
|
"[sync_dirty_tiles] incremental update layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
|||
|
|
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
|||
|
|
);
|
|||
|
|
} else {
|
|||
|
|
let mut tl = TiledLayer::new(layer.width, layer.height);
|
|||
|
|
tl.update_tiles_in_region(&layer.pixels, layer.width, dx0, dy0, dx1, dy1);
|
|||
|
|
self.tile_layers[i] = Some(tl);
|
|||
|
|
log::trace!(
|
|||
|
|
"[sync_dirty_tiles] created new TiledLayer for layer[{}] id={} visible={} dirty_bounds=[{},{},{},{}]",
|
|||
|
|
i, layer.id, layer.visible, dx0, dy0, dx1, dy1
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
self.tile_layers[i] = Some(TiledLayer::from_dense(&layer.pixels, layer.width, layer.height));
|
|||
|
|
log::trace!(
|
|||
|
|
"[sync_dirty_tiles] full rebuild TiledLayer for layer[{}] id={} visible={} (no dirty_bounds)",
|
|||
|
|
i, layer.id, layer.visible
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
synced_count += 1;
|
|||
|
|
}
|
|||
|
|
self.tile_layers.truncate(count);
|
|||
|
|
if synced_count > 0 {
|
|||
|
|
log::trace!("[sync_dirty_tiles] synced {} layers, db={:?}, tile_layers_len={}", synced_count, db, self.tile_layers.len());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
pub fn is_composite_dirty(&self) -> bool {
|
|||
|
|
self.document.composite_dirty
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn clear_dirty_flags(&mut self) {
|
|||
|
|
self.document.clear_dirty();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── AI Ops ───────────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
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_clone = self.document.selection_mask.clone();
|
|||
|
|
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_clone.as_deref());
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
if let Some(before) = before_pixels {
|
|||
|
|
self.document.push_draw_snapshot(layer_idx, before, None, "AI Fill Rect".to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"AddVectorPath" => {
|
|||
|
|
let closed = val["closed"].as_bool().unwrap_or(true);
|
|||
|
|
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_width = val["stroke_width"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
|
|||
|
|
let fc = [u8::from_str_radix(&fill_hex.trim_start_matches('#')[0..2], 16).unwrap_or(0),
|
|||
|
|
u8::from_str_radix(&fill_hex.trim_start_matches('#')[2..4], 16).unwrap_or(0),
|
|||
|
|
u8::from_str_radix(&fill_hex.trim_start_matches('#')[4..6], 16).unwrap_or(0), 255];
|
|||
|
|
let sc = [u8::from_str_radix(&stroke_hex.trim_start_matches('#')[0..2], 16).unwrap_or(0),
|
|||
|
|
u8::from_str_radix(&stroke_hex.trim_start_matches('#')[2..4], 16).unwrap_or(0),
|
|||
|
|
u8::from_str_radix(&stroke_hex.trim_start_matches('#')[4..6], 16).unwrap_or(0), 255];
|
|||
|
|
|
|||
|
|
if let Some(pts) = val["points"].as_array() {
|
|||
|
|
let path_pts: Vec<[f32; 2]> = pts.iter()
|
|||
|
|
.filter_map(|p| p.as_array())
|
|||
|
|
.filter(|p| p.len() >= 2)
|
|||
|
|
.map(|p| [p[0].as_f64().unwrap_or(0.0) as f32, p[1].as_f64().unwrap_or(0.0) as f32])
|
|||
|
|
.collect();
|
|||
|
|
if !path_pts.is_empty() {
|
|||
|
|
let shape = hcie_protocol::VectorShape::FreePath {
|
|||
|
|
pts: path_pts, closed, stroke: stroke_width, color: sc,
|
|||
|
|
fill_color: fc, fill: true, opacity: 1.0, hardness: 0.8,
|
|||
|
|
};
|
|||
|
|
self.add_vector_shape(shape);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"RenameLayer" => {
|
|||
|
|
let new_name = val["new_name"].as_str().unwrap_or("Layer");
|
|||
|
|
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
|||
|
|
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
|||
|
|
let ids = self.document.all_layer_ids();
|
|||
|
|
if let Some(&id) = ids.get(idx) {
|
|||
|
|
self.rename_layer(id, new_name);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for info in &self.document.layer_infos() {
|
|||
|
|
if info.name == name_or_idx {
|
|||
|
|
self.rename_layer(info.id, new_name);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"SetLayerOpacity" => {
|
|||
|
|
let opacity = val["opacity"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
|||
|
|
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
|||
|
|
let ids = self.document.all_layer_ids();
|
|||
|
|
if let Some(&id) = ids.get(idx) {
|
|||
|
|
self.set_layer_opacity(id, opacity);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for info in &self.document.layer_infos() {
|
|||
|
|
if info.name == name_or_idx {
|
|||
|
|
self.set_layer_opacity(info.id, opacity);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"SetLayerBlendMode" => {
|
|||
|
|
let mode_str = val["blend_mode"].as_str().unwrap_or("Normal");
|
|||
|
|
let mode = blend_mode_from_label(mode_str);
|
|||
|
|
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
|||
|
|
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
|||
|
|
let ids = self.document.all_layer_ids();
|
|||
|
|
if let Some(&id) = ids.get(idx) {
|
|||
|
|
self.set_layer_blend_mode(id, mode);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
for info in &self.document.layer_infos() {
|
|||
|
|
if info.name == name_or_idx {
|
|||
|
|
self.set_layer_blend_mode(info.id, mode);
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"ToggleLayerVisibility" => {
|
|||
|
|
if let Some(name_or_idx) = val["name_or_index"].as_str() {
|
|||
|
|
if let Ok(idx) = name_or_idx.parse::<usize>() {
|
|||
|
|
let ids = self.document.all_layer_ids();
|
|||
|
|
if let Some(&id) = ids.get(idx) {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
layer.visible = !layer.visible;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
let infos = self.document.layer_infos();
|
|||
|
|
let _target_name = name_or_idx.to_string();
|
|||
|
|
let target_id = infos.iter().find(|info| info.name == name_or_idx).map(|info| info.id);
|
|||
|
|
drop(infos);
|
|||
|
|
if let Some(id) = target_id {
|
|||
|
|
if let Some(layer) = self.document.get_layer_by_id_mut(id) {
|
|||
|
|
layer.visible = !layer.visible;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"AddEllipse" => {
|
|||
|
|
let cx = val["cx"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
let cy = val["cy"].as_f64().unwrap_or(0.5) as f32;
|
|||
|
|
let rx = val["rx"].as_f64().unwrap_or(0.15) as f32;
|
|||
|
|
let ry = val["ry"].as_f64().unwrap_or(0.15) as f32;
|
|||
|
|
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
|||
|
|
let fc = parse_hex_color(fill_hex);
|
|||
|
|
let sc = parse_hex_color(stroke_hex);
|
|||
|
|
let shape = VectorShape::Circle {
|
|||
|
|
x1: cx - rx, y1: cy - ry, x2: cx + rx, y2: cy + ry,
|
|||
|
|
stroke: stroke_width, color: sc, fill_color: fc, fill: true, angle: 0.0, opacity: 1.0, hardness: 1.0,
|
|||
|
|
};
|
|||
|
|
self.add_vector_shape(shape);
|
|||
|
|
}
|
|||
|
|
"AddRect" => {
|
|||
|
|
let x1 = val["x1"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
let y1 = val["y1"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
let x2 = val["x2"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
let y2 = val["y2"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
|||
|
|
let radius = val["radius"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
let fc = parse_hex_color(fill_hex);
|
|||
|
|
let sc = parse_hex_color(stroke_hex);
|
|||
|
|
let shape = VectorShape::Rect {
|
|||
|
|
x1, y1, x2, y2,
|
|||
|
|
stroke: stroke_width, color: sc, fill_color: fc, fill: true, radius, angle: 0.0, opacity: 1.0, hardness: 1.0,
|
|||
|
|
};
|
|||
|
|
self.add_vector_shape(shape);
|
|||
|
|
}
|
|||
|
|
"DeleteShape" => {
|
|||
|
|
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if shape_idx < shapes.len() {
|
|||
|
|
shapes.remove(shape_idx);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"SetShapeColor" => {
|
|||
|
|
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
|||
|
|
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let fill_hex = val["fill_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let sc = parse_hex_color(stroke_hex);
|
|||
|
|
let fc = parse_hex_color(fill_hex);
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if shape_idx < shapes.len() {
|
|||
|
|
let shape = &mut shapes[shape_idx];
|
|||
|
|
set_shape_color(shape, sc, fc);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"MoveShape" => {
|
|||
|
|
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
|||
|
|
let dx = val["dx"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
let dy = val["dy"].as_f64().unwrap_or(0.0) as f32;
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if shape_idx < shapes.len() {
|
|||
|
|
move_shape(shapes[shape_idx].clone(), dx, dy, &mut shapes[shape_idx]);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"ScaleShape" => {
|
|||
|
|
let shape_idx = val["shape_index"].as_u64().unwrap_or(0) as usize;
|
|||
|
|
let scale_x = val["scale_x"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
let scale_y = val["scale_y"].as_f64().unwrap_or(1.0) as f32;
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
if let hcie_protocol::LayerData::Vector { ref mut shapes } = layer.data {
|
|||
|
|
if shape_idx < shapes.len() {
|
|||
|
|
scale_shape(&mut shapes[shape_idx], scale_x, scale_y);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"SelectTool" => {
|
|||
|
|
log::info!("AI SelectTool action received — tool selection should be handled by GUI layer");
|
|||
|
|
}
|
|||
|
|
"SearchTemplatesDb" => {
|
|||
|
|
let query = val["object_name"].as_str().unwrap_or("");
|
|||
|
|
let matches = ai_templates::search_templates(query);
|
|||
|
|
if matches.is_empty() {
|
|||
|
|
log::info!("[AI] search_templates_db: no matches for '{}'", query);
|
|||
|
|
} else {
|
|||
|
|
for m in &matches {
|
|||
|
|
log::info!("[AI] search_templates_db match: '{}' — {}", m.name, m.description);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"DrawTemplate" => {
|
|||
|
|
let template_name = val["template"].as_str().unwrap_or("house");
|
|||
|
|
let cx = val["cx"].as_f64().unwrap_or(0.5) as f32;
|
|||
|
|
let cy = val["cy"].as_f64().unwrap_or(0.5) as f32;
|
|||
|
|
let scale = val["scale"].as_f64().unwrap_or(0.4) as f32;
|
|||
|
|
let stroke_hex = val["stroke_color"].as_str().unwrap_or("#000000");
|
|||
|
|
let stroke_width = val["stroke_width"].as_f64().unwrap_or(2.0) as f32;
|
|||
|
|
|
|||
|
|
let sc = parse_hex_color(stroke_hex);
|
|||
|
|
|
|||
|
|
let mut params = std::collections::HashMap::new();
|
|||
|
|
if let Some(p_obj) = val["params"].as_object() {
|
|||
|
|
for (k, v) in p_obj {
|
|||
|
|
if let Some(s) = v.as_str() {
|
|||
|
|
params.insert(k.clone(), s.to_string());
|
|||
|
|
} else if let Some(n) = v.as_f64() {
|
|||
|
|
params.insert(k.clone(), n.to_string());
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
match ai_templates::get_template_def(template_name) {
|
|||
|
|
Some(tmpl_def) => {
|
|||
|
|
let (w, h) = (self.document.canvas_width as f32, self.document.canvas_height as f32);
|
|||
|
|
let components = ai_templates::eval_template(&tmpl_def, ¶ms);
|
|||
|
|
let cx_px = cx * w;
|
|||
|
|
let cy_px = cy * h;
|
|||
|
|
let s = scale * w.min(h);
|
|||
|
|
for comp in &components {
|
|||
|
|
let px_pts: Vec<[f32; 2]> = comp.pts.iter().map(|p| [
|
|||
|
|
cx_px + (p[0] - 0.5) * s,
|
|||
|
|
cy_px + (p[1] - 0.5) * s,
|
|||
|
|
]).collect();
|
|||
|
|
let fc = if comp.fill && comp.fill_color != "none" {
|
|||
|
|
parse_hex_color(&comp.fill_color)
|
|||
|
|
} else {
|
|||
|
|
[0u8; 4]
|
|||
|
|
};
|
|||
|
|
let shape = VectorShape::FreePath {
|
|||
|
|
pts: px_pts,
|
|||
|
|
closed: comp.closed,
|
|||
|
|
stroke: stroke_width,
|
|||
|
|
color: sc,
|
|||
|
|
fill_color: fc,
|
|||
|
|
fill: comp.fill,
|
|||
|
|
opacity: 1.0,
|
|||
|
|
hardness: 1.0,
|
|||
|
|
};
|
|||
|
|
self.add_vector_shape(shape);
|
|||
|
|
}
|
|||
|
|
log::info!("[AI] draw_template '{}' at ({:.2},{:.2}) scale={:.2} → {} paths", template_name, cx, cy, scale, components.len());
|
|||
|
|
}
|
|||
|
|
None => {
|
|||
|
|
let available = ai_templates::all_names().join(", ");
|
|||
|
|
log::warn!("[AI] draw_template: unknown template '{}'. Available: {}", template_name, available);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
"SetCanvasSize" => {
|
|||
|
|
let w = val["width"].as_u64().unwrap_or(self.document.canvas_width as u64) as u32;
|
|||
|
|
let h = val["height"].as_u64().unwrap_or(self.document.canvas_height as u64) as u32;
|
|||
|
|
self.resize_canvas(w, h);
|
|||
|
|
log::info!("[AI] set_canvas_size {}x{}", w, h);
|
|||
|
|
}
|
|||
|
|
"CropCanvas" => {
|
|||
|
|
let x = val["x"].as_u64().unwrap_or(0) as u32;
|
|||
|
|
let y = val["y"].as_u64().unwrap_or(0) as u32;
|
|||
|
|
let w = val["width"].as_u64().unwrap_or(self.document.canvas_width as u64) as u32;
|
|||
|
|
let h = val["height"].as_u64().unwrap_or(self.document.canvas_height as u64) as u32;
|
|||
|
|
self.crop(x, y, w, h);
|
|||
|
|
log::info!("[AI] crop_canvas x={} y={} w={} h={}", x, y, w, h);
|
|||
|
|
}
|
|||
|
|
"Finish" | "Unknown" => {}
|
|||
|
|
_ => log::warn!("Unknown AI action variant: {}", key),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
Ok("Executed".to_string())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── Clipboard Ops ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
pub fn get_active_layer_pixels(&self) -> Option<Vec<u8>> {
|
|||
|
|
self.document.active_layer().map(|l| l.pixels.clone())
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn get_active_layer_size(&self) -> Option<(u32, u32)> {
|
|||
|
|
self.document.active_layer().map(|l| (l.width, l.height))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn clear_active_layer_pixels(&mut self) {
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
layer.pixels.fill(0);
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// Clear pixels only within the active selection mask. If no selection, clears all.
|
|||
|
|
pub fn clear_selection_pixels(&mut self) {
|
|||
|
|
let mask = self.document.selection_mask.clone();
|
|||
|
|
if let Some(ref mask_data) = mask {
|
|||
|
|
let w = self.document.canvas_width as usize;
|
|||
|
|
let h = self.document.canvas_height as usize;
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
for y in 0..h {
|
|||
|
|
for x in 0..w {
|
|||
|
|
let idx = y * w + x;
|
|||
|
|
if idx < mask_data.len() && mask_data[idx] > 0 {
|
|||
|
|
let px = idx * 4;
|
|||
|
|
if px + 3 < layer.pixels.len() {
|
|||
|
|
layer.pixels[px + 3] = 0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
self.clear_active_layer_pixels();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn set_active_layer_pixels(&mut self, pixels: Vec<u8>) {
|
|||
|
|
if let Some(layer) = self.document.active_layer_mut() {
|
|||
|
|
layer.pixels = pixels;
|
|||
|
|
layer.dirty = true;
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
pub fn create_layer_from_data(&mut self, name: &str, pixels: Vec<u8>, width: u32, height: u32) -> u64 {
|
|||
|
|
let id = self.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.dirty = true;
|
|||
|
|
}
|
|||
|
|
self.document.composite_dirty = true;
|
|||
|
|
self.document.modified = 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;
|
|||
|
|
self.document.modified = 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;
|
|||
|
|
self.document.modified = 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),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
_ => {}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
events.push(EngineEvent::CanvasRendered { width: self.canvas_width(), height: self.canvas_height() });
|
|||
|
|
events
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn parse_hex_color(hex: &str) -> [u8; 4] {
|
|||
|
|
let h = hex.trim_start_matches('#');
|
|||
|
|
if h.len() >= 6 {
|
|||
|
|
let r = u8::from_str_radix(&h[0..2], 16).unwrap_or(0);
|
|||
|
|
let g = u8::from_str_radix(&h[2..4], 16).unwrap_or(0);
|
|||
|
|
let b = u8::from_str_radix(&h[4..6], 16).unwrap_or(0);
|
|||
|
|
let a = if h.len() >= 8 { u8::from_str_radix(&h[6..8], 16).unwrap_or(255) } else { 255 };
|
|||
|
|
[r, g, b, a]
|
|||
|
|
} else {
|
|||
|
|
[0, 0, 0, 255]
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn set_shape_color(shape: &mut hcie_protocol::VectorShape, stroke_color: [u8; 4], fill_color: [u8; 4]) {
|
|||
|
|
use hcie_protocol::VectorShape::*;
|
|||
|
|
match shape {
|
|||
|
|
Line { color, .. } => *color = stroke_color,
|
|||
|
|
Rect { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Circle { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Arrow { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Star { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Polygon { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Rhombus { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Cylinder { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Heart { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Bubble { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Gear { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Cross { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Crescent { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Bolt { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
Arrow4 { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
FreePath { color, fill_color: fc, .. } => { *color = stroke_color; *fc = fill_color; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn move_shape(_original: hcie_protocol::VectorShape, dx: f32, dy: f32, shape: &mut hcie_protocol::VectorShape) {
|
|||
|
|
use hcie_protocol::VectorShape::*;
|
|||
|
|
match shape {
|
|||
|
|
Line { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Rect { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Circle { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Arrow { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Star { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Polygon { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Rhombus { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Cylinder { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Heart { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Bubble { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Gear { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Cross { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Crescent { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Bolt { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
Arrow4 { x1, y1, x2, y2, .. } => { *x1 += dx; *y1 += dy; *x2 += dx; *y2 += dy; }
|
|||
|
|
FreePath { pts, .. } => { for p in pts.iter_mut() { p[0] += dx; p[1] += dy; } }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn scale_shape(shape: &mut hcie_protocol::VectorShape, scale_x: f32, scale_y: f32) {
|
|||
|
|
use hcie_protocol::VectorShape::*;
|
|||
|
|
match shape {
|
|||
|
|
Line { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Rect { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Circle { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Arrow { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Star { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Polygon { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Rhombus { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Cylinder { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Heart { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Bubble { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Gear { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Cross { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Crescent { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Bolt { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
Arrow4 { x1, y1, x2, y2, .. } => {
|
|||
|
|
let cx = (*x1 + *x2) / 2.0;
|
|||
|
|
let cy = (*y1 + *y2) / 2.0;
|
|||
|
|
*x1 = cx + (*x1 - cx) * scale_x;
|
|||
|
|
*y1 = cy + (*y1 - cy) * scale_y;
|
|||
|
|
*x2 = cx + (*x2 - cx) * scale_x;
|
|||
|
|
*y2 = cy + (*y2 - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
FreePath { pts, .. } => {
|
|||
|
|
if pts.is_empty() { return; }
|
|||
|
|
let cx = pts.iter().map(|p| p[0]).sum::<f32>() / pts.len() as f32;
|
|||
|
|
let cy = pts.iter().map(|p| p[1]).sum::<f32>() / pts.len() as f32;
|
|||
|
|
for p in pts.iter_mut() {
|
|||
|
|
p[0] = cx + (p[0] - cx) * scale_x;
|
|||
|
|
p[1] = cy + (p[1] - cy) * scale_y;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn blend_mode_from_label(label: &str) -> hcie_protocol::BlendMode {
|
|||
|
|
use hcie_protocol::BlendMode::*;
|
|||
|
|
match label.to_lowercase().as_str() {
|
|||
|
|
"normal" => Normal,
|
|||
|
|
"dissolve" => Dissolve,
|
|||
|
|
"darken" => Darken,
|
|||
|
|
"multiply" => Multiply,
|
|||
|
|
"color burn" | "colorburn" => ColorBurn,
|
|||
|
|
"linear burn" | "linearburn" => LinearBurn,
|
|||
|
|
"darker color" | "darkercolor" => DarkerColor,
|
|||
|
|
"lighten" => Lighten,
|
|||
|
|
"screen" => Screen,
|
|||
|
|
"color dodge" | "colordodge" => ColorDodge,
|
|||
|
|
"linear dodge" | "lineardodge" => LinearDodge,
|
|||
|
|
"lighter color" | "lightercolor" => LighterColor,
|
|||
|
|
"overlay" => Overlay,
|
|||
|
|
"soft light" | "softlight" => SoftLight,
|
|||
|
|
"hard light" | "hardlight" => HardLight,
|
|||
|
|
"vivid light" | "vividlight" => VividLight,
|
|||
|
|
"linear light" | "linearlight" => LinearLight,
|
|||
|
|
"pin light" | "pinlight" => PinLight,
|
|||
|
|
"hard mix" | "hardmix" => HardMix,
|
|||
|
|
"difference" => Difference,
|
|||
|
|
"exclusion" => Exclusion,
|
|||
|
|
"subtract" => Subtract,
|
|||
|
|
"divide" => Divide,
|
|||
|
|
"hue" => Hue,
|
|||
|
|
"saturation" => Saturation,
|
|||
|
|
"color" => Color,
|
|||
|
|
"luminosity" => Luminosity,
|
|||
|
|
_ => Normal,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
fn base64_encode(data: &[u8]) -> String {
|
|||
|
|
use std::fmt::Write;
|
|||
|
|
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|||
|
|
let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
|
|||
|
|
for chunk in data.chunks(3) {
|
|||
|
|
let b0 = chunk[0] as u32;
|
|||
|
|
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
|||
|
|
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
|||
|
|
let n = (b0 << 16) | (b1 << 8) | b2;
|
|||
|
|
out.push(TABLE[((n >> 18) & 63) as usize] as char);
|
|||
|
|
out.push(TABLE[((n >> 12) & 63) as usize] as char);
|
|||
|
|
if chunk.len() > 1 { out.push(TABLE[((n >> 6) & 63) as usize] as char); } else { out.push('='); }
|
|||
|
|
if chunk.len() > 2 { out.push(TABLE[(n & 63) as usize] as char); } else { out.push('='); }
|
|||
|
|
}
|
|||
|
|
let _ = write!(out, "");
|
|||
|
|
out
|
|||
|
|
}
|