fix(layers): a locked group locks the layers inside it (#847)

Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
reese 2026-10-07 19:18:27 -05:00 committed by GitHub
parent 6f39aba7f2
commit 2873afdda9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 171 additions and 72 deletions

View File

@ -86,6 +86,18 @@ pub struct Locks {
pub all: bool,
}
impl Locks {
pub fn union(self, o: Locks) -> Locks {
Locks {
transparency: self.transparency || o.transparency,
pixels: self.pixels || o.pixels,
position: self.position || o.position,
artboard: self.artboard || o.artboard,
all: self.all || o.all,
}
}
}
/// Photoshop layer colour labels.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum LabelColor {
@ -797,6 +809,17 @@ impl Document {
self.walk().into_iter().find(|(_, _, l)| l.id == id).map(|(p, _, _)| p)
}
/// The locks in force on the layer at `path`: its own and those of every group around it,
/// since locking a group locks its contents.
pub fn locks_at(&self, path: &[usize]) -> Locks {
(1..=path.len()).filter_map(|n| self.layer_at(path.get(..n)?)).fold(Locks::default(), |a, l| a.union(l.locks))
}
/// [`Self::locks_at`] for the layer `id`.
pub fn effective_locks(&self, id: LayerId) -> Locks {
self.path_of(id).map_or_else(Locks::default, |p| self.locks_at(&p))
}
pub fn layer(&self, id: LayerId) -> Option<&Layer> {
let path = self.path_of(id)?;
self.layer_at(&path)
@ -982,6 +1005,21 @@ mod tests {
assert_eq!(d.layer_count(), 4);
}
#[test]
fn a_locked_group_locks_its_contents() {
let mut d = doc();
let inner = Layer::raster("inner", d.pixel_format());
let inner_id = inner.id;
let mut g = Layer::group("G", vec![Layer::group("H", vec![inner])]);
g.locks.position = true;
let gid = d.insert_above(None, g);
d.layer_mut(inner_id).unwrap().locks.pixels = true;
let k = d.effective_locks(inner_id);
assert!(k.position && k.pixels && !k.all);
assert!(!d.effective_locks(gid).pixels, "a child's lock doesn't lock its group");
assert_eq!(d.effective_locks(LayerId(u64::MAX)), Locks::default());
}
#[test]
fn duplicate_assigns_fresh_ids_recursively() {
let g = Layer::group("G", vec![Layer::raster("x", PixelFormat::RGBA8)]);

View File

@ -182,8 +182,7 @@ fn rgba_edit(s: &mut Session, label: &str, p: &Value, mut f: impl FnMut(&mut Vec
let id = crate::commands::layer_param(s, &Value::Null)?;
s.edit(label, |doc, _| {
let sel = doc.selection.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
process_surface(surf, sel.as_ref(), &mut f);
Ok(Value::Null)
})

View File

@ -486,9 +486,8 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = Stroke { brush, points: pts };
let dmg = s.edit("Color Replacement", |doc, _| {
let sel = doc.selection.clone();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let lock = l.locks.transparency;
let surf = paint_surface(l, &json!({}))?;
let lock = doc.effective_locks(id).transparency;
let surf = paint_surface(doc, id, &json!({}))?;
Ok(apply_color_replacement(surf, &stroke, &rs, sel.as_ref(), lock))
})?;
Ok(damage_json(s, dmg))

View File

@ -514,9 +514,8 @@ pub(crate) fn target_surface<'a>(doc: &'a mut Document, layer: Option<LayerId>,
Target::QuickMask => doc.quick_mask.as_mut().map(|c| (&mut c.surface, false)).ok_or_else(|| EngineError::Other("not in Quick Mask mode".into())),
t => {
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let lock = l.locks.transparency && t == Target::Pixels;
Ok((crate::commands::paint_surface(l, p)?, lock))
let lock = doc.effective_locks(id).transparency && t == Target::Pixels;
Ok((crate::commands::paint_surface(doc, id, p)?, lock))
}
}
}
@ -1361,8 +1360,7 @@ fn apply_image(s: &mut Session, p: &Value) -> Result<Value> {
}
let id = layer.ok_or_else(|| EngineError::Other("no active layer".into()))?;
let fmt = doc.pixel_format();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let sf = surf.format();
if sf != fmt {
return Err(EngineError::Other("the target layer's format doesn't match the document".into()));

View File

@ -62,8 +62,10 @@ pub(crate) fn has_paintable(s: &Session) -> std::result::Result<(), String> {
}
/// Surface a paint command writes to: the layer's pixels, or its mask with `"target":"mask"`.
pub(crate) fn paint_surface<'a>(l: &'a mut Layer, p: &Value) -> Result<&'a mut photocraft_raster::Surface> {
if !is_mask_target(p) && (l.locks.pixels || l.locks.all) {
pub(crate) fn paint_surface<'a>(doc: &'a mut Document, id: LayerId, p: &Value) -> Result<&'a mut photocraft_raster::Surface> {
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if !is_mask_target(p) && (locks.pixels || locks.all) {
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
}
if is_mask_target(p) {

View File

@ -5,7 +5,7 @@
use photocraft_algo::matting::{self, RefineParams};
use photocraft_algo::segment::subject;
use photocraft_doc::{Layer, LayerContent, LayerMask};
use photocraft_doc::{Document, Layer, LayerContent, LayerMask};
use serde_json::{Value, json};
use crate::commands::{CommandSpec, layer_param};
@ -19,24 +19,26 @@ const CMD: &str = "layer.removeBackground";
const REFINE: RefineParams = RefineParams { radius: 2.0, smart_radius: true, smooth: 10.0, feather: 0.5, contrast: 10.0, shift_edge: 0.0 };
/// Remove Background needs an unlocked pixel layer.
fn check(l: &Layer) -> std::result::Result<(), String> {
fn check(doc: &Document, l: &Layer) -> std::result::Result<(), String> {
if !matches!(l.content, LayerContent::Raster(_)) {
return Err(format!("the layer is a {} layer, not a pixel layer", l.content.kind_name()));
}
if l.locks.pixels || l.locks.all {
let locks = doc.effective_locks(l.id);
if locks.pixels || locks.all {
return Err(format!("Could not complete your request because the layer \"{}\" is locked", l.name));
}
Ok(())
}
fn enabled(s: &Session) -> std::result::Result<(), String> {
check(crate::active_layer_of(s)?)
let d = s.active().ok_or("no document open")?;
check(&d.doc, crate::active_layer_of(s)?)
}
fn run(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
check(doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?;
check(doc, doc.layer(id).ok_or(EngineError::NoLayer(id))?).map_err(EngineError::Other)?;
let sample_all = p.get("sampleAllLayers").and_then(Value::as_bool).unwrap_or(false);
let refine = p.get("refine").and_then(Value::as_bool).unwrap_or(true);
crate::jobs::edit_job(

View File

@ -16,7 +16,7 @@ use photocraft_algo::puppet::{PuppetDensity, PuppetMode, PuppetPin, PuppetWarp,
use photocraft_algo::transform::Interp;
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, LayerId, SmartFilter};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, SmartFilter};
use photocraft_geom::Rect;
use photocraft_raster::Surface;
use serde_json::{Value, json};
@ -220,8 +220,9 @@ fn float_background(l: &mut Layer) {
}
}
fn check_locks(l: &Layer, position: bool) -> Result<()> {
if l.locks.all || l.locks.pixels || (position && l.locks.position) {
fn check_locks(l: &Layer, group: Locks, position: bool) -> Result<()> {
let locks = l.locks.union(group);
if locks.all || locks.pixels || (position && locks.position) {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
Ok(())
@ -248,6 +249,7 @@ fn run_on_layer(
s.edit(label, |doc: &mut Document, _| {
let canvas = doc.bounds();
let selection = doc.selection.clone();
let group = crate::transform_cmds::group_locks(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: params.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
@ -256,7 +258,7 @@ fn run_on_layer(
if moves {
float_background(l);
}
check_locks(l, moves)?;
check_locks(l, group, moves)?;
let LayerContent::Raster(surf) = &mut l.content else {
return Err(EngineError::Other(format!("{label} needs a pixel layer (rasterize it first)")));
};

View File

@ -114,15 +114,17 @@ fn pixel_layer(s: &Session) -> std::result::Result<LayerId, String> {
if !matches!(l.content, LayerContent::Raster(_)) {
return Err(format!("active layer is a {} layer, not a pixel layer", l.content.kind_name()));
}
if l.locks.all || l.locks.pixels {
let locks = d.doc.effective_locks(id);
if locks.all || locks.pixels {
return Err(format!("the layer \"{}\" is locked", l.name));
}
Ok(id)
}
fn writable_surface(doc: &mut Document, id: LayerId) -> Result<&mut Surface> {
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.all || l.locks.pixels {
if locks.all || locks.pixels {
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
}
l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))
@ -657,7 +659,7 @@ pub fn find_matches(text: &str, find: &str, case: bool, whole: bool) -> Vec<(usi
fn type_layers(doc: &Document, forward: bool) -> Vec<LayerId> {
// Layers panel order: top first.
let mut ids: Vec<LayerId> =
doc.walk().into_iter().rev().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_)) && !l.locks.all).map(|(_, _, l)| l.id).collect();
doc.walk().into_iter().rev().filter(|(p, _, l)| matches!(l.content, LayerContent::Text(_)) && !doc.locks_at(p).all).map(|(_, _, l)| l.id).collect();
if !forward {
ids.reverse();
}

View File

@ -8,7 +8,7 @@
//! work in the layer's own colour model and depth, and are one undo step per click / stroke.
use photocraft_algo::selection as sel;
use photocraft_doc::{Layer, LayerContent, LayerId};
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use photocraft_geom::Rect;
use photocraft_paint::Stroke;
use photocraft_paint::bg_erase::{BgEraseSettings, apply_background_eraser};
@ -68,8 +68,9 @@ fn unlock_background(l: &mut Layer) {
}
}
fn pixels_locked(l: &Layer) -> Option<EngineError> {
(l.locks.pixels || l.locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
fn pixels_locked(doc: &Document, l: &Layer) -> Option<EngineError> {
let locks = doc.effective_locks(l.id);
(locks.pixels || locks.all).then(|| EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)))
}
fn damage(s: &mut Session, dmg: Rect, erased: bool) -> Value {
@ -94,7 +95,7 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let id = active_pixel_layer(s, CMD)?;
{
let d = s.active().ok_or(EngineError::NoDocument)?;
if let Some(e) = d.doc.layer(id).and_then(pixels_locked) {
if let Some(e) = d.doc.layer(id).and_then(|l| pixels_locked(&d.doc, l)) {
return Err(e);
}
// Clicking off the canvas erases nothing (and records no history step).
@ -109,9 +110,9 @@ fn magic_eraser(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Magic Eraser", |doc, _| {
let selection = doc.selection.clone();
unlock_background(doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?);
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
unlock_background(l);
let lock = l.locks.transparency;
let surf = l.surface_mut().ok_or_else(|| bad(CMD, "not a pixel layer"))?;
let fmt = surf.format();
// Locked transparency: paint the background colour instead (Photoshop).
@ -141,10 +142,10 @@ fn background_eraser(s: &mut Session, p: &Value) -> Result<Value> {
{
let d = s.active().ok_or(EngineError::NoDocument)?;
let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?;
if let Some(e) = pixels_locked(l) {
if let Some(e) = pixels_locked(&d.doc, l) {
return Err(e);
}
if l.locks.transparency && !is_background(l) {
if d.doc.effective_locks(id).transparency && !is_background(l) {
return Err(EngineError::Other(format!("Could not use the Background Eraser because the transparency of layer \"{}\" is locked", l.name)));
}
}

View File

@ -68,11 +68,12 @@ fn map_pixels(s: &mut Session, label: &str, f: impl FnOnce(Rect, &mut [[f32; 4]]
let canvas = doc.bounds();
let selection = doc.selection.clone();
let area = selection.as_ref().map_or(canvas, |m| m.content_bounds().intersect(&canvas));
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.pixels || l.locks.all {
if locks.pixels || locks.all {
return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name)));
}
let lock_alpha = l.locks.transparency;
let lock_alpha = locks.transparency;
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?;
if area.is_empty() {
return Ok(());
@ -139,9 +140,8 @@ fn stroke(s: &mut Session, p: &Value) -> Result<Value> {
};
let band: Vec<f32> = outer.iter().zip(&inner).map(|(o, i)| (o - i).clamp(0.0, 1.0)).collect();
let band = sel::mask_to_surface(&band, canvas);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let lock = l.locks.transparency;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let lock = doc.effective_locks(id).transparency;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let area = band.content_bounds().intersect(&canvas);
if !area.is_empty() {
crate::pixels::fill_surface(surf, area, color, Some(&band), lock);

View File

@ -490,7 +490,8 @@ fn relight_enabled(s: &Session) -> std::result::Result<(), String> {
return Ok(());
}
let l = crate::active_layer_of(s)?;
if l.locks.pixels || l.locks.all { Err(format!("the layer \"{}\" is locked", l.name)) } else { Ok(()) }
let locks = s.active().ok_or("no document open")?.doc.effective_locks(l.id);
if locks.pixels || locks.all { Err(format!("the layer \"{}\" is locked", l.name)) } else { Ok(()) }
}
fn run_relight(s: &mut Session, p: &Value) -> Result<Value> {

View File

@ -120,7 +120,8 @@ fn can_float(s: &Session) -> std::result::Result<(), String> {
if !matches!(l.content, LayerContent::Raster(_)) {
return Err("the active layer has no pixels to move".into());
}
if l.locks.all || l.locks.position {
let locks = st.doc.effective_locks(l.id);
if locks.all || locks.position {
return Err(format!("layer \"{}\" is locked", l.name));
}
Ok(())

View File

@ -104,8 +104,7 @@ fn edit_rgba(s: &mut Session, label: &str, grow: i32, f: impl FnOnce(&mut Vec<[f
let id = layer_param(s, &Value::Null)?;
s.edit(label, |doc, _| {
let bounds = doc.bounds();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let r = surf.content_bounds().inflate(grow).intersect(&bounds.inflate(grow));
if r.is_empty() {
return Ok(Value::Null);
@ -124,9 +123,8 @@ fn edit_rgba(s: &mut Session, label: &str, grow: i32, f: impl FnOnce(&mut Vec<[f
fn apply_mask(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
s.edit("Apply Layer Mask", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let mask = l.mask.take().ok_or_else(|| other("the layer has no layer mask"))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let mask = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask.take().ok_or_else(|| other("the layer has no layer mask"))?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let r = surf.content_bounds();
if !r.is_empty() {
let mut px = read_rgba(surf, r);
@ -147,8 +145,7 @@ fn mask_from_transparency(s: &mut Session, p: &Value) -> Result<Value> {
let id = layer_param(s, p)?;
s.edit("From Transparency", |doc, _| {
let bounds = doc.bounds();
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let r = surf.content_bounds().intersect(&bounds);
let mut mask = LayerMask::hide_all();
if !r.is_empty() {
@ -161,7 +158,7 @@ fn mask_from_transparency(s: &mut Session, p: &Value) -> Result<Value> {
}
write_rgba(surf, r, &px);
}
l.mask = Some(mask);
doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.mask = Some(mask);
Ok(Value::Null)
})
}
@ -294,8 +291,7 @@ fn color_decontaminate(s: &mut Session, p: &Value) -> Result<Value> {
let radius = num(p, "radius", 4.0).clamp(1.0, 100.0);
let id = layer_param(s, &Value::Null)?;
s.edit("Color Decontaminate", |doc, _| {
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = crate::commands::paint_surface(l, &Value::Null)?;
let surf = crate::commands::paint_surface(doc, id, &Value::Null)?;
let r = surf.content_bounds();
if r.is_empty() || !surf.format().alpha {
return Ok(());

View File

@ -225,8 +225,9 @@ pub(crate) fn move_layers(doc: &mut Document, moves: &[(LayerId, i32, i32)]) ->
if dx == 0 && dy == 0 {
continue;
}
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.position || l.locks.all {
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
crate::commands::translate_layer(&snapshot, l, dx, dy);
@ -276,8 +277,9 @@ pub fn move_targets(doc: &Document, roots: &[LayerId]) -> Vec<LayerId> {
pub fn moved(doc: &Document, ids: &[LayerId], dx: i32, dy: i32) -> Result<Document> {
let mut out = doc.clone();
for &id in ids {
let locks = doc.effective_locks(id);
let l = out.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.position || l.locks.all {
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is position-locked", l.name)));
}
shift_shown(doc, l, dx, dy);
@ -1119,6 +1121,46 @@ mod tests {
}
}
#[test]
fn a_locked_group_locks_its_contents() {
let mut s = session(8);
let a = rect_layer(&mut s, Rect::new(0, 0, 5, 5));
let g = s.execute("layer.new.groupFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": g, "locked": true})).unwrap();
s.execute("layer.select", json!({"layer": a.0})).unwrap();
for (cmd, p) in [
("layer.translate", json!({"dx": 1})),
("paint.stroke", json!({"points": [[2, 2], [4, 4]]})),
("edit.fill", json!({"color": "#00ff00"})),
("edit.transform.flipHorizontal", json!({})),
] {
assert!(s.execute(cmd, p).is_err(), "{cmd} edited a layer in a locked group");
}
// Unlocking the group releases its contents.
s.execute("layer.setProps", json!({"layer": g, "locked": false})).unwrap();
s.execute("layer.translate", json!({"dx": 1})).unwrap();
assert_eq!(bounds(&s, a), Rect::new(1, 0, 6, 5));
}
#[test]
fn a_locked_group_keeps_a_grouped_background_locked() {
let mut s = session(8);
let bg = doc(&s).layers[0].id;
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
let g = s.execute("layer.new.groupFromLayers", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.setProps", json!({"layer": g, "locked": true})).unwrap();
s.execute("layer.select", json!({"layer": bg.0})).unwrap();
for (cmd, p) in [
("edit.transform.flipHorizontal", json!({})),
("edit.transform.warp", json!({"style": "flag", "bend": 50})),
("edit.puppetWarp", json!({"pins": [{"src": [30, 25], "dst": [36, 29]}]})),
] {
let e = s.execute(cmd, p).unwrap_err().to_string();
assert!(e.contains("locked"), "{cmd}: {e}");
assert_eq!(doc(&s).layer(bg).unwrap().name, "Background", "{cmd}");
}
}
#[test]
fn group_from_layers_preserves_order() {
let mut s = session(8);

View File

@ -263,12 +263,13 @@ fn run_filter(s: &mut Session, cmd: &str, label: &str, stored: Value, float_bg:
s.edit(label, |doc: &mut Document, _| {
let canvas = doc.bounds();
let selection = doc.selection.clone();
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if let LayerContent::Smart(_) = l.content {
let sf = SmartFilter { command: cmd.to_string(), params: stored.clone(), blend: photocraft_color::BlendMode::Normal, opacity: 1.0, visible: true };
return crate::smart_cmds::add_smart_filter(doc, id, sf, selection.as_ref());
}
if l.locks.all || l.locks.pixels {
if locks.all || locks.pixels {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
// A Background layer gains transparency when the edge mode exposes it (as in Photoshop).

View File

@ -102,7 +102,7 @@ fn rotate_arbitrary(s: &mut Session, p: &Value) -> Result<Value> {
let locks = l.locks;
l.locks.position = false;
l.locks.all = false;
crate::transform_cmds::transform_layer(None, l, &h, Some(a), interp)?;
crate::transform_cmds::transform_layer(None, photocraft_doc::Locks::default(), l, &h, Some(a), interp)?;
l.locks = locks;
}
// What Free Transform leaves alone: unlinked vector masks, gradient angles, artboards…

View File

@ -93,7 +93,8 @@ fn stamp_down(s: &mut Session, p: &Value) -> Result<Value> {
if !matches!(lower.content, LayerContent::Raster(_)) {
return Err(EngineError::Other("the layer below is not a pixel layer".into()));
}
if lower.locks.all || lower.locks.pixels {
let locks = doc.effective_locks(lower.id);
if locks.all || locks.pixels {
return Err(EngineError::Other("the layer below is locked".into()));
}
if !upper.visible {

View File

@ -2,7 +2,7 @@
use photocraft_algo::transform::{Homography, Interp, warp_surface};
use photocraft_color::PixelFormat;
use photocraft_doc::{Document, Layer, LayerContent, LayerId};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks};
use photocraft_geom::{Affine, Rect};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@ -160,14 +160,21 @@ pub fn target_bounds(doc: &Document, surf: &Surface) -> Rect {
}
}
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homography, affine: Option<Affine>, interp: Interp) -> Result<()> {
/// The locks `id` inherits from the groups around it.
pub(crate) fn group_locks(doc: &Document, id: LayerId) -> Locks {
doc.path_of(id).and_then(|p| Some(doc.locks_at(p.split_last()?.1))).unwrap_or_default()
}
/// `group` holds the locks `l` inherits from the groups around it ([`group_locks`]).
pub(crate) fn transform_layer(doc_sel: Option<&Surface>, group: Locks, 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" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
if l.locks.position || l.locks.all {
let locks = l.locks.union(group);
if locks.position || 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
@ -176,7 +183,7 @@ pub(crate) fn transform_layer(doc_sel: Option<&Surface>, l: &mut Layer, h: &Homo
match &mut l.content {
LayerContent::Group(g) => {
for c in g.children.iter_mut() {
transform_layer(None, c, h, affine, interp)?;
transform_layer(None, locks, c, h, affine, interp)?;
}
}
LayerContent::Text(t) => {
@ -383,8 +390,9 @@ fn transform(s: &mut Session, p: &Value) -> Result<Value> {
}
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 group = group_locks(doc, id);
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)?;
transform_layer(if is_group { None } else { sel.as_ref() }, group, l, &h, affine, interp)?;
// Type layers re-render from their new transform.
let snapshot = doc.clone();
if let Some(l) = doc.layer_mut(id) {

View File

@ -33,7 +33,7 @@ fn char_at(text: &str, bi: usize) -> usize {
/// Type layers top to bottom (Layers panel order), skipping fully locked ones.
fn type_layers(doc: &Document) -> Vec<LayerId> {
doc.walk().into_iter().rev().filter(|(_, _, l)| matches!(l.content, LayerContent::Text(_)) && !l.locks.all).map(|(_, _, l)| l.id).collect()
doc.walk().into_iter().rev().filter(|(p, _, l)| matches!(l.content, LayerContent::Text(_)) && !doc.locks_at(p).all).map(|(_, _, l)| l.id).collect()
}
fn has_type(s: &Session) -> std::result::Result<(), String> {

View File

@ -960,8 +960,8 @@ fn path_fill(s: &mut Session, p: &Value) -> Result<Value> {
if feather > 0.0 {
cov = sel::feather(&cov, area.width() as usize, area.height() as usize, feather);
}
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let lock = l.locks.transparency;
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Fill Path needs a pixel layer".into()))?;
paint_coverage(surf, area, &cov, src, opacity, mode, lock);
Ok(())
@ -1018,8 +1018,8 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let bg = s.tools.background;
let dmg = s.edit("Stroke Path", |doc, _| {
let sel = doc.selection.clone();
let lock = doc.effective_locks(id).transparency;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let lock = l.locks.transparency;
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?;
let mut brush = brush.clone();
if brush.erase && lock {
@ -1081,11 +1081,12 @@ fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result<Value>
path.inverted = !path.inverted;
}
s.edit("Add Vector Mask", |doc, _| {
let locked = doc.effective_locks(id).all;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if matches!(l.content, LayerContent::Shape(_)) {
return Err(EngineError::Other("a shape layer's path is already its vector mask".into()));
}
if l.locks.all {
if locked {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
l.vector_mask = Some(VectorMask::new(path));

View File

@ -138,8 +138,9 @@ fn vanishing_point(s: &mut Session, p: &Value) -> Result<Value> {
};
// A new layer clones from the visible image; otherwise from the layer itself.
let base: Option<Surface> = new_layer.then(|| crate::file_cmds::flattened(doc, doc.pixel_format()));
let locks = doc.effective_locks(id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
if l.locks.all || l.locks.pixels {
if locks.all || locks.pixels {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Vanishing Point needs a pixel layer".into()))?;

View File

@ -10,7 +10,7 @@
use photocraft_algo::transform::{Homography, Interp};
use photocraft_algo::warp::{warp_mesh_gray, warp_mesh_surface};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Rect};
use photocraft_doc::{Document, Layer, LayerContent, LayerId, Locks, Rect};
use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle};
use photocraft_raster::Surface;
use serde_json::{Value, json};
@ -110,20 +110,21 @@ pub fn warp_from_params(cmd: &str, p: &Value, rect: [f64; 4]) -> Result<Warp> {
Ok(w)
}
fn warp_layer(doc_sel: Option<&Surface>, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
fn warp_layer(doc_sel: Option<&Surface>, group: Locks, l: &mut Layer, w: &Warp, rect: Rect, interp: Interp) -> Result<()> {
if l.locks.position && l.name == "Background" {
l.locks.position = false;
l.locks.transparency = false;
l.name = "Layer 0".into();
}
if l.locks.position || l.locks.all {
let locks = l.locks.union(group);
if locks.position || locks.all {
return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name)));
}
let map = |x: f64, y: f64| w.map(x, y);
match &mut l.content {
LayerContent::Group(g) => {
for c in g.children.iter_mut() {
warp_layer(None, c, w, rect, interp)?;
warp_layer(None, locks, c, w, rect, interp)?;
}
}
LayerContent::Text(_) => return Err(EngineError::Other("Warp needs rasterized type (or use Type › Warp Text)".into())),
@ -211,8 +212,9 @@ fn apply(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
s.edit("Warp", |doc, _| {
let sel = doc.selection.clone();
let is_group = doc.layer(id).is_some_and(Layer::is_group);
let group = crate::transform_cmds::group_locks(doc, id);
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
warp_layer(if is_group { None } else { sel.as_ref() }, l, &w, rect, interp)?;
warp_layer(if is_group { None } else { sel.as_ref() }, group, l, &w, rect, interp)?;
let snapshot = doc.clone();
if let Some(l) = doc.layer_mut(id) {
crate::transform_cmds::refresh_text(&snapshot, l);

View File

@ -123,13 +123,14 @@ pub fn entry_enabled(app: &PhotocraftApp, menu: &CanvasToolMenu, command: &str)
}
let Some(st) = app.session.active() else { return false };
let layer = st.active_layer.and_then(|id| st.doc.layer(id));
let can_paint = layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_)) && !l.locks.all);
let locked = layer.is_some_and(|l| st.doc.effective_locks(l.id).all);
let can_paint = layer.is_some_and(|l| matches!(l.content, LayerContent::Raster(_))) && !locked;
let shape_layer = layer.and_then(|l| match &l.content {
LayerContent::Shape(sh) => Some(sh),
_ => None,
});
let shape = shape_layer.is_some();
let can_mask = layer.is_some_and(|l| !matches!(l.content, LayerContent::Shape(_)) && !l.locks.all);
let can_mask = layer.is_some_and(|l| !matches!(l.content, LayerContent::Shape(_))) && !locked;
let saved = menu.path_name.as_deref().is_some_and(|n| n != "work" && n != "layer");
let has_path = menu.has_path;
let pending = app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 2);

View File

@ -77,7 +77,8 @@ pub fn needed(app: &PhotocraftApp) -> Option<(Kind, photocraft_doc::LayerId)> {
let st = app.session.active()?;
let id = st.active_layer?;
let l = st.doc.layer(id)?;
if l.locks.all || l.locks.pixels {
let locks = st.doc.effective_locks(id);
if locks.all || locks.pixels {
return None;
}
use photocraft_doc::LayerContent as C;