mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-11 00:11:46 +00:00
Merge pull request #9337 from DuBearte/feat/8639-stroke-emboss
feat(compose): Stroke Emboss bevels the stroked outline (#8639)
This commit is contained in:
6 files changed
+224
-4
No files matched your search
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user