mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-16 12:16:50 -04:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04e9904cc4 | ||
|
|
b721f219c5 | ||
|
|
6b71fa8a92 |
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user