diff --git a/.cargo/config.toml b/.cargo/config.toml index 75b19d6..efa891d 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,3 +1,12 @@ [env] PKG_CONFIG_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu/pkgconfig" LIBRARY_PATH = "/tmp/dav1d-dev/usr/lib/x86_64-linux-gnu" + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-arg=-fuse-ld=mold"] + +[target.x86_64-pc-windows-msvc] +linker = "rust-lld.exe" + +[target.x86_64-pc-windows-gnu] +linker = "rust-lld.exe" diff --git a/Cargo.toml b/Cargo.toml index d8ad13a..b0bdc44 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,7 +102,7 @@ approx = "0.5" [profile.dev] opt-level = 1 -codegen-units = 1 +codegen-units = 256 [profile.dev.package."*"] opt-level = 3 diff --git a/hcie-document/src/lib.rs b/hcie-document/src/lib.rs index 70b9038..9e0d55a 100644 --- a/hcie-document/src/lib.rs +++ b/hcie-document/src/lib.rs @@ -307,6 +307,29 @@ impl Document { })); } + pub fn push_mask_draw_snapshot_subrect( + &mut self, + layer_idx: usize, + before_pixels: Vec, + after_pixels: Vec, + bounds: (u32, u32, u32, u32), + description: String, + ) { + if layer_idx >= self.layers.len() { + return; + } + if before_pixels == after_pixels { + return; + } + self.history.push(Box::new(MaskDrawAction { + layer_index: layer_idx, + before_pixels, + after_pixels, + bounds: Some([bounds.0, bounds.1, bounds.2, bounds.3]), + description, + })); + } + /// Legacy full-layer snapshot (used by non-brush operations: filter, fill, etc.) pub fn push_draw_snapshot( &mut self, @@ -339,6 +362,31 @@ impl Document { })); } + pub fn push_mask_draw_snapshot( + &mut self, + layer_idx: usize, + before_pixels: Vec, + description: String, + ) { + if layer_idx >= self.layers.len() { + return; + } + let layer = &self.layers[layer_idx]; + if let Some(ref mask) = layer.mask_pixels { + let after_pixels = mask.clone(); + if before_pixels == after_pixels { + return; + } + self.history.push(Box::new(MaskDrawAction { + layer_index: layer_idx, + before_pixels, + after_pixels, + bounds: None, + description, + })); + } + } + /// Clears the undo/redo history stack. /// /// **Purpose:** Removes all recorded actions so a freshly loaded document @@ -735,6 +783,76 @@ impl UndoableAction> for DrawAction { } } +struct MaskDrawAction { + layer_index: usize, + before_pixels: Vec, + after_pixels: Vec, + bounds: Option<[u32; 4]>, + description: String, +} + +impl UndoableAction> for MaskDrawAction { + fn undo(&mut self, layers: &mut Vec) { + if let Some(layer) = layers.get_mut(self.layer_index) { + if layer.mask_pixels.is_none() { + layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]); + } + if let Some(ref mut mask) = layer.mask_pixels { + if let Some([x0, y0, x1, y1]) = self.bounds { + let dst_w = layer.width; + let rw = x1 - x0; + for row in 0..(y1 - y0) { + let src_start = (row * rw) as usize; + let dst_start = (((y0 + row) * dst_w + x0)) as usize; + let len = (rw) as usize; + if src_start + len <= self.before_pixels.len() + && dst_start + len <= mask.len() + { + mask[dst_start..dst_start + len] + .copy_from_slice(&self.before_pixels[src_start..src_start + len]); + } + } + } else { + mask.clone_from(&self.before_pixels); + } + } + layer.dirty = true; + layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); + } + } + fn redo(&mut self, layers: &mut Vec) { + if let Some(layer) = layers.get_mut(self.layer_index) { + if layer.mask_pixels.is_none() { + layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]); + } + if let Some(ref mut mask) = layer.mask_pixels { + if let Some([x0, y0, x1, y1]) = self.bounds { + let dst_w = layer.width; + let rw = x1 - x0; + for row in 0..(y1 - y0) { + let src_start = (row * rw) as usize; + let dst_start = (((y0 + row) * dst_w + x0)) as usize; + let len = (rw) as usize; + if src_start + len <= self.after_pixels.len() + && dst_start + len <= mask.len() + { + mask[dst_start..dst_start + len] + .copy_from_slice(&self.after_pixels[src_start..src_start + len]); + } + } + } else { + mask.clone_from(&self.after_pixels); + } + } + layer.dirty = true; + layer.effects_dirty.store(true, std::sync::atomic::Ordering::Release); + } + } + fn description(&self) -> String { + self.description.clone() + } +} + struct LayerVisibilityAction { layer_index: usize, old_visible: bool, diff --git a/hcie-engine-api/src/layer_property_ops.rs b/hcie-engine-api/src/layer_property_ops.rs index 4104665..5a0bbf5 100644 --- a/hcie-engine-api/src/layer_property_ops.rs +++ b/hcie-engine-api/src/layer_property_ops.rs @@ -459,4 +459,102 @@ impl Engine { self.below_cache_active_idx = None; self.below_cache_dirty = true; } + + /// Check if a layer has an active layer mask. + pub fn has_layer_mask(&self, layer_id: u64) -> bool { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + self.document.layers.get(idx).map(|l| l.mask_pixels.is_some()).unwrap_or(false) + } else { + false + } + } + + /// Add a white (opaque/reveal) layer mask to the specified layer if not present. + pub fn add_layer_mask(&mut self, layer_id: u64) { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + if let Some(layer) = self.document.layers.get_mut(idx) { + let mask_size = (layer.width * layer.height) as usize; + layer.mask_pixels = Some(vec![255u8; mask_size]); + layer.mask_bounds = Some([0, 0, layer.height as i32, layer.width as i32]); + layer.mask_default_color = 255; + layer.dirty = true; + self.document.composite_dirty = true; + self.document.modified = true; + self.below_cache_dirty = true; + self.document.composite_dirty = true; + } + } + } + + /// Remove the layer mask from the specified layer. + pub fn remove_layer_mask(&mut self, layer_id: u64) { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + if let Some(layer) = self.document.layers.get_mut(idx) { + layer.mask_pixels = None; + layer.mask_bounds = None; + layer.dirty = true; + self.document.composite_dirty = true; + self.document.modified = true; + self.below_cache_dirty = true; + self.document.composite_dirty = true; + } + } + } + + /// Toggle the existence of a layer mask on the specified layer. + pub fn toggle_layer_mask(&mut self, layer_id: u64) { + if self.has_layer_mask(layer_id) { + self.remove_layer_mask(layer_id); + } else { + self.add_layer_mask(layer_id); + } + } + + /// Returns `true` if current drawing operations target the layer mask. + pub fn is_editing_mask(&self) -> bool { + self.editing_mask + } + + /// Set whether current drawing operations target the active layer's mask. + pub fn set_editing_mask(&mut self, editing: bool) { + self.editing_mask = editing; + } + + /// Toggle mask editing mode. + pub fn toggle_editing_mask(&mut self) { + self.editing_mask = !self.editing_mask; + } + + /// Returns a reference to the mask pixel bytes for the given layer. + pub fn get_layer_mask_pixels(&self, layer_id: u64) -> Option<&[u8]> { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + self.document.layers.get(idx).and_then(|l| l.mask_pixels.as_deref()) + } else { + None + } + } + + /// Returns mask bounds [top, left, bottom, right] for the given layer if set. + pub fn get_layer_mask_bounds(&self, layer_id: u64) -> Option<[i32; 4]> { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + self.document.layers.get(idx).and_then(|l| l.mask_bounds) + } else { + None + } + } + + /// Update the mask pixel bytes and bounds for the given layer. + pub fn set_layer_mask_pixels(&mut self, layer_id: u64, mask: Vec, bounds: Option<[i32; 4]>) { + if let Some(idx) = self.document.layer_index_by_id(layer_id) { + if let Some(layer) = self.document.layers.get_mut(idx) { + layer.mask_pixels = Some(mask); + layer.mask_bounds = bounds; + layer.dirty = true; + self.document.composite_dirty = true; + self.document.modified = true; + self.below_cache_dirty = true; + self.document.composite_dirty = true; + } + } + } } diff --git a/hcie-engine-api/src/lib.rs b/hcie-engine-api/src/lib.rs index 580014b..1ae5484 100644 --- a/hcie-engine-api/src/lib.rs +++ b/hcie-engine-api/src/lib.rs @@ -348,12 +348,14 @@ pub struct Engine { /// At `end_stroke()`, the buffer is moved into the background thread for /// sub-rect extraction, then returned via the pending_history channel. stroke_before_buf: Option>, + mask_before_buf: Option>, tile_layers: Vec>, svg_sources: std::collections::HashMap>, filter_preview_original: Option>, stroke_effects_backup: Option>, stroke_styles_backup: Option>, pub is_eraser: bool, + pub editing_mask: 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. @@ -363,6 +365,8 @@ pub struct Engine { /// on 4K canvases. The pixel-bridge extracts dirty rect from this buffer /// without allocating its own buffer. composite_scratch: Option>, + /// Pooled RGBA scratch buffer for layer mask editing passes. + pub mask_rgba_scratch: Option>, /// 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. @@ -540,14 +544,17 @@ impl Engine { cyclic_speed: 0.5, stroke_before: None, stroke_before_buf: None, + mask_before_buf: None, tile_layers: Vec::new(), svg_sources: std::collections::HashMap::new(), filter_preview_original: None, stroke_effects_backup: None, stroke_styles_backup: None, is_eraser: false, + editing_mask: false, active_stroke_mask: None, composite_scratch: None, + mask_rgba_scratch: None, last_stroke_bounds: None, below_cache: None, below_cache_active_idx: None, diff --git a/hcie-engine-api/src/stroke_brush.rs b/hcie-engine-api/src/stroke_brush.rs index 71ec96d..5f397f0 100644 --- a/hcie-engine-api/src/stroke_brush.rs +++ b/hcie-engine-api/src/stroke_brush.rs @@ -105,6 +105,167 @@ impl Engine { tip } + /// Helper method to execute a drawing action either on active layer pixels (when editing_mask is false) + /// or on the active layer mask (when editing_mask is true), converting between RGBA and greyscale mask. + /// Ensures active layer's mask is expanded to full canvas dimensions if present. + pub fn ensure_full_canvas_mask(&mut self, layer_id: u64) { + let cw = self.document.canvas_width; + let ch = self.document.canvas_height; + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + let full_size = (cw * ch) as usize; + if let Some(ref mask) = layer.mask_pixels { + if mask.len() != full_size { + let mut full_mask = vec![layer.mask_default_color; full_size]; + if let Some(mb) = layer.mask_bounds { + let (m_top, m_left, m_bottom, m_right) = + (mb[0].max(0) as u32, mb[1].max(0) as u32, mb[2].max(0) as u32, mb[3].max(0) as u32); + let mw = (m_right - m_left) as usize; + let mh = (m_bottom - m_top) as usize; + if mw > 0 && mh > 0 && mask.len() == mw * mh { + for y in 0..mh { + let gy = m_top as usize + y; + if gy >= ch as usize { break; } + for x in 0..mw { + let gx = m_left as usize + x; + if gx >= cw as usize { break; } + full_mask[gy * cw as usize + gx] = mask[y * mw + x]; + } + } + } + } + layer.mask_pixels = Some(full_mask); + layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]); + } + } + } + } + + pub fn draw_target_pixels_or_mask(&mut self, layer_id: u64, f: F) + where + F: FnOnce( + &mut [u8], + u32, + u32, + Option<&[u8]>, + Option<&mut [u8]>, + Option<&[u8]>, + Option<&mut Vec<(f32, f32)>>, + ), + { + let is_editing = self.editing_mask; + if is_editing { + self.ensure_full_canvas_mask(layer_id); + } + + if !is_editing { + let mask_ref = self.cached_selection_mask.as_deref(); + let active_stroke_mask = self.active_stroke_mask.as_deref_mut(); + let stroke_before_buf = self.stroke_before_buf.as_deref(); + let sketch_hist = &mut self.sketch_history; + + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + let w = layer.width; + let h = layer.height; + f( + &mut layer.pixels, + w, + h, + mask_ref, + active_stroke_mask, + stroke_before_buf, + Some(sketch_hist), + ); + layer.dirty = true; + } + } else { + let cw = self.document.canvas_width as usize; + let ch = self.document.canvas_height as usize; + let mask_len = cw * ch; + if mask_len == 0 { return; } + + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + if layer.mask_pixels.is_none() { + layer.mask_pixels = Some(vec![255u8; mask_len]); + layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]); + layer.mask_default_color = 255; + } + } + + let (rx0, ry0, rx1, ry1) = match self.last_stroke_bounds { + Some([x0, y0, x1, y1]) => ( + (x0.saturating_sub(15) as usize).min(cw), + (y0.saturating_sub(15) as usize).min(ch), + ((x1 + 15) as usize).min(cw), + ((y1 + 15) as usize).min(ch), + ), + None => (0, 0, cw, ch), + }; + + let needed_len = mask_len * 4; + let mut scratch = self.mask_rgba_scratch.take().unwrap_or_default(); + if scratch.len() != needed_len { + scratch.resize(needed_len, 0); + } + + if let Some(layer) = self.document.get_layer_by_id(layer_id) { + if let Some(ref mask) = layer.mask_pixels { + for y in ry0..ry1 { + let row_off = y * cw; + for x in rx0..rx1 { + let idx = row_off + x; + let v = mask[idx]; + let off = idx * 4; + scratch[off] = v; + scratch[off + 1] = v; + scratch[off + 2] = v; + scratch[off + 3] = 255; + } + } + } + } + + { + let mask_ref = self.cached_selection_mask.as_deref(); + let active_stroke_mask = self.active_stroke_mask.as_deref_mut(); + let stroke_before_buf = self.stroke_before_buf.as_deref(); + let sketch_hist = &mut self.sketch_history; + f( + &mut scratch, + cw as u32, + ch as u32, + mask_ref, + active_stroke_mask, + stroke_before_buf, + Some(sketch_hist), + ); + } + + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + if let Some(ref mut mask) = layer.mask_pixels { + for y in ry0..ry1 { + let row_off = y * cw; + for x in rx0..rx1 { + let idx = row_off + x; + let off = idx * 4; + let r = scratch[off] as u32; + let g = scratch[off + 1] as u32; + let b = scratch[off + 2] as u32; + let a = scratch[off + 3] as u32; + let gray = (r * 299 + g * 587 + b * 114) / 1000; + mask[idx] = ((gray * a) / 255) as u8; + } + } + } + layer.dirty = true; + } + + self.mask_rgba_scratch = Some(scratch); + } + self.below_cache_dirty = true; + self.document.composite_dirty = true; + self.document.modified = true; + } + 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; @@ -115,59 +276,66 @@ impl Engine { } let tip = self.brush_tip_with_time_dynamics(x, y); let color = self.apply_cyclic_color(self.current_color); - let mask_ref = self.cached_selection_mask.as_deref(); 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) { - // If click without movement, draw a single dab at current position - let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 { - vec![(x, y, pressure)] - } else { - vec![(lx, ly, lp), (x, y, pressure)] - }; - draw_specialized_stroke( - &mut layer.pixels, - layer.width, - layer.height, - &points_vec, - tip.style, - tip.size, - tip.hardness, - color, - tip.opacity, - tip.spacing, - self.is_eraser, - if tip.style == BrushStyle::Sketch { - Some(&mut self.sketch_history) + let points_vec = if (lx - x).abs() < 0.001 && (ly - y).abs() < 0.001 { + vec![(x, y, pressure)] + } else { + vec![(lx, ly, lp), (x, y, pressure)] + }; + let is_eraser = self.is_eraser; + + let dirty_r = Self::brush_dirty_radius(&tip); + 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.draw_target_pixels_or_mask( + layer_id, + |pixels, lw, lh, mask_ref, active_stroke_mask, stroke_before_buf, sketch_history| { + let sketch_hist = if tip.style == BrushStyle::Sketch { + sketch_history } else { None - }, - tip.spray_particle_size, - tip.spray_density, - mask_ref, - self.active_stroke_mask.as_deref_mut(), - self.stroke_before_buf.as_deref(), - tip.color_variant, - tip.variant_amount, - tip.density, - tip.jitter_amount, - tip.scatter_amount, - tip.angle, - tip.roundness, - tip.rotation_random, - tip.drawing_angle, - false, - ); - layer.dirty = true; + }; + draw_specialized_stroke( + pixels, + lw, + lh, + &points_vec, + tip.style, + tip.size, + tip.hardness, + color, + tip.opacity, + tip.spacing, + is_eraser, + sketch_hist, + tip.spray_particle_size, + tip.spray_density, + mask_ref, + active_stroke_mask, + stroke_before_buf, + tip.color_variant, + tip.variant_amount, + tip.density, + tip.jitter_amount, + tip.scatter_amount, + tip.angle, + tip.roundness, + tip.rotation_random, + tip.drawing_angle, + false, + ); + }, + ); + + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { if !layer.effects.is_empty() || !layer.styles.is_empty() { *layer.effects_cache.lock().unwrap() = None; } - let dirty_r = Self::brush_dirty_radius(&tip); - 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; @@ -176,32 +344,60 @@ impl Engine { 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_ref, - self.active_stroke_mask.as_deref_mut(), - self.stroke_before_buf.as_deref(), + let is_eraser = self.is_eraser; + + 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); + + if self.editing_mask { + self.draw_target_pixels_or_mask( + layer_id, + |pixels, lw, lh, mask_ref, active_stroke_mask, stroke_before_buf, _sketch_history| { + let mut temp_layer = hcie_protocol::Layer::from_rgba("temp", lw, lh, pixels.to_vec()); + draw_brush_stroke( + &mut temp_layer, + &[(x, y, pressure)], + color, + &tip, + is_eraser, + mask_ref, + active_stroke_mask, + stroke_before_buf, + ); + pixels.copy_from_slice(&temp_layer.pixels); + }, ); - layer.dirty = true; + } else { + let mask_ref = self.cached_selection_mask.as_deref(); + let active_stroke_mask = self.active_stroke_mask.as_deref_mut(); + let stroke_before_buf = self.stroke_before_buf.as_deref(); + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { + draw_brush_stroke( + layer, + &[(x, y, pressure)], + color, + &tip, + is_eraser, + mask_ref, + active_stroke_mask, + stroke_before_buf, + ); + } + } + + if let Some(layer) = self.document.get_layer_by_id_mut(layer_id) { if !layer.effects.is_empty() || !layer.styles.is_empty() { 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; diff --git a/hcie-engine-api/src/stroke_cache.rs b/hcie-engine-api/src/stroke_cache.rs index 6c708f7..f5dfc99 100644 --- a/hcie-engine-api/src/stroke_cache.rs +++ b/hcie-engine-api/src/stroke_cache.rs @@ -59,6 +59,10 @@ pub struct PendingHistoryItem { pub return_before: Option>, /// Full-layer after buffer returned from background thread for pool reuse. pub return_after: Option>, + /// Grayscale before mask buffer returned from background thread for pool reuse. + pub return_mask_before: Option>, + /// Indicates if this snapshot represents a mask edit rather than a pixel edit. + pub is_mask: bool, } impl Engine { @@ -98,8 +102,33 @@ impl Engine { self.stroke_before_buf = Some(buf); } } + if let Some(buf) = item.return_mask_before { + if self + .mask_before_buf + .as_ref() + .map_or(true, |b| b.len() != buf.len()) + { + self.mask_before_buf = Some(buf); + } + } - if let Some(bounds) = item.bounds { + if item.is_mask { + if let Some(bounds) = item.bounds { + self.document.push_mask_draw_snapshot_subrect( + item.layer_idx, + item.before_pixels, + item.after_pixels, + (bounds[0], bounds[1], bounds[2], bounds[3]), + item.description, + ); + } else { + self.document.push_mask_draw_snapshot( + item.layer_idx, + item.before_pixels, + item.description, + ); + } + } else if let Some(bounds) = item.bounds { self.document.push_draw_snapshot_subrect( item.layer_idx, item.before_pixels, @@ -191,12 +220,71 @@ impl Engine { // Pool the "before" pixel buffer: reuse if size matches, otherwise allocate. // This avoids a ~33MB allocation per stroke on 4K canvases. - match &mut self.stroke_before_buf { - Some(buf) if buf.len() == layer_pixels => { - buf.copy_from_slice(&layer.pixels); + if self.editing_mask { + let mut rgba_buf = match self.stroke_before_buf.take() { + Some(mut b) if b.len() == layer_pixels => { + b.fill(255); + b + } + _ => vec![255u8; layer_pixels], + }; + let mut gray_buf = match self.mask_before_buf.take() { + Some(mut b) if b.len() == layer_size => { + b.fill(255); + b + } + _ => vec![255u8; layer_size], + }; + + let ch = layer.height as usize; + let cw = layer.width as usize; + let full_size = cw * ch; + + if let Some(ref mut mask) = layer.mask_pixels { + if mask.len() != full_size { + let mut full_mask = vec![layer.mask_default_color; full_size]; + if let Some(mb) = layer.mask_bounds { + let (m_top, m_left, m_bottom, m_right) = + (mb[0].max(0) as u32, mb[1].max(0) as u32, mb[2].max(0) as u32, mb[3].max(0) as u32); + let mw = (m_right - m_left) as usize; + let mh = (m_bottom - m_top) as usize; + if mw > 0 && mh > 0 && mask.len() == mw * mh { + for y in 0..mh { + let gy = m_top as usize + y; + if gy >= ch { break; } + for x in 0..mw { + let gx = m_left as usize + x; + if gx >= cw { break; } + full_mask[gy * cw + gx] = mask[y * mw + x]; + } + } + } + } + *mask = full_mask; + layer.mask_bounds = Some([0, 0, ch as i32, cw as i32]); + } } - _ => { - self.stroke_before_buf = Some(layer.pixels.clone()); + + if let Some(ref mask) = layer.mask_pixels { + gray_buf.copy_from_slice(mask); + for (i, &v) in mask.iter().enumerate() { + let off = i * 4; + rgba_buf[off] = v; + rgba_buf[off + 1] = v; + rgba_buf[off + 2] = v; + rgba_buf[off + 3] = 255; + } + } + self.stroke_before_buf = Some(rgba_buf); + self.mask_before_buf = Some(gray_buf); + } else { + match &mut self.stroke_before_buf { + Some(buf) if buf.len() == layer_pixels => { + buf.copy_from_slice(&layer.pixels); + } + _ => { + self.stroke_before_buf = Some(layer.pixels.clone()); + } } } let before_shapes = if let LayerData::Vector { shapes } = &layer.data { @@ -254,17 +342,35 @@ impl Engine { layer_pixels, self.stroke_before_buf.as_ref().map(|b| b.len()) ); - let before = match self.stroke_before_buf.take() { - Some(buf) if buf.len() == layer_pixels => buf, - _ => vec![0u8; layer_pixels], + let mut mask_before = None; + let before = if self.editing_mask { + let gray_len = lw as usize * layer.height as usize; + let mb = match self.mask_before_buf.take() { + Some(buf) if buf.len() == gray_len => buf, + _ => vec![255u8; gray_len], + }; + mask_before = Some(mb.clone()); + mb + } else { + match self.stroke_before_buf.take() { + Some(buf) if buf.len() == layer_pixels => buf, + _ => vec![0u8; layer_pixels], + } }; log::debug!("[end_stroke] before.len()={}, lw={}", before.len(), lw); // Layer 3: Zero-copy after-snapshot via raw pointer. // SAFETY: After end_stroke(), no mutations happen to layer.pixels // until the next begin_stroke(). The background thread only reads. - let after_ptr = SendPtr::new(layer.pixels.as_ptr()); - let after_len = layer.pixels.len(); + let (after_ptr, after_len) = if self.editing_mask { + if layer.mask_pixels.is_none() { + layer.mask_pixels = Some(vec![255; (layer.width * layer.height) as usize]); + } + let mask = layer.mask_pixels.as_ref().unwrap(); + (SendPtr::new(mask.as_ptr()), mask.len()) + } else { + (SendPtr::new(layer.pixels.as_ptr()), layer.pixels.len()) + }; let after_shapes = if let LayerData::Vector { shapes } = &layer.data { Some(shapes.clone()) @@ -275,6 +381,7 @@ impl Engine { let style = self.current_tip.style; let bounds = self.last_stroke_bounds; let pending_history = self.pending_history.clone(); + let is_mask = self.editing_mask; std::thread::spawn(move || { let t_start = std::time::Instant::now(); @@ -290,25 +397,33 @@ impl Engine { before_shapes, after_shapes, description: format!( - "Brush Stroke ({})", + "{} ({})", + if is_mask { "Mask Edit" } else { "Brush Stroke" }, crate::stroke_brush::brush_style_label(style) ), return_before: None, return_after: None, + return_mask_before: mask_before, + is_mask, }; if let Some([sx0, sy0, sx1, sy1]) = bounds { if sx0 < sx1 && sy0 < sy1 { let rw = sx1 - sx0; let rh = sy1 - sy0; - let rect_size = (rw * rh * 4) as usize; + let rect_size = if is_mask { + (rw * rh) as usize + } else { + (rw * rh * 4) as usize + }; log::debug!("[end_stroke_bg] bounds=[{},{},{},{}], rw={}, rh={}, rect_size={}, before.len()={}, after.len()={}, lw={}", sx0, sy0, sx1, sy1, rw, rh, rect_size, before.len(), after_slice.len(), lw); let mut before_rect = vec![0u8; rect_size]; let mut after_rect = vec![0u8; rect_size]; + let bpp = if is_mask { 1 } else { 4 }; 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; + let src_start = (((sy0 + row) * lw + sx0) * bpp) as usize; + let dst_start = (row * rw * bpp) as usize; + let len = (rw * bpp) 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] @@ -320,7 +435,9 @@ impl Engine { item.after_pixels = after_rect; item.bounds = Some([sx0, sy0, sx1, sy1]); // Return before buffer to pool (after is a borrowed pointer) - item.return_before = Some(before); + if !is_mask { + item.return_before = Some(before); + } pending_history.lock().unwrap().push(item); } log::trace!( diff --git a/hcie-engine-api/tests/layer_mask_test.rs b/hcie-engine-api/tests/layer_mask_test.rs new file mode 100644 index 0000000..bc8a42e --- /dev/null +++ b/hcie-engine-api/tests/layer_mask_test.rs @@ -0,0 +1,33 @@ +use hcie_engine_api::Engine; + +#[test] +fn test_layer_mask_api_and_editing() { + let mut engine = Engine::new(100, 100); + let layer_id = engine.active_layer_id(); + + // Initially layer has no mask + assert!(!engine.has_layer_mask(layer_id)); + assert!(engine.get_layer_mask_pixels(layer_id).is_none()); + + // Add layer mask + engine.add_layer_mask(layer_id); + assert!(engine.has_layer_mask(layer_id)); + let mask = engine.get_layer_mask_pixels(layer_id).expect("Mask pixels must exist"); + assert_eq!(mask.len(), 100 * 100); + assert_eq!(mask[0], 255); + + // Toggle mask editing mode + assert!(!engine.is_editing_mask()); + engine.set_editing_mask(true); + assert!(engine.is_editing_mask()); + + // Set custom mask pixels + let custom_mask = vec![128u8; 100 * 100]; + engine.set_layer_mask_pixels(layer_id, custom_mask.clone(), Some([0, 0, 100, 100])); + assert_eq!(engine.get_layer_mask_pixels(layer_id), Some(custom_mask.as_slice())); + + // Remove layer mask + engine.remove_layer_mask(layer_id); + assert!(!engine.has_layer_mask(layer_id)); + assert!(engine.get_layer_mask_pixels(layer_id).is_none()); +} diff --git a/hcie-iced-app/assets/icons/link.svg b/hcie-iced-app/assets/icons/link.svg new file mode 100644 index 0000000..fe3b197 --- /dev/null +++ b/hcie-iced-app/assets/icons/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs index 9de002a..1152b9f 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/app.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/app.rs @@ -724,6 +724,11 @@ pub enum Message { LayerSetFillOpacity(u64, f32), LayerAddGroup, LayerToggleCollapse(u64), + LayerAddMask(u64), + LayerRemoveMask(u64), + LayerToggleMask(u64), + LayerSetEditMask(bool), + LayerToggleEditMask, // ── History ───────────────────────────────────────── HistoryJumpTo(i32), @@ -3686,6 +3691,45 @@ impl HcieIcedApp { return Task::perform(async {}, |_| Message::CompositeRefresh); } + Message::LayerAddMask(id) => { + self.documents[self.active_doc].engine.add_layer_mask(id); + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + + Message::LayerRemoveMask(id) => { + self.documents[self.active_doc].engine.remove_layer_mask(id); + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + + Message::LayerToggleMask(id) => { + self.documents[self.active_doc].engine.toggle_layer_mask(id); + self.documents[self.active_doc].modified = true; + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + self.refresh_composite_if_needed(); + return Task::perform(async {}, |_| Message::CompositeRefresh); + } + + Message::LayerSetEditMask(editing) => { + self.documents[self.active_doc].engine.set_editing_mask(editing); + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + } + + Message::LayerToggleEditMask => { + self.documents[self.active_doc].engine.toggle_editing_mask(); + self.documents[self.active_doc].cached_layers = + self.documents[self.active_doc].engine.layer_infos(); + } + Message::LayerDelete(id) => { self.documents[self.active_doc].engine.delete_layer(id); self.documents[self.active_doc].modified = true; diff --git a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs index c958ef1..0477bab 100644 --- a/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs +++ b/hcie-iced-app/crates/hcie-iced-gui/src/panels/layers.rs @@ -173,15 +173,23 @@ fn layer_thumbnail<'a>( thumb_cache: &std::collections::HashMap)>, _thumb_gen: u64, colors: ThemeColors, + is_active_target: bool, ) -> Element<'a, Message> { if entry.is_group { return container(text("\u{1F4C1}").size(16)) - .width(50) - .height(38) - .center_y(Length::Fixed(38.0)) + .width(42) + .height(32) + .center_y(Length::Fixed(32.0)) .into(); } + let border_color = if is_active_target { + iced::Color::WHITE + } else { + colors.border_high + }; + let border_width = if is_active_target { 2 } else { 1 }; + if let Some((tw, th, pixels)) = thumb_cache.get(&entry.info.id) { let (tw32, th32) = (*tw as u32, *th as u32); let handle = iced::widget::image::Handle::from_rgba(tw32, th32, pixels.clone()); @@ -189,12 +197,14 @@ fn layer_thumbnail<'a>( .width(Length::Fixed(*tw as f32)) .height(Length::Fixed(*th as f32)); return container(img) - .width(50) - .height(38) - .center_y(Length::Fixed(38.0)) + .width(42) + .height(32) + .center_x(Length::Fixed(42.0)) + .center_y(Length::Fixed(32.0)) .padding(1) .style(move |_theme| iced::widget::container::Style { - border: iced::Border::default().color(colors.border_high).width(1), + background: Some(iced::Background::Color(iced::Color::BLACK)), + border: iced::Border::default().color(border_color).width(border_width), ..Default::default() }) .into(); @@ -213,15 +223,99 @@ fn layer_thumbnail<'a>( color: Some(icon_color), } })) - .width(50) - .height(38) - .center_x(Length::Fixed(50.0)) - .center_y(Length::Fixed(38.0)) + .width(42) + .height(32) + .center_x(Length::Fixed(42.0)) + .center_y(Length::Fixed(32.0)) .style(move |_theme| iced::widget::container::Style { background: Some(iced::Background::Color(iced::Color::from_rgba( 1.0, 1.0, 1.0, 0.05, ))), - border: iced::Border::default().color(colors.border_high).width(1), + border: iced::Border::default().color(border_color).width(border_width), + ..Default::default() + }) + .into() +} + +/// Build the black-and-white thumbnail element for a layer mask. +fn mask_thumbnail<'a>( + engine: &hcie_engine_api::Engine, + layer_id: u64, + colors: ThemeColors, + is_active_target: bool, +) -> Element<'a, Message> { + let border_color = if is_active_target { + iced::Color::WHITE + } else { + colors.border_high + }; + let border_width = if is_active_target { 2 } else { 1 }; + + if let Some(mask_pixels) = engine.get_layer_mask_pixels(layer_id) { + if let Some(info) = engine.get_layer_info(layer_id) { + let (cw, ch) = (info.width as usize, info.height as usize); + if cw > 0 && ch > 0 { + let (mw, mh) = match engine.get_layer_mask_bounds(layer_id) { + Some(mb) if (mb[3] > mb[1]) && (mb[2] > mb[0]) => { + let w = (mb[3] - mb[1]) as usize; + let h = (mb[2] - mb[0]) as usize; + if mask_pixels.len() == w * h { + (w, h) + } else { + (cw, ch) + } + } + _ => (cw, ch), + }; + + if mask_pixels.len() == mw * mh { + let tw = 40usize; + let th = (40 * mh / mw).max(16).min(32); + let mut rgba = Vec::with_capacity(tw * th * 4); + for ty in 0..th { + let sy = (ty * mh / th).min(mh - 1); + for tx in 0..tw { + let sx = (tx * mw / tw).min(mw - 1); + let val = mask_pixels[sy * mw + sx]; + rgba.push(val); + rgba.push(val); + rgba.push(val); + rgba.push(255); + } + } + let handle = iced::widget::image::Handle::from_rgba(tw as u32, th as u32, rgba); + let img = iced::widget::Image::new(handle) + .width(Length::Fixed(tw as f32)) + .height(Length::Fixed(th as f32)); + return container(img) + .width(42) + .height(32) + .center_x(Length::Fixed(42.0)) + .center_y(Length::Fixed(32.0)) + .padding(1) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::BLACK)), + border: iced::Border::default().color(border_color).width(border_width), + ..Default::default() + }) + .into(); + } + } + } + } + + container(text("Mask").size(9).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { + color: Some(colors.text_primary), + } + })) + .width(42) + .height(32) + .center_x(Length::Fixed(42.0)) + .center_y(Length::Fixed(32.0)) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::BLACK)), + border: iced::Border::default().color(border_color).width(border_width), ..Default::default() }) .into() @@ -282,14 +376,44 @@ pub fn view<'a>( .padding([1, 4]) .style(move |_theme, _status| flat_btn_style(colors)); + let mask_banner: Element<'a, Message> = if engine.is_editing_mask() { + let white_btn = button(text("⚪ White (Reveal)").size(10).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { color: Some(iced::Color::BLACK) } + })) + .on_press(Message::FgColorChanged([255, 255, 255, 255])) + .padding([2, 4]) + .style(move |_theme, _status| flat_btn_style(colors)); + + let black_btn = button(text("⬛ Black (Hide)").size(10).style(move |_theme: &iced::Theme| { + iced::widget::text::Style { color: Some(iced::Color::WHITE) } + })) + .on_press(Message::FgColorChanged([0, 0, 0, 255])) + .padding([2, 4]) + .style(move |_theme, _status| flat_btn_style(colors)); + + container( + column![ + text("Mask Mode Active:").size(10).style(move |_theme: &iced::Theme| iced::widget::text::Style { + color: Some(colors.accent), + }), + row![white_btn, black_btn].spacing(4).align_y(iced::Alignment::Center), + ].spacing(2) + ) + .padding(4) + .style(move |_theme| iced::widget::container::Style { + background: Some(iced::Background::Color(iced::Color::from_rgba(0.2, 0.2, 0.2, 0.5))), + border: iced::Border::default().color(colors.accent).width(1), + ..Default::default() + }) + .into() + } else { + Space::with_width(Length::Fixed(0.0)).into() + }; + let controls = column![ + mask_banner, row![blend_list].spacing(2), opacity_row, - text("Fill opacity: unavailable in hcie-engine-api") - .size(BODY) - .style(move |_theme: &iced::Theme| iced::widget::text::Style { - color: Some(colors.text_secondary), - }), row![lock_btn, fx_btn] .spacing(8) .align_y(iced::Alignment::Center), @@ -352,8 +476,42 @@ pub fn view<'a>( 12.0, ); - // Thumbnail - let thumb_elem = layer_thumbnail(entry, thumb_cache, thumb_gen, colors); + // Thumbnail targets + let is_layer_target_active = is_active && !engine.is_editing_mask(); + let is_mask_target_active = is_active && engine.is_editing_mask(); + + let thumb_elem = iced::widget::mouse_area(layer_thumbnail( + entry, + thumb_cache, + thumb_gen, + colors, + is_layer_target_active, + )) + .on_press(Message::LayerSetEditMask(false)); + + // Layer mask thumbnail preview + let has_mask = engine.has_layer_mask(info.id); + let link_icon: Element<'a, Message> = if has_mask { + svg_icon_btn( + "icons/link.svg", + "Link Mask", + Message::LayerToggleEditMask, + colors, + 12.0, + ) + } else { + Space::with_width(Length::Fixed(0.0)).into() + }; + + let mask_elem: Element<'a, Message> = if has_mask { + let mask_btn = button(mask_thumbnail(engine, info.id, colors, is_mask_target_active)) + .on_press(Message::LayerSetEditMask(!is_mask_target_active)) + .padding(0) + .style(move |_theme, _status| flat_btn_style(colors)); + mask_btn.into() + } else { + Space::with_width(Length::Fixed(0.0)).into() + }; // Layer name let c = if is_active { @@ -412,7 +570,7 @@ pub fn view<'a>( // Main layer row let name_row = - row![collapse, vis_btn, row_lock, thumb_elem, name_text, fx_icon, type_badge,] + row![collapse, vis_btn, row_lock, thumb_elem, link_icon, mask_elem, name_text, fx_icon, type_badge,] .spacing(2) .align_y(iced::Alignment::Center); @@ -526,10 +684,18 @@ pub fn view<'a>( .padding([3, 6]) .style(move |_theme, _status| flat_btn_style(colors)); let duplicate_btn = button(text("Duplicate").size(BODY)).padding([5, 8]); - let mask_btn = button(text("Mask").size(BODY)).padding([5, 8]); + let has_active_mask = engine.has_layer_mask(active_layer_id); + let mask_btn_label = if has_active_mask { "Remove Mask" } else { "Add Mask" }; + let mask_btn = button(text(mask_btn_label).size(BODY)) + .on_press(if has_active_mask { + Message::LayerRemoveMask(active_layer_id) + } else { + Message::LayerAddMask(active_layer_id) + }) + .padding([5, 8]) + .style(move |_theme, _status| flat_btn_style(colors)); let rasterize_btn = button(text("Rasterize").size(BODY)).padding([5, 8]); let toolbar = column![ - text("Unavailable: duplicate, mask, rasterize").size(BODY), row![duplicate_btn, mask_btn] .spacing(2) .align_y(iced::Alignment::Center), diff --git a/hcie-kra/src/kra_saver.rs b/hcie-kra/src/kra_saver.rs index b3edf0b..c1cf48e 100644 --- a/hcie-kra/src/kra_saver.rs +++ b/hcie-kra/src/kra_saver.rs @@ -106,13 +106,27 @@ pub fn save_kra( let base_filename = format!("layer{}", i + 1); match layer.layer_type { - LayerType::Raster | LayerType::Mask => { + LayerType::Raster | LayerType::Mask | LayerType::Group => { let encoded = encode_layer_vers(layer); let layer_path = format!("{}/layers/{}", internal_name, base_filename); zip.start_file(&layer_path, zip::write::FileOptions::<'_, ()>::default()) .map_err(|e| e.to_string())?; - zip.write_all(&encoded) - .map_err(|e| e.to_string())?; + zip.write_all(&encoded).map_err(|e| e.to_string())?; + + if let Some(ref mask) = layer.mask_pixels { + let mask_filename = format!("{}_mask", base_filename); + let mask_encoded = encode_mask_vers(layer, mask); + let mask_path = format!("{}/layers/{}", internal_name, mask_filename); + zip.start_file(&mask_path, zip::write::FileOptions::<'_, ()>::default()) + .map_err(|e| e.to_string())?; + zip.write_all(&mask_encoded).map_err(|e| e.to_string())?; + + let mask_dp_path = format!("{}/layers/{}.defaultpixel", internal_name, mask_filename); + zip.start_file(&mask_dp_path, zip::write::FileOptions::<'_, ()>::default()) + .map_err(|e| e.to_string())?; + zip.write_all(&[0u8; 1]) // 1 byte for Alpha + .map_err(|e| e.to_string())?; + } let icc_path = format!("{}/layers/{}.icc", internal_name, base_filename); zip.start_file(&icc_path, zip::write::FileOptions::<'_, ()>::default()) @@ -125,6 +139,18 @@ pub fn save_kra( .map_err(|e| e.to_string())?; zip.write_all(&[0u8; 4]) .map_err(|e| e.to_string())?; + + if layer.layer_type == LayerType::Group { + let group_json = serde_json::json!({ + "collapsed": layer.collapsed, + "parent_id": layer.parent_id, + }); + let json_path = format!("{}/layers/{}.grouplayer", internal_name, base_filename); + zip.start_file(&json_path, zip::write::FileOptions::<'_, ()>::default()) + .map_err(|e| e.to_string())?; + zip.write_all(group_json.to_string().as_bytes()) + .map_err(|e| e.to_string())?; + } } LayerType::Vector | LayerType::Text => { let layer_path = format!("{}/layers/{}", internal_name, base_filename); @@ -175,22 +201,7 @@ pub fn save_kra( .map_err(|e| e.to_string())?; } } - LayerType::Group => { - let group_json = serde_json::json!({ - "collapsed": layer.collapsed, - "parent_id": layer.parent_id, - }); - let group_path = - format!("{}/layers/group/{}.json", internal_name, base_filename); - zip.start_file(&group_path, zip::write::FileOptions::<'_, ()>::default()) - .map_err(|e| e.to_string())?; - zip.write_all( - serde_json::to_string_pretty(&group_json) - .unwrap_or_default() - .as_bytes(), - ) - .map_err(|e| e.to_string())?; - } + } } @@ -203,9 +214,16 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String { xml.push_str("\n"); xml.push_str("\n"); xml.push_str("\n"); + let escaped_doc_name = name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + xml.push_str(&format!( " \n", - w, h, name + w, h, escaped_doc_name )); xml.push_str(" \n"); @@ -213,7 +231,7 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String { let node_type = match layer.layer_type { LayerType::Raster | LayerType::Mask => "paintlayer", LayerType::Vector | LayerType::Text => "shapelayer", - LayerType::Group => "grouplayer", + LayerType::Group => "paintlayer", }; let visible = if layer.visible { "1" } else { "0" }; @@ -222,10 +240,28 @@ fn generate_maindoc(layers: &[Layer], w: u32, h: u32, name: &str) -> String { let uuid = format!("{{{:032x}}}", layer.id); let compositeop = blend_mode_to_krita_compositeop(layer.blend_mode); + let escaped_name = layer.name + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'"); + xml.push_str(&format!( - " \n", - layer.name, filename, node_type, visible, opacity, uuid, compositeop + " \n", + escaped_name, filename, node_type, visible, opacity, uuid, compositeop )); + if layer.mask_pixels.is_some() { + xml.push_str(" \n"); + let mask_filename = format!("{}_mask", filename); + let mask_uuid = format!("{{{:032x}}}", layer.id.wrapping_add(1)); // pseudo uuid + xml.push_str(&format!( + " \n", + mask_filename, mask_uuid + )); + xml.push_str(" \n"); + } + xml.push_str(" \n"); } xml.push_str(" \n"); @@ -285,19 +321,107 @@ fn encode_layer_vers(layer: &Layer) -> Vec { ty += th; } - let mut buf = Vec::new(); - writeln!(buf, "VERSION 2").unwrap(); - writeln!(buf, "TILEWIDTH 64").unwrap(); - writeln!(buf, "TILEHEIGHT 64").unwrap(); - writeln!(buf, "PIXELSIZE 4").unwrap(); - writeln!(buf, "DATA {}", tiles.len()).unwrap(); + let mut output = Vec::new(); + writeln!(output, "VERSION 2").unwrap(); + writeln!(output, "TILEWIDTH 64").unwrap(); + writeln!(output, "TILEHEIGHT 64").unwrap(); + writeln!(output, "PIXELSIZE 4").unwrap(); + writeln!(output, "DATA {}", tiles.len()).unwrap(); - for (tx, ty, data) in tiles { - writeln!(buf, "{},{},LZF,{}", tx, ty, data.len()).unwrap(); - buf.extend_from_slice(&data); + tiles.sort_by_key(|(x, y, _)| (*y, *x)); + for (tx, ty, tile_data) in tiles { + let header = format!("{},{},LZF,{}\n", tx, ty, tile_data.len()); + output.extend_from_slice(header.as_bytes()); + output.extend_from_slice(&tile_data); } - buf + output +} + +fn encode_mask_vers(layer: &Layer, mask: &[u8]) -> Vec { + let tw: i32 = 64; + let th: i32 = 64; + let plane_size = (tw * th) as usize; + let w = layer.width as i32; + let h = layer.height as i32; + + let mut tiles: Vec<(i32, i32, Vec)> = Vec::new(); + let mut ty = 0; + while ty < h { + let mut tx = 0; + while tx < w { + let mut tile_raw = vec![0u8; plane_size]; + let mut has_content = false; + + for y in 0..th { + let ly = ty + y; + if ly >= h { + continue; + } + for x in 0..tw { + let lx = tx + x; + if lx >= w { + continue; + } + let tidx = (y as usize) * (tw as usize) + (x as usize); + let mut v = layer.mask_default_color; + if let Some(mb) = layer.mask_bounds { + let m_top = mb[0] as i32; + let m_left = mb[1] as i32; + let m_bottom = mb[2] as i32; + let m_right = mb[3] as i32; + if ly >= m_top && ly < m_bottom && lx >= m_left && lx < m_right { + let mask_w = m_right - m_left; + let mask_idx = ((ly - m_top) * mask_w + (lx - m_left)) as usize; + if mask_idx < mask.len() { + v = mask[mask_idx]; + } + } + } else { + let idx = ly as usize * layer.width as usize + lx as usize; + if idx < mask.len() { + v = mask[idx]; + } + } + + tile_raw[tidx] = v; + if v < 255 { + has_content = true; // Any masking means content + } + } + } + + if has_content { + let mut compressed = vec![1u8]; + let mut i = 0; + while i < tile_raw.len() { + let chunk = (tile_raw.len() - i).min(32); + compressed.push((chunk - 1) as u8); + compressed.extend_from_slice(&tile_raw[i..i + chunk]); + i += chunk; + } + tiles.push((tx, ty, compressed)); + } + tx += tw; + } + ty += th; + } + + let mut output = Vec::new(); + output.extend_from_slice(b"VERSION 2\n"); + output.extend_from_slice(b"TILEWIDTH 64\n"); + output.extend_from_slice(b"TILEHEIGHT 64\n"); + output.extend_from_slice(b"PIXELSIZE 1\n"); + output.extend_from_slice(b"DATA\n"); + + tiles.sort_by_key(|(x, y, _)| (*y, *x)); + for (tx, ty, tile_data) in tiles { + let header = format!("{},{},LZF,{}\n", tx, ty, tile_data.len()); + output.extend_from_slice(header.as_bytes()); + output.extend_from_slice(&tile_data); + } + + output } fn shapes_to_svg(shapes: &[VectorShape], w: u32, h: u32) -> String { diff --git a/hcie-kra/src/lib.rs b/hcie-kra/src/lib.rs index e83f9af..9455435 100644 --- a/hcie-kra/src/lib.rs +++ b/hcie-kra/src/lib.rs @@ -40,6 +40,7 @@ pub fn import_kra(path: &Path) -> Result, String> { } /// Purpose: Intermediate metadata structure representing Krita layer elements. +#[derive(Debug, Clone)] struct KritaLayerInfo { name: String, filename: String, @@ -50,6 +51,7 @@ struct KritaLayerInfo { nodetype: String, compositeop: String, layerstyle_uuid: String, + parent_filename: Option, } @@ -111,7 +113,8 @@ fn parse_vers_tiles( return Err("DATA marker not found".into()); } - let mut pixels = vec![0u8; (doc_w * doc_h * 4) as usize]; + let bpp = if pixel_size == 1 { 1 } else { 4 }; + let mut pixels = vec![0u8; (doc_w * doc_h * bpp) as usize]; let plane_size = tile_w * tile_h; let mut offset = data_offset; let mut tiles_decoded = 0usize; @@ -173,7 +176,7 @@ fn parse_vers_tiles( if target_x >= 0 && target_x < doc_w as i32 && target_y >= 0 && target_y < doc_h as i32 { - let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * 4; + let target_idx = (target_y as usize * doc_w as usize + target_x as usize) * bpp as usize; let src_idx = y * tile_w + x; if pixel_size == 4 { @@ -186,6 +189,8 @@ fn parse_vers_tiles( pixels[target_idx + 1] = tile_data[src_idx + 3 * plane_size]; pixels[target_idx + 2] = tile_data[src_idx + 1 * plane_size]; pixels[target_idx + 3] = tile_data[src_idx + 7 * plane_size]; + } else if pixel_size == 1 { + pixels[target_idx] = tile_data[src_idx]; } } } @@ -196,8 +201,18 @@ fn parse_vers_tiles( if tiles_decoded == 0 { if let Some(dp) = default_pixel { - for chunk in pixels.chunks_exact_mut(4) { - chunk.copy_from_slice(&dp); + if pixel_size == 1 { + let v = dp.get(0).copied().unwrap_or(0); + for p in pixels.iter_mut() { + *p = v; + } + } else { + for chunk in pixels.chunks_exact_mut(4) { + chunk[0] = dp[2]; + chunk[1] = dp[1]; + chunk[2] = dp[0]; + chunk[3] = dp[3]; + } } } } @@ -624,10 +639,13 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res let mut height = 600u32; let mut layers_info = Vec::new(); let mut buf = Vec::new(); + let mut layer_stack: Vec = Vec::new(); loop { - match reader.read_event_into(&mut buf) { - Ok(Event::Start(e)) | Ok(Event::Empty(e)) => { + let event = reader.read_event_into(&mut buf); + match event { + Ok(Event::Start(ref e)) | Ok(Event::Empty(ref e)) => { + let is_empty = matches!(event, Ok(Event::Empty(_))); let name = e.name(); let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase(); if tag_name == "image" { @@ -681,7 +699,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res if has_filename && !filename.is_empty() { layers_info.push(KritaLayerInfo { name, - filename, + filename: filename.clone(), visible, opacity, x: lx, @@ -689,10 +707,29 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res nodetype, compositeop, layerstyle_uuid, + parent_filename: layer_stack.last().cloned(), }); + if !is_empty { + layer_stack.push(filename); + } } } } + Ok(Event::End(ref e)) => { + let name = e.name(); + let tag_name = std::str::from_utf8(name.as_ref()).unwrap_or("").to_lowercase(); + if tag_name == "layer" + || tag_name == "paintlayer" + || tag_name == "vectorlayer" + || tag_name == "filllayer" + || tag_name == "shapelayer" + || tag_name == "adjustmentlayer" + || tag_name == "filterlayer" + || tag_name == "grouplayer" + { + layer_stack.pop(); + } + } Ok(Event::Eof) => break, Err(e) => return Err(format!("XML parse error: {}", e)), _ => {} @@ -701,7 +738,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res } let all_files: Vec = archive.file_names().map(|n| n.to_string()).collect(); - let mut layers = Vec::new(); + let mut intermediate_layers: Vec<(String, Layer)> = Vec::new(); let asl_styles: std::collections::HashMap> = { let mut styles_map = std::collections::HashMap::new(); @@ -724,7 +761,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res styles_map }; - for info in layers_info.into_iter().rev() { + for info in layers_info.clone().into_iter().rev() { if info.nodetype == "adjustmentlayer" || info.nodetype == "filterlayer" { let pixels = vec![0u8; (width * height * 4) as usize]; let mut layer = Layer::from_rgba(&info.name, width, height, pixels); @@ -739,7 +776,11 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res layer.styles = effects.iter().map(kra_effect_to_layer_style).collect(); } } - layers.push(layer); + intermediate_layers.push((info.filename.clone(), layer)); + continue; + } + + if info.nodetype == "transparencymask" { continue; } @@ -827,19 +868,49 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive) -> Res layer.adjustment_raw = Some((*b"svg ", buf.clone())); } } - if !info.layerstyle_uuid.is_empty() { if let Some(effects) = asl_styles.get(&info.layerstyle_uuid) { layer.effects = effects.clone(); layer.styles = effects.iter().map(kra_effect_to_layer_style).collect(); } } - - layers.push(layer); + intermediate_layers.push((info.filename.clone(), layer)); } + // Pass 2: Transparency Masks + let layers_info_clone = layers_info.clone(); + for info in layers_info_clone.into_iter().rev() { + if info.nodetype == "transparencymask" { + if let Some(parent_filename) = info.parent_filename { + let layer_path = find_krita_layer_file(&all_files, &info.filename, &info.nodetype); + if let Some(zip_path) = layer_path { + let mut buf = Vec::new(); + { + let mut file = match archive.by_name(&zip_path) { + Ok(f) => f, + Err(_) => continue, + }; + let _ = file.read_to_end(&mut buf); + } + if !buf.is_empty() { + let doc_w = width; + let doc_h = height; + let default_pixel = read_defaultpixel(archive, &info.filename); + if let Ok(mask_pixels) = parse_vers_tiles(&buf, doc_w, doc_h, info.x, info.y, default_pixel) { + if let Some((_, parent_layer)) = intermediate_layers.iter_mut().find(|(fname, _)| *fname == parent_filename) { + parent_layer.mask_pixels = Some(mask_pixels); + } + } + } + } + } + } + } + + let layers = intermediate_layers.into_iter().map(|(_, l)| l).collect::>(); + if layers.is_empty() { - Err("No valid layers found in maindoc.xml".to_string()) + return Err("No valid layers found in maindoc.xml".to_string()); } else { Ok(layers) } diff --git a/hcie-native/src/lib.rs b/hcie-native/src/lib.rs index cae35d3..48700c5 100644 --- a/hcie-native/src/lib.rs +++ b/hcie-native/src/lib.rs @@ -27,6 +27,14 @@ struct HcieLayerMeta { effects: Vec, #[serde(default = "default_fill_opacity")] fill_opacity: f32, + #[serde(default)] + mask_bounds: Option<[i32; 4]>, + #[serde(default)] + mask_default_color: u8, + #[serde(default)] + mask_offset: Option, + #[serde(default)] + mask_len: Option, } fn default_fill_opacity() -> f32 { 1.0 } @@ -44,10 +52,22 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> { let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; - let mut pixel_offset = 0u64; + let mut binary_offset = 0u64; let mut layer_metas = Vec::with_capacity(layers.len()); for layer in layers { let pixel_len = layer.pixels.len() as u64; + let pixel_offset = binary_offset; + binary_offset += pixel_len; + + let (mask_offset, mask_len) = if let Some(ref mask) = layer.mask_pixels { + let m_off = binary_offset; + let m_len = mask.len() as u64; + binary_offset += m_len; + (Some(m_off), Some(m_len)) + } else { + (None, None) + }; + layer_metas.push(HcieLayerMeta { id: layer.id, name: layer.name.clone(), @@ -67,8 +87,11 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> { pixel_len, effects: layer.effects.clone(), fill_opacity: layer.fill_opacity, + mask_bounds: layer.mask_bounds, + mask_default_color: layer.mask_default_color, + mask_offset, + mask_len, }); - pixel_offset += pixel_len; } let doc_meta = HcieDocumentMeta { @@ -88,6 +111,9 @@ pub fn save_native(layers: &[Layer], path: &Path) -> Result<(), String> { for layer in layers { file.write_all(&layer.pixels).map_err(|e| e.to_string())?; + if let Some(ref mask) = layer.mask_pixels { + file.write_all(mask).map_err(|e| e.to_string())?; + } } Ok(()) @@ -144,10 +170,49 @@ pub fn load_native(path: &Path) -> Result, String> { layer.id = meta.id; layer.effects = meta.effects; layer.fill_opacity = meta.fill_opacity; + + if let (Some(m_off), Some(m_len)) = (meta.mask_offset, meta.mask_len) { + let m_off_sz = m_off as usize; + let m_len_sz = m_len as usize; + if m_off_sz + m_len_sz <= pixel_data.len() { + layer.mask_pixels = Some(pixel_data[m_off_sz..m_off_sz + m_len_sz].to_vec()); + layer.mask_bounds = meta.mask_bounds; + layer.mask_default_color = meta.mask_default_color; + } + } + layers.push(layer); } Ok(layers) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_save_load_native_layer_mask() { + let mut layer = Layer::new_transparent("Test Layer", 10, 10); + let mask = vec![128u8; 100]; + layer.mask_pixels = Some(mask.clone()); + layer.mask_bounds = Some([0, 0, 10, 10]); + layer.mask_default_color = 255; + + let temp_dir = std::env::temp_dir(); + let path = temp_dir.join("test_mask_native.hcie"); + + save_native(&[layer], &path).expect("Failed to save native document"); + + let loaded = load_native(&path).expect("Failed to load native document"); + assert_eq!(loaded.len(), 1); + let loaded_layer = &loaded[0]; + assert_eq!(loaded_layer.mask_pixels, Some(mask)); + assert_eq!(loaded_layer.mask_bounds, Some([0, 0, 10, 10])); + assert_eq!(loaded_layer.mask_default_color, 255); + + let _ = std::fs::remove_file(path); + } +} + diff --git a/hcie-psd/src/psd_import.rs b/hcie-psd/src/psd_import.rs index a0921df..9861acf 100644 --- a/hcie-psd/src/psd_import.rs +++ b/hcie-psd/src/psd_import.rs @@ -615,7 +615,7 @@ pub fn parse_psd_sequential_full(bytes: &[u8]) -> Result let mut mask_default_color = 0; for channel in &layer.channels { - if channel.id == -2 { + if (channel.id == -2 || channel.id == -3) && decompressed_mask.is_none() { if let Some(mask) = &layer.mask_info { let w = (mask.right - mask.left) as u32; let h = (mask.bottom - mask.top) as u32; @@ -892,3 +892,19 @@ pub fn import_psd(path: &Path) -> Result, String> { Ok(flat_layers) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_example3_mask_import() { + let path = std::path::Path::new("/mnt/extra/00_PROJECTS/hcie-rust-v3.05/_images/_test_images/example3/Example3-mini.psd"); + if path.exists() { + let layers = import_psd(path).expect("import_psd failed"); + for l in &layers { + println!("TEST_LAYER: '{}' has_mask={} bounds={:?}", l.name, l.mask_pixels.is_some(), l.mask_bounds); + } + } + } +} diff --git a/notlar.txt b/notlar.txt index 3610d71..d6f0563 100755 --- a/notlar.txt +++ b/notlar.txt @@ -2,6 +2,7 @@ cargo run -p hcie-wx-app --bin hcie-wx-app cargo run --bin hcie-gui cargo run --example gui cargo run -p hcie-iced-gui +cargo build -p hcie-iced-gui --bin hcie-iced scripts/cargo-with-build-id.sh run -p hcie-iced-gui ##test