feat: implement layer mask editing support with scratch buffer and coordinate normalization
mandatory-regression-gate / deterministic-tests (push) Has been cancelled
mandatory-regression-gate / protected-performance-path (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-24 06:01:30 +03:00
parent 7496f5e208
commit 505562424b
16 changed files with 1224 additions and 155 deletions
+9
View File
@@ -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"
+1 -1
View File
@@ -102,7 +102,7 @@ approx = "0.5"
[profile.dev]
opt-level = 1
codegen-units = 1
codegen-units = 256
[profile.dev.package."*"]
opt-level = 3
+118
View File
@@ -307,6 +307,29 @@ impl Document {
}));
}
pub fn push_mask_draw_snapshot_subrect(
&mut self,
layer_idx: usize,
before_pixels: Vec<u8>,
after_pixels: Vec<u8>,
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<u8>,
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<Vec<Layer>> for DrawAction {
}
}
struct MaskDrawAction {
layer_index: usize,
before_pixels: Vec<u8>,
after_pixels: Vec<u8>,
bounds: Option<[u32; 4]>,
description: String,
}
impl UndoableAction<Vec<Layer>> for MaskDrawAction {
fn undo(&mut self, layers: &mut Vec<Layer>) {
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<Layer>) {
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,
+98
View File
@@ -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<u8>, 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;
}
}
}
}
+7
View File
@@ -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<Vec<u8>>,
mask_before_buf: Option<Vec<u8>>,
tile_layers: Vec<Option<TiledLayer>>,
svg_sources: std::collections::HashMap<u64, Vec<u8>>,
filter_preview_original: Option<Vec<u8>>,
stroke_effects_backup: Option<Vec<hcie_protocol::effects::LayerEffect>>,
stroke_styles_backup: Option<Vec<hcie_protocol::LayerStyle>>,
pub is_eraser: bool,
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<Vec<u8>>,
/// Pooled RGBA scratch buffer for layer mask editing passes.
pub mask_rgba_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.
@@ -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,
+261 -65
View File
@@ -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<F>(&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;
+134 -17
View File
@@ -59,6 +59,10 @@ pub struct PendingHistoryItem {
pub return_before: Option<Vec<u8>>,
/// Full-layer after buffer returned from background thread for pool reuse.
pub return_after: Option<Vec<u8>>,
/// Grayscale before mask buffer returned from background thread for pool reuse.
pub return_mask_before: Option<Vec<u8>>,
/// 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!(
+33
View File
@@ -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());
}
+4
View File
@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" xmlns="http://www.w3.org/2000/svg">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>

After

Width:  |  Height:  |  Size: 268 B

@@ -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;
@@ -173,15 +173,23 @@ fn layer_thumbnail<'a>(
thumb_cache: &std::collections::HashMap<u64, (u32, u32, Vec<u8>)>,
_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),
+157 -33
View File
@@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str("<!DOCTYPE DOC PUBLIC '-//KDE//DTD krita 2.0//EN' 'http://www.calligra.org/DTD/krita-2.0.dtd'>\n");
xml.push_str("<DOC xmlns=\"http://www.calligra.org/DTD/krita\" editor=\"Krita\" kritaVersion=\"6.0.1\" syntaxVersion=\"2.0\">\n");
let escaped_doc_name = name
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;");
xml.push_str(&format!(
" <IMAGE width=\"{}\" height=\"{}\" name=\"{}\" mime=\"application/x-kra\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x-res=\"100\" y-res=\"100\">\n",
w, h, name
w, h, escaped_doc_name
));
xml.push_str(" <layers>\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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;");
xml.push_str(&format!(
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\" />\n",
layer.name, filename, node_type, visible, opacity, uuid, compositeop
" <layer name=\"{}\" filename=\"{}\" nodetype=\"{}\" visible=\"{}\" opacity=\"{}\" uuid=\"{}\" compositeop=\"{}\" colorspacename=\"RGBA\" channeldepth=\"U8\" profile=\"sRGB-elle-V2-g10.icc\" x=\"0\" y=\"0\">\n",
escaped_name, filename, node_type, visible, opacity, uuid, compositeop
));
if layer.mask_pixels.is_some() {
xml.push_str(" <layers>\n");
let mask_filename = format!("{}_mask", filename);
let mask_uuid = format!("{{{:032x}}}", layer.id.wrapping_add(1)); // pseudo uuid
xml.push_str(&format!(
" <layer name=\"Transparency Mask\" filename=\"{}\" nodetype=\"transparencymask\" visible=\"1\" opacity=\"255\" uuid=\"{}\" compositeop=\"normal\" colorspacename=\"Alpha\" channeldepth=\"U8\" profile=\"\" x=\"0\" y=\"0\" />\n",
mask_filename, mask_uuid
));
xml.push_str(" </layers>\n");
}
xml.push_str(" </layer>\n");
}
xml.push_str(" </layers>\n");
@@ -285,19 +321,107 @@ fn encode_layer_vers(layer: &Layer) -> Vec<u8> {
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<u8> {
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<u8>)> = 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 {
+85 -14
View File
@@ -40,6 +40,7 @@ pub fn import_kra(path: &Path) -> Result<Vec<Layer>, 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<String>,
}
@@ -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<std::fs::File>) -> Res
let mut height = 600u32;
let mut layers_info = Vec::new();
let mut buf = Vec::new();
let mut layer_stack: Vec<String> = 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<std::fs::File>) -> 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<std::fs::File>) -> 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<std::fs::File>) -> Res
}
let all_files: Vec<String> = 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<String, Vec<hcie_protocol::effects::LayerEffect>> = {
let mut styles_map = std::collections::HashMap::new();
@@ -724,7 +761,7 @@ fn try_import_krita_maindoc(archive: &mut zip::ZipArchive<std::fs::File>) -> 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<std::fs::File>) -> 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<std::fs::File>) -> 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::<Vec<_>>();
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)
}
+67 -2
View File
@@ -27,6 +27,14 @@ struct HcieLayerMeta {
effects: Vec<hcie_protocol::effects::LayerEffect>,
#[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<u64>,
#[serde(default)]
mask_len: Option<u64>,
}
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<Vec<Layer>, 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);
}
}
+17 -1
View File
@@ -615,7 +615,7 @@ pub fn parse_psd_sequential_full(bytes: &[u8]) -> Result<ParsedPsdData, String>
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<Vec<hcie_protocol::Layer>, 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);
}
}
}
}
+1
View File
@@ -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