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
@@ -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 };@@ -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)]);@@ -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)> = rev@@ -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![