Compare commits

...
Author SHA1 Message Date
Tanish Manku 04e9904cc4 fmt 2026-10-15 12:35:22 -07:00
Tanish Manku b721f219c5 Merge Layers keeps the result clipped only when the clipped layers share one clip chain 2026-10-15 12:35:14 -07:00
Tanish Manku 6b71fa8a92 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles
From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.
2026-10-14 16:09:38 -07:00
6 changed files with 122 additions and 1 deletions

No files matched your search

+13
View File
@@ -42,6 +42,11 @@ fn group_of(doc: &Document, id: LayerId) -> Vec<LayerId> {
if group.len() > 1 { group } else { Vec::new() }
}
/// The base `id` clips to (itself when it is a base), when it is in a clip group.
pub(crate) fn base_of(doc: &Document, id: LayerId) -> Option<LayerId> {
group_of(doc, id).first().copied()
}
/// The layers clipped to `id`: the clipped run directly above it. Empty when `id` is itself
/// clipped (it is not a base) or nothing is clipped to it.
fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
@@ -51,6 +56,14 @@ fn clipped_to(doc: &Document, id: LayerId) -> Vec<LayerId> {
}
}
/// The clipped layers directly above `id` among its siblings (Release Clipping Mask releases them
/// with it).
pub(crate) fn clipped_above(doc: &Document, id: LayerId) -> Vec<LayerId> {
let Some(path) = doc.path_of(id) else { return Vec::new() };
let Some((list, idx)) = siblings(doc, &path) else { return Vec::new() };
list.iter().skip(idx.saturating_add(1)).take_while(|l| l.clipped).map(|l| l.id).collect()
}
/// The clip groups of layers about to move or be deleted, read before they leave their slots.
#[derive(Default)]
pub(crate) struct Before {
+18 -1
View File
@@ -253,7 +253,10 @@ pub(crate) fn parse_hex(s: &str) -> Option<[f32; 4]> {
}
/// Layer › Create / Release Clipping Mask. With a `layer` param, just that layer. Otherwise, as in
/// Photoshop, the selection (#1248): Create clips every selected layer except the lowest, which
/// becomes the base; Release releases every selected layer. One history step either way.
/// becomes the base; Release releases every selected layer. Releasing a clipped layer also
/// releases the clipped layers above it in its group, as Adobe documents ("removes the selected
/// layer and any layers above it from the clipping mask"), so none is left clipped to a layer
/// that is no longer clipped. One history step either way.
fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
let label = if clip { "Create Clipping Mask" } else { "Release Clipping Mask" };
let ids: Vec<LayerId> = if p.get("layer").is_some() {
@@ -267,6 +270,20 @@ fn set_clipped(s: &mut Session, p: &Value, clip: bool) -> Result<Value> {
(false, all) => all.to_vec(),
}
};
let ids = if clip {
ids
} else {
let doc = &s.active().ok_or(EngineError::NoDocument)?.doc;
let mut all = ids.clone();
for id in ids.iter().filter(|id| doc.layer(**id).is_some_and(|l| l.clipped)) {
for above in crate::clip_group::clipped_above(doc, *id) {
if !all.contains(&above) {
all.push(above);
}
}
}
all
};
s.edit(label, |doc, _| {
for id in &ids {
doc.layer_mut(*id).ok_or(EngineError::NoLayer(*id))?.clipped = clip;
+5
View File
@@ -522,11 +522,16 @@ fn delete_where(s: &mut Session, label: &str, pred: fn(&Layer) -> bool) -> Resul
n
}
let backup = doc.layers.clone();
// The clip groups of the doomed layers, read before they go (clip_group rule 3: layers
// clipped to a deleted base are released).
let doomed: Vec<LayerId> = doc.walk().into_iter().filter(|(_, _, l)| pred(l)).map(|(_, _, l)| l.id).collect();
let before = crate::clip_group::Before::read(doc, &doomed);
let n = rec(&mut doc.layers, pred);
if doc.layers.is_empty() {
doc.layers = backup;
return Err(EngineError::Other("a document must keep at least one layer".into()));
}
crate::clip_group::settle(doc, &[], &before, false);
if active.is_none_or(|id| doc.layer(id).is_none()) {
*active = doc.top_layer();
}
+6
View File
@@ -1312,6 +1312,12 @@ fn merge_layers(s: &mut Session) -> Result<Value> {
let fmt = PixelFormat::new(fmt.mode, fmt.sample, true);
let mut merged = Layer::raster(top.name.clone(), fmt);
merged.locks = top.locks;
merged.label = top.label;
// Clipped layers of one chain merge into a layer still clipped to their base. Clipped
// layers of different chains don't: the result, at the top one's slot, would clip the
// lower chain's pixels to the upper chain's base.
let base = crate::clip_group::base_of(doc, top_id);
merged.clipped = base.is_some() && solo.layers.iter().all(|l| l.clipped && crate::clip_group::base_of(doc, l.id) == base);
*crate::pixels_mut(&mut merged)? = crate::pixels::composite_layers_uncropped(&solo, fmt);
let mid = merged.id;
// Hidden selected layers are discarded, as in Photoshop.
+5
View File
@@ -415,5 +415,10 @@ pub fn merge_down(doc: &Document, lower: &Layer, upper: &Layer) -> Layer {
// Merge Down replaces the lower layer, so keep its editing restrictions.
// In particular, a merged Background must remain opaque and position-locked.
merged.locks = lower.locks;
// And its place: two layers clipped in one chain merge into a layer still clipped to the base,
// with the lower layer's colour label and link.
merged.clipped = lower.clipped && upper.clipped;
merged.label = lower.label;
merged.link_group = lower.link_group;
merged
}
+75
View File
@@ -0,0 +1,75 @@
//! Clip groups through Release Clipping Mask, Merge Down, Merge Layers and Delete Hidden Layers,
//! as in Photoshop (layers audit L2). The stack: Background, B, C1*, C2* (C1 and C2 clipped to B).
use photocraft_engine::Session;
use serde_json::json;
fn stack() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 32, "background": "white"})).unwrap();
let mut ids = [0u64; 3];
for (i, color) in ["#ff0000", "#00ff00", "#0000ff"].iter().enumerate() {
ids[i] = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("select.rect", json!({"x": 4 * i, "y": 4, "width": 12, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": color})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
}
for c in &ids[1..] {
s.execute("layer.setProps", json!({"layer": c, "clipped": true})).unwrap();
}
(s, ids)
}
fn clipped(s: &Session, id: u64) -> bool {
s.active().unwrap().doc.layer(photocraft_doc::LayerId(id)).unwrap().clipped
}
#[test]
fn releasing_a_middle_layer_releases_the_ones_above_it() {
let (mut s, [_, c1, c2]) = stack();
let r = s.execute("layer.releaseClippingMask", json!({"layer": c1})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "C2 isn't left clipped to the released C1: {r}");
// Releasing the top one leaves the one below it alone.
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.releaseClippingMask", json!({"layer": c2})).unwrap();
assert!(clipped(&s, c1) && !clipped(&s, c2));
}
#[test]
fn merging_clipped_layers_keeps_the_result_clipped() {
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c2})).unwrap();
let r = s.execute("layer.mergeDown", json!({})).unwrap();
let merged = r["layer"].as_u64().unwrap_or(c1);
assert!(clipped(&s, merged), "C2 merged down into C1 stays clipped to B");
let (mut s, [_, c1, c2]) = stack();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(clipped(&s, r["layer"].as_u64().unwrap()), "C1 + C2 merged stay clipped");
// Clipped layers of two chains (Background, B, C1*, E, D*: C1 clipped to B, D to E) merge
// into an ordinary layer: kept clipped at D's slot, C1's pixels would clip to E.
let (mut s, [_, c1, e]) = stack();
s.execute("layer.setProps", json!({"layer": e, "clipped": false})).unwrap();
let d = s.execute("layer.new.layer", json!({})).unwrap()["layer"].as_u64().unwrap();
s.execute("edit.fill", json!({"color": "#808080"})).unwrap();
s.execute("layer.setProps", json!({"layer": d, "clipped": true})).unwrap();
s.execute("layer.select", json!({"layer": c1})).unwrap();
s.execute("layer.select", json!({"layer": d, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()), "C1 + D come from two clip chains");
// Merging the base with its clipped layers gives an ordinary layer.
let (mut s, [b, _, c2]) = stack();
s.execute("layer.select", json!({"layer": b})).unwrap();
s.execute("layer.select", json!({"layer": c2, "mode": "add"})).unwrap();
let r = s.execute("layer.mergeLayers", json!({})).unwrap();
assert!(!clipped(&s, r["layer"].as_u64().unwrap()));
}
#[test]
fn deleting_a_hidden_base_releases_its_clipped_layers() {
let (mut s, [b, c1, c2]) = stack();
s.execute("layer.setProps", json!({"layer": b, "visible": false})).unwrap();
s.execute("layer.delete.hiddenLayers", json!({})).unwrap();
assert!(!clipped(&s, c1) && !clipped(&s, c2), "they don't clip to the layer below the deleted base");
}