Merge approved PhotoCraft PR #8039: Compose: Extend Softer outer glows past Size (#7899)

Port behavior and regression coverage into current modules; preserve current upstream behavior.
Added bounded Softer outer-glow tail in current CPU paint and GPU glow-builder modules, retaining original tent convolution and current program dispatch; registered exact tail/kernel regression and expanded render margin only for outer Softer glows.
This commit is contained in:
FredApps committed 2026-10-29 05:24:44 +01:00
commit f369874ddb
8 files changed
+194 -5

No files matched your search

+9 -2
View File
@@ -41,7 +41,7 @@ mod paint;
pub use bevel::{BEVEL_H_EPS, BevelGeom, BevelPaint, TextureCtx, bevel_chisel_h, bevel_geom, satin_distance};
#[cfg(test)]
use bevel::{bevel_maps, satin_map};
pub use blur::tent_kernel;
pub use blur::{tent_kernel, softer_glow_tail_width, softer_glow_tail_amplitude};
#[cfg(test)]
use blur::{blur, box_line, dilate, tent, tent_direct};
pub(crate) use composite::{VectorStroke, build_maps_prepared, composite_with_effects_prepared, map_count};
@@ -89,7 +89,14 @@ pub fn margin(layer: &Layer) -> i32 {
for e in layer.effects.items.iter().filter(|e| e.enabled()) {
let r = match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => s.distance + s.size,
Effect::OuterGlow(g) | Effect::InnerGlow(g) => g.size,
Effect::OuterGlow(g) => {
if g.technique == GlowTechnique::Softer {
g.size + softer_glow_tail_width(g.size)
} else {
g.size
}
}
Effect::InnerGlow(g) => g.size,
Effect::Stroke(s) => s.size,
Effect::Satin(s) => satin_distance(s) + s.size,
Effect::BevelEmboss(b) => b.size + b.soften,
+11
View File
@@ -210,3 +210,14 @@ fn tent_fast(m: &mut Map, w: f32) {
rows_par(&t, &mut b, mh, twice);
m.v = transpose_map(&b, mh, mw);
}
/// Raw coverage at the Size boundary, scaled through the glow's Range to a subtle one-percent tail.
pub fn softer_glow_tail_amplitude(range: f32) -> f32 {
let range = if range.is_finite() { range.clamp(0.01, 1.0) } else { 1.0 };
0.005 * range
}
/// Width of the added fading tail on a Softer outer glow, capped so large settings stay local.
pub fn softer_glow_tail_width(size: f32) -> f32 {
if size.is_finite() && size > 0.0 { (size * 0.1).clamp(1.0, 6.0) } else { 0.0 }
}
+27 -1
View File
@@ -1,4 +1,4 @@
use super::blur::{blur, dilate};
use super::blur::{blur, dilate, softer_glow_tail_width, softer_glow_tail_amplitude};
use super::distance::{dist_inside, dist_outside};
use super::{
BlendMode, Buffer, Contour, Effect, FxPaint, GlobalLight, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Map, Placement, PreparedPatterns, Rect,
@@ -303,6 +303,9 @@ pub(super) fn glow_map(shape: &Map, g: &Glow, inner: bool, origin: (i32, i32)) -
let (r, bw) = spread_split(g.size, g.spread);
let mut m = dilate(&src, r);
blur(&mut m, bw);
if !inner {
add_softer_glow_tail(&mut m, &src, g.size, g.range);
}
m
}
};
@@ -402,3 +405,26 @@ pub(super) fn paint_glow(dst: &mut Buffer, m: &Map, g: &Glow, shape_bounds: Rect
let strength = Map { w: m.w, h: m.h, v: m.v.iter().map(|v| (v * gain).min(1.0)).collect() };
paint(dst, &strength, |i| prepared.sample(1.0 - m.v.get(i).copied().unwrap_or(0.0).clamp(0.0, 1.0)), g.common.blend, g.common.opacity);
}
fn add_softer_glow_tail(map: &mut Map, shape: &Map, size: f32, range: f32) {
let width = softer_glow_tail_width(size);
if width == 0.0 {
return;
}
let amp = softer_glow_tail_amplitude(range);
let distance = dist_outside(shape);
for (coverage, distance) in map.v.iter_mut().zip(distance) {
let t = (distance + 0.5 - (size - width)) / width;
let tail = if (0.0..=1.0).contains(&t) {
amp * t * t * (3.0 - 2.0 * t)
} else if (1.0..=2.0).contains(&t) {
let u = t - 1.0;
let remaining = 1.0 - u;
amp * remaining * remaining * (1.0 + 2.0 * u)
} else {
0.0
};
*coverage = coverage.max(tail);
}
}
+50
View File
@@ -858,6 +858,56 @@ fn glows_match_photoshop() {
}
}
#[test]
fn softer_outer_glow_keeps_a_fading_tail_past_its_size() {
let mut shape = Map::new(220, 220, 0.0);
for y in 0..shape.h {
for x in 20..100 {
shape.v[y * shape.w + x] = 1.0;
}
}
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let m = glow_map(&shape, &glow, false, (0, 0));
let row = shape.h / 2;
let at = |distance: usize| m.v[row * m.w + 100 + distance];
assert!(at(50) > 0.024, "the softer shoulder should stay visible: {}", at(50));
assert!(at(60) > 1.0 / 255.0, "the glow must retain a visible tail at its Size: {}", at(60));
assert!(at(62) > 0.5 / 255.0, "the glow must extend beyond its Size: {}", at(62));
assert!(at(66) < 1e-6, "the added tail should fade to zero: {}", at(66));
}
#[test]
fn softer_outer_glow_margin_includes_its_tail_support() {
let glow = Glow {
common: photocraft_doc::FxCommon::new(BlendMode::Screen, 1.0),
paint: FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
};
let mut layer = Layer::raster("glow", photocraft_color::PixelFormat::RGBA8);
layer.effects.items.push(Effect::OuterGlow(glow));
assert_eq!(margin(&layer), 68);
}
#[test]
fn offsets_follow_light() {
let (dx, dy) = offset(120.0, 10.0);
+17 -1
View File
@@ -1449,6 +1449,18 @@ fn fs_mblur(in: VOut) -> @location(0) vec4<f32> {
@fragment
fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
let d = textureLoad(tex_a, local(in.pos), 0).r + 0.5;
if (op.p0.w > 0.5) {
let t = (d - op.p0.x) / op.p0.y;
var tail = 0.0;
if (t >= 0.0 && t <= 1.0) {
tail = op.p0.z * t * t * (3.0 - 2.0 * t);
} else if (t > 1.0 && t <= 2.0) {
let u = t - 1.0;
let remaining = 1.0 - u;
tail = op.p0.z * remaining * remaining * (1.0 + 2.0 * u);
}
return mout(tail);
}
var m = 0.5 * clamp(1.0 - (d - op.p0.x) / op.p0.y, 0.0, 1.0);
if (d <= op.p0.x) { m = 1.0; }
return mout(m);
@@ -1460,7 +1472,11 @@ fn fs_mglow(in: VOut) -> @location(0) vec4<f32> {
fn fs_mfinish(in: VOut) -> @location(0) vec4<f32> {
let p = local(in.pos);
var v = textureLoad(tex_a, p, 0).r;
if (op.p0.x > 0.5) { v = abs(v - textureLoad(tex_b, p, 0).r); }
if (op.p0.x > 1.5) {
v = max(v, textureLoad(tex_b, p, 0).r);
} else if (op.p0.x > 0.5) {
v = abs(v - textureLoad(tex_b, p, 0).r);
}
if (op.p0.y > 0.5 && op.p0.y < 1.5) { v = 1.0 - v; }
if (op.p0.z > 0.5) { v = lut(0, v); }
if (op.p0.y > 1.5) { v = 1.0 - v; }
+18
View File
@@ -226,6 +226,24 @@ impl B {
let Some(k) = blur_kernel(size) else { return src };
self.conv(src, k)
}
fn max(&mut self, a: In, b: In) -> In {
self.push(stage(Kernel::MFinish, Some(a), Some(b), [2.0, 0.0, 0.0, 0.0], 0))
}
fn softer_glow_tail(&mut self, blur: In, size: f32, range: f32) -> In {
let width = photocraft_compose::effects::softer_glow_tail_width(size);
if width == 0.0 {
return blur;
}
let distance = self.field(FieldKind::Outside, size + width);
let tail = self.push(stage(
Kernel::MGlow,
Some(distance),
None,
[size - width, width, photocraft_compose::effects::softer_glow_tail_amplitude(range), 1.0],
0,
));
self.max(blur, tail)
}
/// Separable convolution with a symmetric kernel `(radius, weights)` (two `MBlur` passes).
fn conv(&mut self, src: In, (r, w): (i32, Vec<f32>)) -> In {
if r <= 0 {
+2 -1
View File
@@ -55,7 +55,8 @@ impl B {
GlowTechnique::Softer => {
// Inner glows spread 1 - alpha.
let src = if inner { self.shift(In::Shape, 0.0, 0.0, 0.0, true) } else { In::Shape };
self.spread_blur(src, (g.size, g.spread), inner, 0.0, 0.0)
let m=self.spread_blur(src, (g.size,g.spread),inner,0.0,0.0);
if inner {m} else {self.softer_glow_tail(m,g.size,g.range)}
}
};
self.finish_glow(m, fx::glow_lut(g), inner, center);
+60
View File
@@ -51,6 +51,36 @@ fn programs_key_on_geometry_not_colour() {
assert_ne!(a.key, program(&Effect::DropShadow(s), &light, false).key);
}
#[test]
fn softer_outer_glow_program_keeps_the_tent_and_adds_a_distance_tail() {
let effect = Effect::OuterGlow(photocraft_doc::Glow {
common: FxCommon::new(BlendMode::Screen, 1.0),
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
});
let kernel = blur_kernel(60.0).unwrap();
let p = program(&effect, &GlobalLight::default(), false);
let blur: Vec<_> = p.stages.iter().filter(|s| s.kernel == Kernel::MBlur).collect();
assert_eq!(blur.len(), 2);
for stage in blur {
assert_eq!(stage.radius, kernel.0);
assert_eq!(stage.lut.as_deref().unwrap(), kernel.1.as_slice());
}
let tail = p.stages.iter().find(|s| s.kernel == Kernel::MGlow).unwrap();
assert_eq!(tail.a, Some(In::Field(FieldKind::Outside)));
assert_eq!(tail.p0, [54.0, 6.0, 0.0025, 1.0]);
assert!(p.stages.iter().any(|s| s.kernel == Kernel::MFinish && s.p0[0] == 2.0));
assert!(p.fields.contains(&(FieldKind::Outside, reach_for(66.0))));
}
/// A styled group's effect shape follows its own vector mask and its children's Blend If and
/// Advanced Blending, so the cached shape must be rebuilt when any of them changes.
#[test]
@@ -308,3 +338,33 @@ fn shape_tiles_and_group_pins_hold_addresses_not_pixels() {
drop(group);
assert!(pins.iter().all(|w| w.strong_count() == 0), "a group's pins keep none of its pixels");
}
#[test]
fn softer_outer_glow_program_keeps_the_tent_and_adds_a_distance_tail() {
let effect = Effect::OuterGlow(photocraft_doc::Glow {
common: FxCommon::new(BlendMode::Screen, 1.0),
paint: photocraft_doc::FxPaint::Color(photocraft_color::Color::WHITE),
technique: GlowTechnique::Softer,
spread: 0.0,
size: 60.0,
contour: Contour::Linear,
anti_alias: false,
range: 0.5,
jitter: 0.0,
noise: 0.0,
source: GlowSource::Edge,
});
let kernel = blur_kernel(60.0).unwrap();
let p = program(&effect, &GlobalLight::default(), false);
let blur: Vec<_> = p.stages.iter().filter(|s| s.kernel == Kernel::MBlur).collect();
assert_eq!(blur.len(), 2);
for stage in blur {
assert_eq!(stage.radius, kernel.0);
assert_eq!(stage.lut.as_deref().unwrap(), kernel.1.as_slice());
}
let tail = p.stages.iter().find(|s| s.kernel == Kernel::MGlow).unwrap();
assert_eq!(tail.a, Some(In::Field(FieldKind::Outside)));
assert_eq!(tail.p0, [54.0, 6.0, 0.0025, 1.0]);
assert!(p.stages.iter().any(|s| s.kernel == Kernel::MFinish && s.p0[0] == 2.0));
assert!(p.fields.contains(&(FieldKind::Outside, reach_for(66.0))));
}