Free Transform: selection moves only its part of a linked mask; masked preview; target masks and channels (#205) (#242) (#242)
This commit is contained in:
parent
e2444ceb68
commit
5a5faccd4a
|
|
@ -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<f32> = 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<LayerId>, p: &Value) -> Result<Option<&'a Surface>> {
|
||||
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<LayerId>, p: &Value) -> Result<Option<&'a mut Surface>> {
|
||||
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<Affine>, 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<Value> {
|
||||
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<f64> = 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<Value> {
|
|||
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<CommandSpec> {
|
|||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// In-progress inline type editing (Type tool). Offsets are character indices.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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<Document>,
|
||||
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<crate::transform_tex::PreviewMask> {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue