diff --git a/crates/engine/src/transform_cmds.rs b/crates/engine/src/transform_cmds.rs index ba6a1ba058..b73b68b96c 100644 --- a/crates/engine/src/transform_cmds.rs +++ b/crates/engine/src/transform_cmds.rs @@ -65,6 +65,101 @@ pub(crate) fn warp_gray(s: &Surface, h: &Homography, interp: Interp) -> Surface out } +/// Split a single-channel surface whose outside reads as its default (a mask, a channel) by a +/// selection: (the selected values as grey + alpha = selection, the surface with the selected +/// values replaced by the default). `None` for surfaces that aren't a single grey channel. +pub fn split_gray_selected(s: &Surface, sel: &Surface) -> Option<(Surface, Surface)> { + let fmt = s.format(); + if fmt.alpha || fmt.channels() != 1 { + return None; + } + let default = s.default_pixel().first().copied().unwrap_or(0.0); + let mut rest = s.clone(); + let mut lifted = Surface::new(PixelFormat::new(fmt.mode, fmt.sample, true)); + // Everything selected moves, including default-valued parts outside the painted content, so + // the moved region stays aligned with the moved pixels. + let area = sel.content_bounds(); + if area.is_empty() { + return Some((lifted, rest)); + } + let v = s.read_region(area); + let w = area.width() as usize; + let mut lp = Vec::with_capacity(v.len() * 2); + let mut rp = Vec::with_capacity(v.len()); + for (i, g) in v.iter().enumerate() { + let (x, y) = (area.x0 + (i % w) as i32, area.y0 + (i / w) as i32); + let k = sel.sample_channel(x, y, 0); + lp.extend([*g, k]); + rp.push(g * (1.0 - k) + default * k); + } + lifted.write_region(area, &lp); + lifted.prune(); + rest.write_region(area, &rp); + rest.prune(); + Some((lifted, rest)) +} + +/// [`warp_gray`] limited to a selection: only the selected values move (the vacated area reads as +/// the default), as the selected pixels of the layer do. +pub(crate) fn warp_gray_selected(s: &Surface, sel: &Surface, h: &Homography, interp: Interp) -> Surface { + let Some((lifted, mut out)) = split_gray_selected(s, sel) else { return warp_gray(s, h, interp) }; + let src = lifted.content_bounds(); + if src.is_empty() { + return out; + } + let w = warp_surface(&lifted, src, h, interp); + let b = w.content_bounds(); + if !b.is_empty() { + let moved = w.read_region(b); + let under = out.read_region(b); + let flat: Vec = moved.as_chunks::<2>().0.iter().zip(&under).map(|(p, u)| p[0] * p[1] + u * (1.0 - p[1])).collect(); + out.write_region(b, &flat); + } + out.prune(); + out +} + +/// The surface Free Transform moves by itself when the params target one (`"target"`): an +/// unlinked layer mask, an alpha channel or the Quick Mask. `None` means the layer, with its +/// linked masks (a targeted *linked* mask moves together with its layer, as in Photoshop). +pub fn lone_target<'a>(doc: &'a Document, layer: Option, p: &Value) -> Result> { + use crate::channel_cmds::Target; + match crate::channel_cmds::target_of(p) { + Target::Pixels => Ok(None), + Target::Mask => { + let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?; + let l = doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let m = l.mask.as_ref().ok_or_else(|| EngineError::Other("layer has no mask".into()))?; + Ok((!m.linked).then_some(&m.surface)) + } + Target::Alpha(i) => doc + .channels + .get(i) + .map(|c| Some(&c.surface)) + .ok_or_else(|| EngineError::Other(format!("no alpha channel {i} (document has {})", doc.channels.len()))), + Target::QuickMask => doc.quick_mask.as_ref().map(|c| Some(&c.surface)).ok_or_else(|| EngineError::Other("not in Quick Mask mode".into())), + } +} + +/// [`lone_target`], mutably. +pub fn lone_target_mut<'a>(doc: &'a mut Document, layer: Option, p: &Value) -> Result> { + if lone_target(doc, layer, p)?.is_none() { + return Ok(None); + } + crate::channel_cmds::target_surface(doc, layer, p).map(|(s, _)| Some(s)) +} + +/// The frame Free Transform starts from on a lone target: its painted content (the whole canvas +/// when nothing is painted), within the selection. +pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect { + let content = surf.content_bounds(); + let content = if content.is_empty() { doc.bounds() } else { content }; + match &doc.selection { + Some(sel) => content.intersect(&sel.content_bounds()), + None => content, + } +} + pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homography, affine: Option, interp: Interp) -> Result<()> { // Photoshop turns the Background into a normal layer before transforming it. if l.locks.position && l.name == "Background" { @@ -75,6 +170,9 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homo if l.locks.position || l.locks.all { return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); } + // With a selection only the selected pixels move, and so only the same region of a linked + // mask (#205). Groups, type, shapes and smart objects move whole. + let mask_sel = doc_sel.filter(|_| !matches!(l.content, LayerContent::Group(_) | LayerContent::Text(_) | LayerContent::Shape(_) | LayerContent::Smart(_))); match &mut l.content { LayerContent::Group(g) => { for c in g.children.iter_mut() { @@ -129,7 +227,10 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homo if let Some(m) = l.mask.as_mut() && m.linked { - m.surface = warp_gray(&m.surface, h, interp); + m.surface = match mask_sel { + Some(sel) => warp_gray_selected(&m.surface, sel, h, interp), + None => warp_gray(&m.surface, h, interp), + }; } if let Some(vm) = l.vector_mask.as_mut() && vm.linked @@ -219,17 +320,26 @@ fn quad_param(p: &Value) -> Option<[[f64; 2]; 4]> { fn transform(s: &mut Session, p: &Value) -> Result { let st = s.active().ok_or(EngineError::NoDocument)?; let id = match p.get("layer").and_then(Value::as_u64) { - Some(v) => LayerId(v), - None => st.active_layer.ok_or(EngineError::Other("no active layer".into()))?, + Some(v) => Some(LayerId(v)), + None => st.active_layer, + }; + let lone = lone_target(&st.doc, id, p)?; + let id = match (id, lone.is_some() && crate::channel_cmds::is_channel_target(p)) { + (_, true) => None, + (Some(id), false) => Some(id), + (None, false) => return Err(EngineError::Other("no active layer".into())), }; - let layer = st.doc.layer(id).ok_or(EngineError::NoLayer(id))?; let rect = match p.get("rect").and_then(Value::as_array) { Some(r) if r.len() == 4 => { let v: Vec = r.iter().map(|x| x.as_f64().unwrap_or(0.0)).collect(); [v[0], v[1], v[2], v[3]] } _ => { - let b = transform_bounds(&st.doc, layer); + let b = match (lone, id) { + (Some(surf), _) => target_bounds(&st.doc, surf), + (None, Some(id)) => transform_bounds(&st.doc, st.doc.layer(id).ok_or(EngineError::NoLayer(id))?), + (None, None) => Rect::EMPTY, + }; if b.is_empty() { return Err(EngineError::Other("nothing to transform".into())); } @@ -255,8 +365,22 @@ fn transform(s: &mut Session, p: &Value) -> Result { let affine = (m[6].abs() < 1e-12 && m[7].abs() < 1e-12).then(|| Affine { m: [m[0] / m[8], m[3] / m[8], m[1] / m[8], m[4] / m[8], m[2] / m[8], m[5] / m[8]] }); let interp = Interp::parse(p.get("interpolation").and_then(Value::as_str).unwrap_or("bicubic")); + let lone = lone.is_some(); s.edit("Free Transform", |doc, _| { let sel = doc.selection.clone(); + if lone { + // A targeted unlinked mask, alpha channel or Quick Mask transforms by itself. + let (surf, _) = crate::channel_cmds::target_surface(doc, id, p)?; + *surf = match &sel { + Some(sel) => warp_gray_selected(surf, sel, &h, interp), + None => warp_gray(surf, &h, interp), + }; + if let Some(sel) = &doc.selection { + doc.selection = Some(warp_gray(sel, &h, Interp::Bilinear)).filter(|s| !s.content_bounds().is_empty()); + } + return Ok(()); + } + let id = id.ok_or_else(|| EngineError::Other("no active layer".into()))?; let is_group = doc.layer(id).is_some_and(Layer::is_group); let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; transform_layer(if is_group { None } else { sel.as_ref() }, l, &h, affine, interp)?; @@ -280,7 +404,7 @@ pub fn specs() -> Vec { label: "Free Transform", menu: &[], shortcut: None, - params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic"}"##, + params: r##"{"layer":id?,"rect":[x0,y0,x1,y1]? (source frame; default = layer content ∩ selection),"quad":[[x,y]×4]? (where the frame's corners go, clockwise from top-left),"matrix":[a,b,c,d,e,f]? (affine alternative),"interpolation":"bicubic|bilinear|nearest"="bicubic","target":"pixels"|"mask"|"quickMask"|{"channel":i}=Channels panel target (an unlinked mask, an alpha channel or the Quick Mask transforms alone; a linked mask moves with its layer)}"##, enabled: has_layer, journal: true, run: transform, @@ -395,4 +519,148 @@ mod tests { let b1 = active_bounds(&s); assert_eq!((b1.x0 - b0.x0, b1.y0 - b0.y0), (20, 10)); } + + // ---- Layer masks and targets (#205) ---- + + /// `session()` plus a reveal-all mask with `hide` painted black (linked unless `linked` is false). + fn masked(hide: &[Rect], linked: bool) -> Session { + let mut s = session(); + s.execute("layer.layerMask.revealAll", json!({})).unwrap(); + s.edit("mask", |doc, active| { + let m = doc.layer_mut(active.unwrap()).unwrap().mask.as_mut().unwrap(); + for r in hide { + m.surface.fill_rect(*r, &[0.0]); + } + m.linked = linked; + Ok(()) + }) + .unwrap(); + s + } + + fn mask_at(s: &Session, x: i32, y: i32) -> f32 { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(x, y, 0) + } + + fn alpha_at(s: &Session, x: i32, y: i32) -> f32 { + let st = s.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().pixel(x, y)[3] + } + + #[test] + fn linked_raster_mask_moves_with_the_pixels() { + let mut s = masked(&[Rect::new(10, 10, 20, 20)], true); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70)); + assert!(mask_at(&s, 55, 65) < 0.01, "the hidden half moved with the pixels"); + assert!(mask_at(&s, 65, 65) > 0.99); + assert!(mask_at(&s, 15, 15) > 0.99, "nothing hidden left behind"); + } + + #[test] + fn linked_mask_with_a_selection_moves_only_the_selected_region() { + // The selection covers the left half of the red rect; the mask hides its left quarter + // (selected) and its right quarter (not selected). + let mut s = masked(&[Rect::new(10, 10, 15, 20), Rect::new(25, 10, 30, 20)], true); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50]})).unwrap(); + // Moved pixels and their mask line up. + assert_eq!(alpha_at(&s, 12, 65), 1.0); + assert!(mask_at(&s, 12, 65) < 0.01, "selected hidden quarter moved"); + assert!(mask_at(&s, 17, 65) > 0.99, "selected visible quarter moved"); + // The vacated area reads as revealed (default), with transparent pixels. + assert_eq!(alpha_at(&s, 12, 15), 0.0); + assert!(mask_at(&s, 12, 15) > 0.99); + // The unselected part of the mask stays, aligned with the unselected pixels. + assert_eq!(alpha_at(&s, 27, 15), 1.0); + assert!(mask_at(&s, 27, 15) < 0.01, "unselected mask stays put"); + assert!(mask_at(&s, 27, 65) > 0.99, "and isn't copied along"); + // Every pixel's mask value is where it was relative to the pixel. + for (x, y) in [(11, 61), (14, 69), (15, 61), (19, 69)] { + let want = if x < 15 { 0.0 } else { 1.0 }; + assert!((mask_at(&s, x, y) - want).abs() < 0.01, "({x},{y})"); + } + } + + #[test] + fn unlinked_mask_stays_put() { + let mut s = masked(&[Rect::new(10, 10, 20, 20)], false); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50]})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70)); + assert!(mask_at(&s, 15, 15) < 0.01); + assert!(mask_at(&s, 55, 65) > 0.99); + // With a selection too. + let mut s = masked(&[Rect::new(10, 10, 20, 20)], false); + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 10, "height": 10})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50]})).unwrap(); + assert!(mask_at(&s, 15, 15) < 0.01 && mask_at(&s, 15, 65) > 0.99); + } + + #[test] + fn targeted_unlinked_mask_transforms_alone() { + let mut s = masked(&[Rect::new(10, 10, 20, 20)], false); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": "mask"})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched"); + assert!(mask_at(&s, 55, 65) < 0.01, "mask moved"); + assert!(mask_at(&s, 15, 15) > 0.99, "vacated area reads as the default"); + s.undo(); + assert!(mask_at(&s, 15, 15) < 0.01, "one undo step"); + // With a selection, only its part of the mask. + s.execute("select.rect", json!({"x": 10, "y": 10, "width": 5, "height": 10})).unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 0, 50], "target": "mask"})).unwrap(); + assert!(mask_at(&s, 12, 65) < 0.01 && mask_at(&s, 12, 15) > 0.99); + assert!(mask_at(&s, 17, 15) < 0.01, "unselected mask stays"); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "pixels untouched"); + } + + #[test] + fn targeted_linked_mask_moves_with_its_layer() { + // Photoshop: a linked mask can't move without its layer. + let mut s = masked(&[Rect::new(10, 10, 20, 20)], true); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": "mask"})).unwrap(); + assert_eq!(active_bounds(&s), Rect::new(50, 60, 70, 70)); + assert!(mask_at(&s, 55, 65) < 0.01); + } + + #[test] + fn targeted_alpha_channel_transforms_alone() { + let mut s = session(); + s.execute("channel.new", json!({})).unwrap(); + s.edit("paint channel", |doc, _| { + doc.channels[0].surface.fill_rect(Rect::new(10, 10, 20, 20), &[1.0]); + Ok(()) + }) + .unwrap(); + s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 40, 50], "target": {"channel": 0}})).unwrap(); + let st = s.active().unwrap(); + let ch = &st.doc.channels[0].surface; + assert!(ch.sample_channel(55, 65, 0) > 0.99 && ch.sample_channel(15, 15, 0) < 0.01); + assert_eq!(active_bounds(&s), Rect::new(10, 10, 30, 20), "layer untouched"); + } + + #[test] + fn transform_targets_fail_gracefully() { + let mut s = session(); + let m = json!([1, 0, 0, 1, 5, 5]); + assert!(s.execute("edit.transform", json!({"matrix": m, "target": "mask"})).is_err(), "no mask"); + assert!(s.execute("edit.transform", json!({"matrix": m, "target": {"channel": 9}})).is_err()); + assert!(s.execute("edit.transform", json!({"matrix": m, "target": "quickMask"})).is_err()); + // Unknown target shapes fall back to the pixels. + s.execute("edit.transform", json!({"matrix": m, "target": 7})).unwrap(); + s.execute("edit.transform", json!({"matrix": m, "target": {"channel": "x"}})).unwrap(); + // Selections that miss the mask entirely transform nothing, without panicking. + let mut s = masked(&[Rect::new(10, 10, 20, 20)], false); + s.execute("select.rect", json!({"x": 80, "y": 80, "width": 5, "height": 5})).unwrap(); + let _ = s.execute("edit.transform", json!({"matrix": [1e9, 0, 0, 1e9, 0, 0], "target": "mask"})); + let _ = s.execute("edit.transform", json!({"matrix": [1, 0, 0, 1, 1e12, 0], "target": "mask"})); + } + + #[test] + fn split_gray_selected_rejects_non_gray() { + let s = Surface::new(PixelFormat::RGBA8); + let mut sel = Surface::new(PixelFormat::GRAY8); + sel.fill_rect(Rect::new(0, 0, 4, 4), &[1.0]); + assert!(split_gray_selected(&s, &sel).is_none()); + } } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 1f79c610a2..615fb054c0 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -470,6 +470,10 @@ pub struct TransformSession { /// Select › Transform Selection: the box transforms the selection outline, not pixels. #[serde(default)] pub selection: bool, + /// `edit.transform`'s `"target"` when the box moves an unlinked layer mask, an alpha channel or + /// the Quick Mask by itself (`None`: the layer, with its linked masks). + #[serde(default)] + pub target: Option, } /// In-progress inline type editing (Type tool). Offsets are character indices. diff --git a/crates/ui-egui/src/transform_tex.rs b/crates/ui-egui/src/transform_tex.rs index de44f884c8..2811a6b586 100644 --- a/crates/ui-egui/src/transform_tex.rs +++ b/crates/ui-egui/src/transform_tex.rs @@ -124,7 +124,14 @@ fn halve(img: &ColorImage) -> ColorImage { /// Reads `b` of `surf` as premultiplied texels, at full resolution when it fits in `max_side` /// (else box-filtered down by the smallest integer factor that fits). Returns the image and the /// uv extent of it that covers `b`. -pub fn read_surface(surf: &photocraft_raster::Surface, b: photocraft_geom::Rect, max_side: usize) -> (ColorImage, [f32; 2]) { +/// A layer mask applied to the preview texels: the texel's alpha is scaled by +/// `1 − density × (1 − value)`, as the compositor applies a pixel mask. +pub struct PreviewMask { + pub surface: photocraft_raster::Surface, + pub density: f32, +} + +pub fn read_surface(surf: &photocraft_raster::Surface, mask: Option<&PreviewMask>, b: photocraft_geom::Rect, max_side: usize) -> (ColorImage, [f32; 2]) { let (w, h) = (b.width() as usize, b.height() as usize); if w == 0 || h == 0 { return (ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]); @@ -139,6 +146,14 @@ pub fn read_surface(surf: &photocraft_raster::Surface, b: photocraft_geom::Rect, let rows = (y1 - y0).max(0) as usize; let mut buf = vec![[0u8; 4]; w * rows]; surf.read_rgba8_into(photocraft_geom::Rect::new(b.x0, y0, b.x1, y1), &mut buf); + if let Some(m) = mask { + let v = m.surface.read_region(photocraft_geom::Rect::new(b.x0, y0, b.x1, y1)); + let ch = m.surface.channels().max(1); + for (p, mv) in buf.iter_mut().zip(v.chunks_exact(ch)) { + let k = (1.0 - m.density * (1.0 - mv.first().copied().unwrap_or(1.0))).clamp(0.0, 1.0); + p[3] = (f32::from(p[3]) * k).round() as u8; + } + } if k == 1 { for (o, p) in out.iter_mut().zip(&buf) { *o = if p[3] == 255 { Color32::from_rgb(p[0], p[1], p[2]) } else { Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) }; @@ -204,21 +219,21 @@ mod tests { } } let r = photocraft_geom::Rect::new(0, 0, 500, 300); - let (img, uv) = read_surface(&s, r, 8192); + let (img, uv) = read_surface(&s, None, r, 8192); assert_eq!(img.size, [500, 300]); assert_eq!(uv, [1.0, 1.0]); assert_eq!(img.pixels[0], Color32::WHITE); assert_eq!(img.pixels[1].a(), 0); // Over the limit: box-filtered (grey-ish average), uv covers the padded edge. - let (small, uv) = read_surface(&s, r, 256); + let (small, uv) = read_surface(&s, None, r, 256); assert_eq!(small.size, [250, 150]); assert!((100..=155).contains(&small.pixels[0].a()), "{:?}", small.pixels[0]); assert_eq!(uv, [1.0, 1.0]); - let (odd, uv) = read_surface(&s, photocraft_geom::Rect::new(0, 0, 301, 3), 256); + let (odd, uv) = read_surface(&s, None, photocraft_geom::Rect::new(0, 0, 301, 3), 256); assert_eq!(odd.size, [151, 2]); assert!(uv[0] < 1.0 && uv[1] < 1.0); // Empty rect: a placeholder, no panic. - assert_eq!(read_surface(&s, photocraft_geom::Rect::new(5, 5, 5, 9), 256).0.size, [1, 1]); + assert_eq!(read_surface(&s, None, photocraft_geom::Rect::new(5, 5, 5, 9), 256).0.size, [1, 1]); } #[test] diff --git a/crates/ui-egui/src/transform_tool.rs b/crates/ui-egui/src/transform_tool.rs index 86ec3a4a1d..d79322f36d 100644 --- a/crates/ui-egui/src/transform_tool.rs +++ b/crates/ui-egui/src/transform_tool.rs @@ -35,11 +35,17 @@ fn corners(r: [f64; 4]) -> [[f64; 2]; 4] { [[r[0], r[1]], [r[2], r[1]], [r[2], r[3]], [r[0], r[3]]] } -/// Start Free Transform on the active layer (or its selected pixels). +/// Start Free Transform on the active layer (or its selected pixels), or on the targeted unlinked +/// layer mask, alpha channel or Quick Mask. pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> { + let target = crate::canvas::paint_target(app); let st = app.session.active().ok_or("no document")?; - let id = st.active_layer.ok_or("no active layer")?; let doc = st.doc.clone(); + let lone = photocraft_engine::transform_cmds::lone_target(&doc, st.active_layer, &json!({ "target": target })).map_err(|e| e.to_string())?.cloned(); + if let Some(surf) = lone { + return begin_lone(app, ctx, doc, surf, target); + } + let id = st.active_layer.ok_or("no active layer")?; let layer = doc.layer(id).ok_or("no layer")?; if matches!(layer.content, LayerContent::Adjustment(_)) && layer.mask.is_none() { return Err("Adjustment layers have nothing to transform".into()); @@ -78,6 +84,60 @@ pub fn begin(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<(), String> interpolation: "bicubic".into(), warp: None, selection: false, + target: None, + }); + Ok(()) +} + +/// Free Transform of a targeted unlinked layer mask, alpha channel or Quick Mask by itself: the +/// box frames its content (within the selection) and previews the moving values in grey over the +/// document with them vacated. +fn begin_lone( + app: &mut PhotocraftApp, + ctx: &egui::Context, + doc: Arc, + surf: photocraft_raster::Surface, + target: serde_json::Value, +) -> Result<(), String> { + use photocraft_engine::transform_cmds as tc; + let b = tc::target_bounds(&doc, &surf); + if b.is_empty() { + return Err("Could not transform: nothing is selected".into()); + } + let whole; + let sel = match &doc.selection { + Some(s) => s, + None => { + let mut s = photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8); + s.fill_rect(b, &[1.0]); + whole = s; + &whole + } + }; + let (lifted, rest) = tc::split_gray_selected(&surf, sel).ok_or("this channel can't be transformed")?; + crate::type_tool::commit(app); + let layer = app.session.active().and_then(|st| st.active_layer).map_or(0, |l| l.0); + let mut pd = (*doc).clone(); + let tp = json!({ "target": target }); + if let Ok(Some(s)) = tc::lone_target_mut(&mut pd, Some(LayerId(layer)), &tp) { + *s = rest; + } + let rect = [b.x0 as f64, b.y0 as f64, b.x1 as f64, b.y1 as f64]; + let session = app.ui.alloc_id(); + let max_side = ctx.input(|i| i.max_texture_side).max(app.gpu.as_ref().map_or(1, |g| g.max_texture_side())); + let (image, uv) = crate::transform_tex::read_surface(&lifted, None, b, max_side); + let texture = crate::transform_tex::PreviewTextures::new(ctx, format!("transform-{session}"), image, uv); + app.transform_preview = Some(TransformPreview { session, doc: Arc::new(pd), texture, opacity: 0.6, gesture: None, warp_drag: None }); + app.ui.transform = Some(TransformSession { + session, + layer, + rect, + quad: corners(rect), + pivot: [(rect[0] + rect[2]) / 2.0, (rect[1] + rect[3]) / 2.0], + interpolation: "bicubic".into(), + warp: None, + selection: false, + target: Some(target), }); Ok(()) } @@ -120,6 +180,7 @@ pub fn begin_selection(app: &mut PhotocraftApp, ctx: &egui::Context) -> Result<( interpolation: "bilinear".into(), warp: None, selection: true, + target: None, }); Ok(()) } @@ -139,7 +200,8 @@ pub fn enter_warp(app: &mut PhotocraftApp) { let existing = app.session.active().and_then(|d| d.doc.layer(LayerId(app.ui.transform.as_ref()?.layer)).and_then(photocraft_engine::warp_cmds::smart_warp_doc_space)); let Some(t) = app.ui.transform.as_mut() else { return }; - if t.warp.is_some() { + // Warp moves layers only: a lone mask or channel keeps the box. + if t.warp.is_some() || t.target.is_some() { return; } if t.quad == corners(t.rect) @@ -178,6 +240,8 @@ fn preview_image( max_side: usize, ) -> (egui::ColorImage, [f32; 2]) { let layer = doc.layer(id); + // Linked masks move with the pixels, so the moving texels carry them (#205). + let mask = layer.and_then(|l| preview_mask(l, b)); // Groups and type/fill layers preview from a flattened render of just that layer. let rendered; let surf: Option<&photocraft_raster::Surface> = match (lifted, layer.and_then(|l| l.surface())) { @@ -199,7 +263,26 @@ fn preview_image( } }; let Some(surf) = surf else { return (egui::ColorImage::new([1, 1], vec![Color32::TRANSPARENT]), [1.0, 1.0]) }; - crate::transform_tex::read_surface(surf, b, max_side) + // A flattened render already has the masks applied. + let raw = lifted.is_some() || layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))); + crate::transform_tex::read_surface(surf, mask.as_ref().filter(|_| raw), b, max_side) +} + +/// The layer's masks as the moving texels carry them: the enabled masks when all of them are linked +/// (the vector mask and feathering folded in as the compositor applies them), else the linked +/// pixel mask alone. `None` when no enabled mask moves with the pixels. +fn preview_mask(l: &photocraft_doc::Layer, b: photocraft_geom::Rect) -> Option { + use crate::transform_tex::PreviewMask; + let pixel = l.mask.as_ref().filter(|m| m.enabled); + let vector = l.vector_mask.as_ref().filter(|v| v.enabled); + let linked_pixel = pixel.filter(|m| m.linked); + if pixel.is_none_or(|m| m.linked) + && vector.is_some_and(|v| v.linked) + && let Some(surface) = photocraft_compose::masks::combined_mask(l, b) + { + return Some(PreviewMask { surface, density: 1.0 }); + } + linked_pixel.map(|m| PreviewMask { surface: m.surface.clone(), density: m.density }) } fn contains(l: &photocraft_doc::Layer, id: LayerId) -> bool { @@ -234,7 +317,11 @@ pub fn commit(app: &mut PhotocraftApp) { if t.quad == corners(t.rect) { return; // untouched: nothing to do (Photoshop adds no history step either) } - if let Err(e) = app.run("edit.transform", json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation})) { + let mut p = json!({"layer": t.layer, "rect": t.rect, "quad": t.quad, "interpolation": t.interpolation}); + if let Some(target) = t.target { + p["target"] = target; + } + if let Err(e) = app.run("edit.transform", p) { app.ui.status = e; } } @@ -908,6 +995,7 @@ mod tests { interpolation: "bicubic".into(), warp: None, selection: false, + target: None, } } @@ -1072,6 +1160,14 @@ mod tests { let t0 = std::time::Instant::now(); begin(&mut app, &ctx).unwrap(); let begin_ms = t0.elapsed().as_secs_f64() * 1e3; + // The same with a linked layer mask applied to the texels (#205). + cancel(&mut app); + app.session.execute("layer.layerMask.revealAll", json!({})).unwrap(); + app.session.execute("paint.gradient", json!({"from": [0, 0], "to": [w, 0], "colors": ["#000000", "#ffffff"], "target": "mask"})).unwrap(); + let t0 = std::time::Instant::now(); + begin(&mut app, &ctx).unwrap(); + let masked_ms = t0.elapsed().as_secs_f64() * 1e3; + eprintln!("transform preview begin {w}x{h}: {begin_ms:.1} ms, with a linked mask {masked_ms:.1} ms"); let size = app.transform_preview.as_ref().unwrap().texture.size(); rotate_about_pivot(&mut app, 0.05); let frame = |zoom: f32| { @@ -1147,4 +1243,91 @@ mod tests { leave_warp(&mut app); assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); } + + // ---- Layer masks (#205) ---- + + /// `app_with_square` (red over 8..24) plus a reveal-all mask hiding x < 16. + fn masked_app(linked: bool) -> PhotocraftApp { + let mut app = app_with_square(64, photocraft_geom::Rect::new(8, 8, 24, 24)); + app.session.execute("layer.layerMask.revealAll", json!({})).unwrap(); + app.session + .edit("mask", |doc, a| { + let m = doc.layer_mut(a.unwrap()).unwrap().mask.as_mut().unwrap(); + m.surface.fill_rect(photocraft_geom::Rect::new(0, 0, 16, 64), &[0.0]); + m.linked = linked; + Ok(()) + }) + .unwrap(); + app + } + + fn texel_alpha(img: &egui::ColorImage, x: usize, y: usize) -> u8 { + img.pixels[y * img.size[0] + x].a() + } + + #[test] + fn preview_applies_the_linked_mask() { + for linked in [true, false] { + let app = masked_app(linked); + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + let b = photocraft_geom::Rect::new(8, 8, 24, 24); + let (img, _) = preview_image(&st.doc, id, None, b, 4096); + // Texel x = doc x − 8: x 10 is hidden by the mask, x 20 shows. + assert_eq!(texel_alpha(&img, 12, 4), 255); + assert_eq!(texel_alpha(&img, 2, 4), if linked { 0 } else { 255 }, "linked={linked}"); + } + // With a selection the lifted pixels carry the mask too. + let mut app = masked_app(true); + app.session.execute("select.rect", json!({"x": 8, "y": 8, "width": 12, "height": 16})).unwrap(); + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + let st = app.session.active().unwrap(); + let sel = st.doc.selection.clone().unwrap(); + let id = st.active_layer.unwrap(); + let (lifted, _) = photocraft_engine::transform_cmds::split_selected(st.doc.layer(id).unwrap().surface().unwrap(), &sel); + let b = photocraft_geom::Rect::new(8, 8, 20, 24); + let (img, _) = preview_image(&st.doc, id, Some(&lifted), b, 4096); + assert_eq!(texel_alpha(&img, 2, 4), 0, "masked"); + assert_eq!(texel_alpha(&img, 10, 4), 255, "revealed"); + } + + #[test] + fn targeted_unlinked_mask_transforms_alone_through_the_box() { + let mut app = masked_app(false); + app.ui.mask_target = true; + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + let t = app.ui.transform.as_ref().unwrap(); + assert_eq!(t.target, Some(json!("mask"))); + assert_eq!(t.rect, [0.0, 0.0, 16.0, 64.0], "the box frames the mask's content"); + // The preview document shows the mask vacated (revealed). + let pd = &app.transform_preview.as_ref().unwrap().doc; + assert!(pd.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().mask.as_ref().unwrap().surface.sample_channel(4, 4, 0) > 0.99); + // Move the box 20 px right and commit. + let t = app.ui.transform.as_mut().unwrap(); + t.quad = t.quad.map(|[x, y]| [x + 20.0, y]); + commit(&mut app); + assert!(!app.ui.status_error, "{}", app.ui.status); + let st = app.session.active().unwrap(); + let l = st.doc.layer(st.active_layer.unwrap()).unwrap(); + let m = &l.mask.as_ref().unwrap().surface; + assert!(m.sample_channel(4, 30, 0) > 0.99 && m.sample_channel(30, 30, 0) < 0.01, "mask moved 20 px right"); + assert_eq!(l.surface().unwrap().content_bounds(), photocraft_geom::Rect::new(8, 8, 24, 24), "pixels untouched"); + // Warp mode doesn't apply to a lone mask. + app.ui.mask_target = true; + begin(&mut app, &ctx).unwrap(); + enter_warp(&mut app); + assert!(app.ui.transform.as_ref().unwrap().warp.is_none()); + } + + #[test] + fn linked_mask_target_transforms_the_layer() { + let mut app = masked_app(true); + app.ui.mask_target = true; + let ctx = egui::Context::default(); + begin(&mut app, &ctx).unwrap(); + assert_eq!(app.ui.transform.as_ref().unwrap().target, None); + assert_eq!(app.ui.transform.as_ref().unwrap().rect, [8.0, 8.0, 24.0, 24.0]); + } }