Merge pull request #9337 from DuBearte/feat/8639-stroke-emboss

feat(compose): Stroke Emboss bevels the stroked outline (#8639)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-10 00:21:56 +01:00
commit e72d12106a
6 files changed
+224 -4

No files matched your search

+66 -3
View File
@@ -2,8 +2,8 @@ use super::bevel::{BevelPaint, TextureCtx, bevel_geom, bevel_maps, satin_map};
use super::distance::{INSIDE_EPS, Metric, dist_inside_by, dist_outside_by, local_coverage, stroke_outside_exact};
use super::paint::{dissolves_in, glow_map, paint_color, paint_fx, paint_glow, paint_pattern, rgb, shadow_map};
use super::{
BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement, PreparedPatterns, Rect,
StrokePosition, psblend,
Bevel, BevelStyle, BlendMode, Buffer, Effect, FxMaps, FxPaint, GlobalLight, Layer, MAX_REACH, Map, PREPARED_PATTERN_BYTES, Pattern, Placement,
PreparedPatterns, Rect, StrokePosition, psblend,
};
/// Build every effect map for `layer` from its alpha `shape` over `rect`.
@@ -56,6 +56,37 @@ pub(crate) fn build_maps_prepared(
Effect::OuterGlow(g) => vec![glow_map(&shape, g, false, (rect.x0, rect.y0))],
Effect::InnerGlow(g) => vec![glow_map(&shape, g, true, (rect.x0, rect.y0))],
Effect::Satin(s) => vec![satin_map(&shape, s)],
// Stroke Emboss (#8639): an Inner Bevel of the layer's outline with its Stroke effects
// (the stroked shape's outer edge, inside the stroke too), shading only the strokes,
// over the finished layer, as Photoshop's corpus files show it. Several strokes emboss
// as their union (a 4 px stroke over a 10 px one shades the whole 10 px band). Without
// a stroke it has nothing to emboss.
Effect::BevelEmboss(b) if b.style == BevelStyle::StrokeEmboss => {
let strokes: Vec<&photocraft_doc::StrokeFx> = items.iter().filter_map(|e| if let Effect::Stroke(st) = e { Some(st) } else { None }).collect();
let band = strokes.iter().map(|st| stroke_band(&shape, st)).reduce(|mut acc, m| {
for (a, s) in acc.v.iter_mut().zip(&m.v) {
*a = a.max(*s);
}
acc
});
// Strokes inside the layer shade with the layer, as an Inner Bevel does; one
// reaching outside shades over the finished layer and its backdrop.
bevel_paint[i] = if strokes_inside(&strokes) { BevelPaint::Inner } else { BevelPaint::Both };
match band {
Some(band) => {
// The shape keeps its own (anti-aliased) edge; the stroke adds what lies outside it.
let outline = Map { w, h, v: shape.v.iter().zip(&band.v).map(|(a, s)| if *a > INSIDE_EPS { *a } else { a.max(*s) }).collect() };
let mut maps = bevel_maps(&outline, &Bevel { style: BevelStyle::InnerBevel, ..(*b).clone() }, light, tex, patterns).0;
for m in &mut maps {
for (v, s) in m.v.iter_mut().zip(&band.v) {
*v *= s;
}
}
maps
}
None => vec![Map::new(w, h, 0.0), Map::new(w, h, 0.0)],
}
}
Effect::BevelEmboss(b) => {
let (maps, paint) = bevel_maps(&shape, b, light, tex, patterns);
bevel_paint[i] = paint;
@@ -148,16 +179,48 @@ pub fn premerges_layer(layer: &Layer) -> bool {
}
}
/// Coverage of `st`'s band around `shape` (row-major over the same region): its inside part as
/// far in as the inside width, its outside part as far out as the outside width, as the
/// compositor paints them. The shape Stroke Emboss bevels (#8639).
fn stroke_band(shape: &Map, st: &photocraft_doc::StrokeFx) -> Map {
let (in_w, out_w) = stroke_widths(st);
let din = (in_w > 0.0).then(|| dist_inside_by(shape, Metric::Chamfer));
let metric = if stroke_outside_exact(out_w) { Metric::Euclidean } else { Metric::Chamfer };
let dout = (out_w > 0.0).then(|| dist_outside_by(shape, metric));
let mut band = Map::new(shape.w, shape.h, 0.0);
for (i, (b, a)) in band.v.iter_mut().zip(&shape.v).enumerate() {
*b = if *a > INSIDE_EPS {
din.as_ref().and_then(|d| d.get(i)).map_or(0.0, |d| (in_w + 0.5 - d).clamp(0.0, 1.0))
} else {
dout.as_ref().and_then(|d| d.get(i)).map_or(0.0, |d| (out_w + 0.5 - d).clamp(0.0, 1.0))
};
}
band
}
/// `true` if there are strokes and none reaches outside the layer: Stroke Emboss then shades with
/// the layer, as an Inner Bevel does.
fn strokes_inside(strokes: &[&photocraft_doc::StrokeFx]) -> bool {
!strokes.is_empty() && strokes.iter().all(|st| stroke_widths(st).1 <= 0.0)
}
/// `true` if an enabled effect painted inside the layer (with its content) blends in a mode
/// other than Normal.
fn blends_inside(layer: &Layer) -> bool {
pub(super) fn blends_inside(layer: &Layer) -> bool {
let blends = |m: BlendMode| m != BlendMode::Normal;
// Stroke Emboss with its strokes wholly inside shades with the layer (`BevelPaint::Inner` in
// `build_maps_prepared`), as an Inner Bevel does; one reaching outside shades over the
// finished layer.
let strokes: Vec<&photocraft_doc::StrokeFx> =
layer.effects.items.iter().filter(|e| e.enabled()).filter_map(|e| if let Effect::Stroke(st) = e { Some(st) } else { None }).collect();
let stroke_inside = strokes_inside(&strokes);
layer.effects.items.iter().filter(|e| e.enabled()).any(|e| match e {
Effect::ColorOverlay { common, .. } | Effect::GradientOverlay { common, .. } | Effect::PatternOverlay { common, .. } => blends(common.blend),
Effect::Satin(s) => blends(s.common.blend),
Effect::InnerGlow(g) => blends(g.common.blend),
Effect::InnerShadow(s) => blends(s.common.blend),
Effect::Stroke(s) => stroke_widths(s).0 > 0.0 && blends(s.common.blend),
Effect::BevelEmboss(b) if b.style == BevelStyle::StrokeEmboss => stroke_inside && (blends(b.highlight.blend) || blends(b.shadow.blend)),
Effect::BevelEmboss(b) => bevel_geom(b).paint == BevelPaint::Inner && (blends(b.highlight.blend) || blends(b.shadow.blend)),
Effect::DropShadow(_) | Effect::OuterGlow(_) => false,
})
+66
View File
@@ -1047,3 +1047,69 @@ fn shifting_by_hostile_distances_does_not_overflow() {
assert_eq!(s.v.len(), 144);
}
}
/// #8639 review: a Stroke Emboss whose stroke lies wholly inside shades with the layer, so its
/// non-Normal highlight or shadow counts as an interior blend, as an Inner Bevel's does; with the
/// stroke reaching outside, or no stroke, it doesn't.
#[test]
fn an_inside_stroke_emboss_counts_as_an_interior_blend() {
let stroke = |position: StrokePosition| {
Effect::Stroke(photocraft_doc::StrokeFx {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, 1.0),
size: 4.0,
position,
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::BLACK),
overprint: false,
})
};
let layer = |effects: Vec<Effect>| {
let mut l =
photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)));
l.effects.items = effects;
l
};
let emboss = Effect::BevelEmboss(bevel_of(BevelStyle::StrokeEmboss, BevelTechnique::Smooth));
assert!(super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Inside), emboss.clone()])));
assert!(!super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Outside), emboss.clone()])));
assert!(!super::composite::blends_inside(&layer(vec![stroke(StrokePosition::Center), emboss.clone()])));
assert!(!super::composite::blends_inside(&layer(vec![emboss])));
}
/// #8639 review, measured in the reference application: Stroke Emboss bevels the union of every
/// enabled Stroke (a 4 px stroke listed over a 10 px one shades the whole 10 px band), and a
/// stroke at 0 % opacity still has its band embossed over the backdrop.
#[test]
fn stroke_emboss_bevels_every_stroke_even_a_transparent_one() {
let stroke = |size: f32, position: StrokePosition, opacity: f32| {
Effect::Stroke(photocraft_doc::StrokeFx {
common: photocraft_doc::FxCommon::new(BlendMode::Normal, opacity),
size,
position,
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::BLACK),
overprint: false,
})
};
let emboss = Effect::BevelEmboss(bevel_of(BevelStyle::StrokeEmboss, BevelTechnique::Smooth));
let maps = |effects: Vec<Effect>| {
let mut l =
photocraft_doc::Layer::new("L", photocraft_doc::LayerContent::Raster(photocraft_raster::Surface::new(photocraft_color::PixelFormat::RGBA8)));
l.effects.items = effects;
let n = l.effects.items.len();
let m = build_maps(&l, square(60, 20, 40).v, Rect::new(0, 0, 60, 60), &GlobalLight::default(), &no_tex());
(m.per[n - 1].clone(), m.bevel_paint[n - 1])
};
// 7 px above the shape: inside the 10 px band only; the bevel (light from the top) lights it.
let lit = |m: &[Map]| m[0].v[13 * 60 + 30];
let (two, paint) = maps(vec![stroke(4.0, StrokePosition::Outside, 1.0), stroke(10.0, StrokePosition::Outside, 1.0), emboss.clone()]);
assert!(lit(&two) > 0.1, "{}", lit(&two));
assert_eq!(paint, BevelPaint::Both);
let (thin, _) = maps(vec![stroke(4.0, StrokePosition::Outside, 1.0), emboss.clone()]);
assert_eq!(lit(&thin), 0.0);
let (clear, _) = maps(vec![stroke(10.0, StrokePosition::Outside, 0.0), emboss.clone()]);
assert!(lit(&clear) > 0.1, "{}", lit(&clear));
// Inside only when every stroke is.
let (_, inside) = maps(vec![stroke(4.0, StrokePosition::Inside, 1.0), stroke(6.0, StrokePosition::Inside, 1.0), emboss.clone()]);
assert_eq!(inside, BevelPaint::Inner);
let (_, mixed) = maps(vec![stroke(4.0, StrokePosition::Inside, 1.0), stroke(6.0, StrokePosition::Outside, 1.0), emboss]);
assert_eq!(mixed, BevelPaint::Both);
}
+46
View File
@@ -13,6 +13,52 @@ fn effects(s: &Session) -> Vec<Effect> {
d.doc.layer(d.active_layer.unwrap()).unwrap().effects.items.clone()
}
/// #8639: Stroke Emboss bevels the outline of the layer with its Stroke effect, shading only
/// the stroke, not the layer; without a stroke it shows nothing, as in Photoshop.
#[test]
fn stroke_emboss_shades_the_stroke_band_only() {
let render = |stroke: bool, bevel: Option<&str>| {
let mut s = Session::new();
s.execute("file.new", json!({"width": 120, "height": 90, "background": "#d8d8d8"})).unwrap();
s.execute("shape.create", json!({"kind": "rect", "rect": [30, 25, 60, 40], "fill": "#3f6fd8"})).unwrap();
if stroke {
s.execute("layer.layerStyle.stroke", json!({"size": 10, "position": "outside", "color": "#c0c0c0"})).unwrap();
}
if let Some(style) = bevel {
s.execute("layer.layerStyle.bevelEmboss", json!({"style": style, "size": 6, "depth": 200})).unwrap();
}
let d = &s.active().unwrap().doc;
photocraft_compose::render(d, d.bounds()).into_px()
};
let at = |px: &[[f32; 4]], x: usize, y: usize| px[y * 120 + x];
let (plain, embossed) = (render(true, None), render(true, Some("stroke")));
// The stroked outline is bevelled inward, inside the stroke only (the stroke covers rows
// 15..25 above the shape and 65..75 below it; flat it is 0.753): its top edge faces the
// light, its bottom edge away from it, and the stroke's inner part stays flat.
for ((x, y), change) in [((60, 16), 1.0), ((60, 73), -1.0), ((60, 23), 0.0), ((60, 66), 0.0)] {
let (e, p) = (at(&embossed, x, y)[0], at(&plain, x, y)[0]);
let ok = if change > 0.0 {
e > p + 0.1
} else if change < 0.0 {
e < p - 0.1
} else {
(e - p).abs() < 1e-3
};
assert!(ok, "({x}, {y}): {e} vs {p}");
}
// …while the layer's own pixels and the backdrop stay as they were (an Inner Bevel would
// shade the rectangle's edge).
for (x, y) in [(32, 30), (60, 45), (87, 62), (5, 5)] {
assert_eq!(at(&embossed, x, y), at(&plain, x, y), "({x}, {y})");
}
assert_ne!(at(&render(true, Some("inner")), 32, 30), at(&plain, 32, 30), "the old fallback shaded the layer");
// No stroke, nothing to emboss.
let (a, b) = (render(false, Some("stroke")), render(false, None));
let worst = a.iter().zip(&b).flat_map(|(p, q)| p.iter().zip(q).map(|(x, y)| (x - y).abs())).fold(0.0f32, f32::max);
// Equal but for float rounding: an effect sends the layer through the effects compositor.
assert!(worst < 1e-6, "{worst}");
}
#[test]
fn bevel_command_sets_technique_contour_and_texture() {
let mut s = session();
+4
View File
@@ -807,6 +807,10 @@ impl<'a> Planner<'a> {
for c in clipped {
content = self.atop(c, content)?;
}
// Stroke Emboss shades the stroke's band, which only the CPU compositor builds (#8639).
if layer.effects.items.iter().any(|e| matches!(e, Effect::BevelEmboss(b) if e.enabled() && b.style == photocraft_doc::BevelStyle::StrokeEmboss)) {
return Err(Unsupported(format!("Stroke Emboss on `{}` (composited on the CPU)", layer.name)));
}
let fx = self.fx.len();
self.fx.push(FxLayer { layer, region, bounds: sb });
let outline = photocraft_compose::effect_outline(layer).is_some();
+41
View File
@@ -183,6 +183,47 @@ fn small_layers_composite_only_over_their_bounds() {
assert!(p.passes.iter().all(|p| p.clip.is_none()));
}
/// #8639: Stroke Emboss shades the stroke's band, which only the CPU compositor builds: a
/// document using it is composited on the CPU, so both show the same pixels.
#[test]
fn stroke_emboss_defers_to_the_cpu() {
use photocraft_doc::{Bevel, BevelStyle, BevelTechnique, Contour, FxCommon};
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
let mut l = Layer::raster("embossed", d.pixel_format());
l.surface_mut().unwrap().fill_rect(Rect::new(2, 2, 5, 5), &[1.0, 0.0, 0.0, 1.0]);
let bevel = |style| Bevel {
enabled: true,
style,
technique: BevelTechnique::Smooth,
depth: 1.0,
up: true,
size: 3.0,
soften: 0.0,
angle: 90.0,
altitude: 30.0,
use_global_light: false,
gloss_contour: Contour::Linear,
gloss_anti_alias: false,
highlight: FxCommon::new(BlendMode::Screen, 0.75),
highlight_color: Color::WHITE,
shadow: FxCommon::new(BlendMode::Multiply, 0.75),
shadow_color: Color::BLACK,
contour: None,
texture: None,
};
l.effects.items.push(photocraft_doc::Effect::BevelEmboss(bevel(BevelStyle::InnerBevel)));
d.layers.push(l);
assert!(plan(&d).is_ok(), "other bevel styles stay on the GPU");
d.layers[1].effects.items[0] = photocraft_doc::Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss));
let err = plan(&d).unwrap_err();
assert!(err.0.contains("Stroke Emboss"), "{err}");
// Turned off, it no longer matters.
if let photocraft_doc::Effect::BevelEmboss(b) = &mut d.layers[1].effects.items[0] {
b.enabled = false;
}
assert!(plan(&d).is_ok());
}
#[test]
fn effects_are_planned_in_place() {
let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, SampleType::U8, Color::WHITE);
+1 -1
View File
@@ -1738,7 +1738,7 @@ fn effect_cases() -> Vec<(&'static str, Vec<Effect>)> {
vec![Effect::BevelEmboss(Bevel { technique: BevelTechnique::ChiselHard, ..bevel(BevelStyle::PillowEmboss, false, 7.0, 0.0) })],
),
("bevel emboss smooth wide", vec![Effect::BevelEmboss(bevel(BevelStyle::Emboss, true, 21.0, 0.0))]),
("bevel stroke emboss", vec![Effect::BevelEmboss(bevel(BevelStyle::StrokeEmboss, true, 6.0, 0.0))]),
// Stroke Emboss is composited on the CPU (`plan::tests::stroke_emboss_defers_to_the_cpu`, #8639).
(
"bevel texture",
vec![Effect::BevelEmboss(Bevel {