collective

Browse / github.com/storytold/photocraft / patch

compose, gpu: paint an outer bevel beneath the layer's strokes

compose, gpu: paint an outer bevel beneath the layer's strokes ## What differed from Photoshop For a layer with an Outer Bevel (shadow black, 38 %) and an outside stroke, Photoshop draws the stroke over the bevel, so a Normal stroke keeps its own colour. We painted the bevel after the strokes and tinted the stroke with the bevel's highlight/shadow (a dark mauve rim). On a real Photoshop reference with this combination, the rim now matches Photoshop's hidden bevel. ## Fix Move the outer-bevel paint before the stroke block in `composite_with_effects_prepared` (compose) and in the effect plan (`gpu/src/plan.rs`). Inner/emboss bevels are untouched. ##

Status
open
Score
0.00
Adopters
0 other operators
Author
morioka185 (imported from pull request #9614, open)
Base
d3b0a76c3f6ca22250850f0dafd93279cad85e41
Size
+65 −28 in 4 files
Review
pending
Shared
2026-10-28
Id
32f4edaddc1825d86bb0b899

How it went

No other operator has reported using it yet.

Diff

raw · crates/compose/src/effects.rs crates/compose/src/tests.rs crates/gpu/src/plan.rs crates/gpu/tests/it/parity.rs

diff --git a/crates/compose/src/effects.rs b/crates/compose/src/effects.rs--- a/crates/compose/src/effects.rs+++ b/crates/compose/src/effects.rs@@ -2203,6 +2203,17 @@ pub(crate) fn composite_with_effects_prepared(     for (p, a) in lay.px.iter_mut().zip(&shape.v) {         p[3] *= a.min(1.0);     }+    // An outer bevel lies beneath the layer's strokes (Photoshop draws the strokes over it), so it+    // is painted before them: a Normal outside stroke covers it instead of being shaded by it.+    for (i, e) in rev() {+        if let Effect::BevelEmboss(b) = e+            && maps.bevel_paint[i] == BevelPaint::Outer+        {+            let (hi, sh) = (fx(i, 0), fx(i, 1));+            paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);+            paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);+        }+    }     // Shape layers without a filled outline stroke their vector outline, estimated from local     // coverage; every other layer strokes its shape. The width then picks the field.     let vector_outline = vector_shape && !maps.outline;@@ -2270,15 +2281,6 @@ pub(crate) fn composite_with_effects_prepared(             }         }     }-    for (i, e) in rev() {-        if let Effect::BevelEmboss(b) = e-            && maps.bevel_paint[i] == BevelPaint::Outer-        {-            let (hi, sh) = (fx(i, 0), fx(i, 1));-            paint_color(&mut work, &hi, rgb(&b.highlight_color), b.highlight.blend, b.highlight.opacity);-            paint_color(&mut work, &sh, rgb(&b.shadow_color), b.shadow.blend, b.shadow.opacity);-        }-    }      // Layer (with interior effects) onto the exterior result, in the layer's mode.     let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -1248,6 +1248,33 @@ fn stroke(size: f32, position: StrokePosition) -> Effect {     }) } +/// The pixel at (`x`, 20) of a red square carrying `effects`, in a white document of `depth`.+fn effects_px_at_depth(depth: SampleType, effects: Vec<Effect>, x: i32) -> [f32; 4] {+    let mut d = Document::with_background("t", Size::new(40, 40), ColorMode::Rgb, depth, Color::WHITE);+    let fmt = d.pixel_format();+    let mut l = Layer::raster("sq", fmt);+    l.surface_mut().unwrap().fill_rect(Rect::new(10, 10, 30, 30), &photocraft_raster::from_rgba(&fmt, [1.0, 0.0, 0.0, 1.0]));+    l.effects.items = effects;+    d.layers.push(l);+    px(&d, x, 20)+}++#[test]+fn outer_bevel_lies_beneath_an_outside_stroke() {+    // An outer bevel shades the backdrop around the layer, and Photoshop draws the strokes over+    // it: a Normal outside stroke keeps its own colour where the bevel's shading reaches.+    use photocraft_doc::BevelStyle;+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {+        let bevel_only = vec![bevel_fx(BevelStyle::OuterBevel, 0.0)];+        // The 5 px bevel is shaded on the side facing away from the light: the square's right+        // edge is at x = 30, so x = 31 is 1 px out, inside both the bevel and the 4 px stroke.+        let shaded = effects_px_at_depth(depth, bevel_only, 31);+        assert!(shaded[0] < 0.9, "{depth:?}: the bevel shades the backdrop here: {shaded:?}");+        let both = vec![bevel_fx(BevelStyle::OuterBevel, 0.0), stroke(4.0, StrokePosition::Outside)];+        let under = effects_px_at_depth(depth, both, 31);+        assert!(close4(under, [0.0, 0.0, 1.0, 1.0]), "{depth:?}: the stroke covers the bevel: {under:?}");+    }+} #[test] fn outside_stroke_width() {     let d = fx_doc(vec![stroke(3.0, StrokePosition::Outside)]);diff --git a/crates/gpu/src/plan.rs b/crates/gpu/src/plan.rs--- a/crates/gpu/src/plan.rs+++ b/crates/gpu/src/plan.rs@@ -1298,6 +1298,26 @@ impl<'a> Planner<'a> {         // The shape's own alpha.         let c = self.retain(content);         l = init(self, l, Some(c), INIT_ALPHA, 1.0);+        // An outer bevel lies beneath the strokes (compose::composite_with_effects_prepared).+        for &(i, e) in &rev {+            if let Effect::BevelEmboss(b) = e+                && bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer+            {+                let k = 0;+                w = self.paint(+                    w,+                    content,+                    Cov::Map(map(i, k), 0.0),+                    &Paint::Color(b.highlight_color.to_rgb()),+                    b.highlight.blend,+                    b.highlight.opacity,+                    0,+                    clip,+                    sb,+                );+                w = self.paint(w, content, Cov::Map(map(i, k + 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, 0, clip, sb);+            }+        }         // Outside stroke parts lie beneath the layer and blend onto the exterior result with their         // own mode; a higher stroke covers the ones below it (each takes its coverage × opacity         // not yet taken above it, blended over the result as it was before the strokes).@@ -1356,25 +1376,6 @@ impl<'a> Planner<'a> {             p.clip = Some(clip);             w = self.emit(p);         }-        for &(i, e) in &rev {-            if let Effect::BevelEmboss(b) = e-                && bevel_paint(b) == photocraft_compose::effects::BevelPaint::Outer-            {-                let k = 0;-                w = self.paint(-                    w,-                    content,-                    Cov::Map(map(i, k), 0.0),-                    &Paint::Color(b.highlight_color.to_rgb()),-                    b.highlight.blend,-                    b.highlight.opacity,-                    0,-                    clip,-                    sb,-                );-                w = self.paint(w, content, Cov::Map(map(i, k + 1), 0.0), &Paint::Color(b.shadow_color.to_rgb()), b.shadow.blend, b.shadow.opacity, 0, clip, sb);-            }-        }          let mode = if premerge || layer.blend == BlendMode::PassThrough { BlendMode::Normal } else { layer.blend };         let late: Vec<(usize, &'a photocraft_doc::Bevel)> = revdiff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs--- a/crates/gpu/tests/it/parity.rs+++ b/crates/gpu/tests/it/parity.rs@@ -1738,6 +1738,13 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {                 Effect::Stroke(stroke(6.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.0, 0.0, 0.0)))),             ],         ),+        (+            "outer bevel beneath a stroke",+            vec![+                Effect::BevelEmboss(bevel(BevelStyle::OuterBevel, true, 6.0, 2.0)),+                Effect::Stroke(stroke(4.0, StrokePosition::Outside, FxPaint::Color(Color::rgb(0.2, 0.6, 0.2)))),+            ],+        ),         (             "multiple instances",             vec![