Browse / github.com/storytold/photocraft / patch
Convert to Layers keeps a knockout layer isolated in a group (#9753)
Convert to Layers loses isolation for single-layer knockout contents **Convert to Layers removes a required isolation boundary when a Smart Object contains one knockout layer.** The child is promoted directly into the parent stack, so its Shallow/Deep knockout begins punching through unrelated parent layers. Photoshop keeps the child in a Normal group and preserves the image. Reproduced on Windows 11 with PhotoCraft CLI **0.16.0 (`b1794c6e0`, 2026-10-25)** and actual Photoshop **27.11.0**. The Type, knockout and Fill settings survive; this is a compositing-scope change. ## Reproduction [Self-contained original regression and numeric observations](https
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- NaufalDzz (imported from pull request #9858, open)
- Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035- Size
- +36 −2 in 2 files
- Review
- pending
- Shared
- 2026-10-28
- Id
ec1cd3e40e274f9cb69517d5
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/cmd/smart_cmds/tests.rs crates/engine/src/cmd/smart_cmds/unpack.rs
@@ -929,6 +929,38 @@ fn convert_to_layers_keeps_a_smart_layer_mask_on_a_group() { assert_eq!(max_diff(&flat(&s), &before), 0.0); } +/// #9753: a smart object isolates its contents, so a knockout inside it stops at its bottom.+/// Folded into the parent stack, it would punch through the layers beneath to the Background; a+/// Normal group keeps the look, as Photoshop's Convert to Layers does.+#[test]+fn convert_to_layers_keeps_a_knockout_inside_a_group() {+ let fill = |s: &mut Session, r: Rect, rgba: [f32; 4]| {+ s.edit("paint", |doc, active| {+ doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap().fill_rect(r, &rgba);+ Ok(())+ })+ .unwrap();+ };+ for knockout in ["shallow", "deep"] {+ let mut s = Session::new();+ s.execute("file.new", json!({"width": W, "height": H})).unwrap();+ fill(&mut s, Rect::new(0, 0, W, H), [0.0, 0.0, 1.0, 1.0]);+ s.execute("layer.new.layer", json!({})).unwrap();+ fill(&mut s, Rect::new(0, 0, W, H), [1.0, 1.0, 0.0, 1.0]);+ s.execute("layer.new.layer", json!({})).unwrap();+ fill(&mut s, Rect::new(10, 10, 30, 30), [1.0, 0.0, 0.0, 1.0]);+ s.execute("layer.layerStyle.blendingOptions", json!({"knockout": knockout, "fillOpacity": 50})).unwrap();+ convert(&mut s);+ let before = flat(&s);+ let r = convert_to_layers(&mut s);+ assert_eq!(r["group"], true, "{knockout}: the contents stay isolated");+ let g = active_layer(&s);+ assert_eq!(g.blend, BlendMode::Normal, "{knockout}");+ assert!(max_diff(&flat(&s), &before) <= 1.0 / 255.0 + 1e-6, "{knockout}: the look is kept");+ assert_eq!(g.children().unwrap()[0].advanced.knockout.name(), knockout, "the knockout setting survives");+ }+}+ #[test] fn convert_to_layers_applies_the_placement() { let mut s = session(8);@@ -9,7 +9,7 @@ use std::sync::Arc; use photocraft_algo::transform::Interp; use photocraft_cms::Intent; use photocraft_color::BlendMode;-use photocraft_doc::{Document, Group, Layer, LayerContent, LayerId, Metadata, SmartObject, SmartSource};+use photocraft_doc::{Document, Group, Knockout, Layer, LayerContent, LayerId, Metadata, SmartObject, SmartSource}; use serde_json::{Value, json}; use super::{authorize_source, authorized_source_bytes, decode_source, detach_psd, other, placement, shift_layer, smart};@@ -125,7 +125,8 @@ fn released(l: &mut Layer, f: impl FnOnce(&mut Layer) -> Result<()>) -> Result<( /// Whether the smart layer's own properties fold into its one unpacked layer `c` without /// changing the look: nothing on the smart layer acts on `c`'s composite (masks, style, fill /// opacity, Blend If, channel exclusions), and `c` blends normally and doesn't look at what's-/// beneath it (alone in the smart object, it lands on transparency).+/// beneath it (alone in the smart object, it lands on transparency; a knockout there stops at+/// the smart object's bottom instead of punching through to the Background, #9753). fn foldable(so: &Layer, c: &Layer) -> bool { so.mask.is_none() && so.vector_mask.is_none()@@ -137,6 +138,7 @@ fn foldable(so: &Layer, c: &Layer) -> bool { && matches!(c.blend, BlendMode::Normal | BlendMode::PassThrough) && c.blend_if.is_default() && c.excluded_channels == 0+ && c.advanced.knockout == Knockout::None } /// What replaces smart layer `so`: its one unpacked layer with `so`'s properties folded in, or a