Merge pull request #9256 from davidmann4/fix/scale-effects-bevel-texture

Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254)
This commit is contained in:
David Mann authored and GitHub committed 2026-11-08 01:50:08 +01:00
commit 940b352ca0
7 files changed
+203 -9

No files matched your search

+31 -1
View File
@@ -344,6 +344,9 @@ impl Tile {
/// Most taps per axis of the minification filter (the WGSL mirror uses the same cap).
const MAX_TAPS: usize = 8;
/// The smallest pattern scale a placement draws at (a fraction): keeps tile coordinates finite.
pub const MIN_PLACEMENT_SCALE: f64 = 1e-6;
/// Taps per axis for a placement whose inverse scale is `inv_scale`: one pixel covers
/// `inv_scale` tile pixels, so that many taps (capped) box-filter it. 1 at 100 % and above.
fn pixel_taps(inv_scale: f64) -> usize {
@@ -387,7 +390,9 @@ impl Placement {
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
// Any positive scale draws: a preview at a reduced work scale takes a stored 2 % pattern
// well below 0.1 %. Only 0, negatives and non-finite values fall back to 100 %.
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(MIN_PLACEMENT_SCALE) } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
@@ -608,6 +613,31 @@ mod tests {
assert_eq!((row[0][0], row[1][0]), (0.0, 1.0));
}
/// Review of #9256: a preview at 4 % work scale draws a stored 2 % pattern at 0.08 %, which
/// used to fall back to 100 % and show the stripes full size.
#[test]
fn placement_keeps_scales_below_a_tenth_of_a_percent() {
let place = Placement::new(Rect::EMPTY, false, (0.0, 0.0), 0.0008, 0.0);
let (u, v) = place.map(1.0, 2.0);
assert!((u - 1250.0).abs() < 1e-3 && (v - 2500.0).abs() < 1e-3, "{u},{v}");
// 1-px dark / light stripes: at 100 % the pixels alternate 0 / 1; at 0.08 % each pixel
// covers 1250 of them, so every pixel is their mean.
let mut s = Surface::new(PixelFormat::RGBA8);
s.fill_rect(Rect::new(0, 0, 8, 8), &[1.0, 1.0, 1.0, 1.0]);
for x in (0..8).step_by(2) {
s.fill_rect(Rect::new(x, 0, x + 1, 8), &[0.0, 0.0, 0.0, 1.0]);
}
let t = Tile::new(&Pattern::new("stripes", s, 8, 8)).unwrap();
for p in render(&t, &place, Rect::new(0, 0, 8, 1)) {
assert!((p[0] - 0.5).abs() < 0.1, "{p:?}");
}
// 0, negative and non-finite scales still draw at 100 %.
for bad in [0.0, -0.5, f32::NAN, f32::INFINITY] {
let p = Placement::new(Rect::EMPTY, false, (0.0, 0.0), bad, 0.0);
assert_eq!(p.map(3.0, 0.0).0, 3.0, "{bad}");
}
}
#[test]
fn sampling_at_hostile_phases_does_not_overflow() {
let patterns = [checker()];
+4
View File
@@ -10,6 +10,10 @@ use serde::{Deserialize, Serialize};
use crate::adjust::CurvePoint;
/// The range of a style's pattern scale as a fraction (1 = 100 %): Pattern Overlay, a pattern
/// Stroke and the Bevel & Emboss Texture all take 1..1000 % in the Layer Style dialog.
pub const PATTERN_SCALE_RANGE: (f32, f32) = (0.01, 10.0);
/// Gradient geometry (fill layers, gradient overlays, gradient strokes).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum GradientStyle {
+29 -4
View File
@@ -134,9 +134,21 @@ pub(crate) fn reidentify_copied_layers(doc: &mut Document) {
}
}
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles, Layer Style ›
/// Scale Effects, and previews that draw a style at a reduced resolution).
/// Scale every size and distance of a style by `k` (Image Size's Scale Styles and Layer Style ›
/// Scale Effects), with pattern scales kept within the dialog's 1..1000 %.
pub fn scale_effects(fx: &mut Effects, k: f32) {
scale_effects_with(fx, k, true);
}
/// [`scale_effects`] for a preview that draws a style at a reduced resolution: pattern scales
/// follow `k` exactly, so the preview matches the style it stands for (a 2 % pattern drawn at a
/// quarter of the size is 0.5 %, not the dialog's 1 % minimum).
pub fn scale_effects_preview(fx: &mut Effects, k: f32) {
scale_effects_with(fx, k, false);
}
fn scale_effects_with(fx: &mut Effects, k: f32, clamp: bool) {
let pattern = |scale: &mut f32| scale_pattern(scale, k, clamp);
for e in &mut fx.items {
match e {
Effect::DropShadow(s) | Effect::InnerShadow(s) => {
@@ -152,14 +164,17 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
Effect::BevelEmboss(b) => {
b.size *= k;
b.soften *= k;
if let Some(t) = &mut b.texture {
pattern(&mut t.scale);
}
}
Effect::PatternOverlay { scale, .. } => *scale *= k,
Effect::PatternOverlay { scale, .. } => pattern(scale),
Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {}
}
if let Effect::Stroke(s) = e
&& let FxPaint::Pattern { scale, .. } = &mut s.paint
{
*scale *= k;
pattern(scale);
}
}
// Effects changed: the preserved PSD block no longer applies.
@@ -168,6 +183,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) {
}
}
/// Scale a style's pattern scale; with `clamp`, kept within the 1..1000 % the Layer Style dialog
/// and the reference application allow (a 10 % pattern reduced to 5 % stays at 1 %).
fn scale_pattern(scale: &mut f32, k: f32, clamp: bool) {
let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE;
let v = *scale * k;
if !v.is_nan() {
*scale = if clamp { v.clamp(min, max) } else { v };
}
}
/// Image Size's resampling methods; `None` changes the resolution only.
#[derive(Clone, Copy)]
enum Method {
@@ -140,6 +140,119 @@ fn global_light_blending_options_scale_effects() {
}
}
#[test]
fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() {
let mut s = session(8, "rgb");
paint(&mut s, disc);
s.execute("layer.layerStyle.patternOverlay", json!({"scale": 100})).unwrap();
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": 100})).unwrap();
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
let fx = &active(&s).effects.items;
let overlay = fx.iter().find_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
_ => None,
});
let texture = fx.iter().find_map(|e| match e {
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
_ => None,
});
// Both patterns tile at half size, so a texture aligned with the overlay stays aligned.
assert_eq!((overlay, texture), (Some(0.5), Some(0.5)));
}
/// The pattern scales of Pattern Overlay, the Bevel & Emboss Texture and a pattern Stroke, in
/// that order.
fn pattern_scales(s: &Session) -> (Option<f32>, Option<f32>, Option<f32>) {
let fx = &active(s).effects.items;
let overlay = fx.iter().find_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
_ => None,
});
let texture = fx.iter().find_map(|e| match e {
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
_ => None,
});
let stroke = fx.iter().find_map(|e| match e {
Effect::Stroke(st) => match &st.paint {
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
_ => None,
},
_ => None,
});
(overlay, texture, stroke)
}
/// A layer with a Pattern Overlay, a Bevel & Emboss Texture and a pattern Stroke, all at `pct` %.
fn pattern_styled(pct: f64) -> Session {
let mut s = session(8, "rgb");
paint(&mut s, disc);
s.execute("layer.layerStyle.patternOverlay", json!({"scale": pct})).unwrap();
s.execute("layer.layerStyle.bevelEmboss", json!({"texture": "Bubbles", "textureScale": pct})).unwrap();
s.execute("layer.layerStyle.stroke", json!({"size": 3})).unwrap();
s.edit("pattern stroke", |doc, active| {
let layer = doc.layer_mut(active.unwrap()).unwrap();
for e in &mut layer.effects.items {
if let Effect::Stroke(st) = e {
st.paint = photocraft_doc::FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale: pct as f32 / 100.0 };
}
}
Ok(())
})
.unwrap();
s
}
#[test]
fn scale_effects_keeps_pattern_scales_within_the_dialog_range() {
// 10 % reduced to 1 % would be 0.1 %: the patterns stop at the dialog's 1 %.
let mut s = pattern_styled(10.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1})).unwrap();
assert_eq!(pattern_scales(&s), (Some(0.01), Some(0.01), Some(0.01)));
// 500 % enlarged to 1000 % would be 5000 %: they stop at 1000 %.
let mut s = pattern_styled(500.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 1000})).unwrap();
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
// Inside the range nothing is clamped: 200 % halved is 100 %.
let mut s = pattern_styled(200.0);
s.execute("layer.layerStyle.scaleEffects", json!({"scale": 50})).unwrap();
assert_eq!(pattern_scales(&s), (Some(1.0), Some(1.0), Some(1.0)));
}
/// Previews at a reduced resolution scale the patterns exactly, past the dialog's range: only
/// commands that store the style clamp them.
#[test]
fn preview_scaling_leaves_pattern_scales_unclamped() {
let s = pattern_styled(2.0);
let mut fx = active(&s).effects.clone();
crate::image_cmds::scale_effects_preview(&mut fx, 0.25);
let mut clamped = active(&s).effects.clone();
crate::image_cmds::scale_effects(&mut clamped, 0.25);
let scales = |fx: &photocraft_doc::Effects| {
fx.items
.iter()
.filter_map(|e| match e {
Effect::PatternOverlay { scale, .. } => Some(*scale),
Effect::BevelEmboss(b) => b.texture.as_ref().map(|t| t.scale),
Effect::Stroke(st) => match &st.paint {
photocraft_doc::FxPaint::Pattern { scale, .. } => Some(*scale),
_ => None,
},
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(scales(&fx), vec![0.005; 3]);
assert_eq!(scales(&clamped), vec![0.01; 3]);
}
#[test]
fn image_size_scale_styles_keeps_pattern_scales_within_the_dialog_range() {
// 40x30 -> 400x300 with Scale Styles multiplies by 10: 500 % would become 5000 %.
let mut s = pattern_styled(500.0);
s.execute("image.imageSize", json!({"width": 400, "height": 300, "scaleStyles": true})).unwrap();
assert_eq!(pattern_scales(&s), (Some(10.0), Some(10.0), Some(10.0)));
}
#[test]
fn global_light_rejects_numbers_beyond_f32() {
let mut s = session(8, "rgb");
+3 -2
View File
@@ -2,6 +2,7 @@
use photocraft_color::{BlendMode, Color};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::effects::PATTERN_SCALE_RANGE;
use photocraft_doc::{
Bevel, BevelStyle, BevelTechnique, Contour, Effect, FxCommon, FxPaint, Glow, GlowSource, GlowTechnique, Gradient, GradientStyle, Satin, Shadow, StrokeFx,
StrokePosition,
@@ -804,7 +805,7 @@ fn overlay_pattern(fx: &mut Effect, p: &Value) {
*id = String::new();
}
if let Some(x) = f_of(p, "scale") {
*scale = (x / 100.0).clamp(0.01, 10.0);
*scale = (x / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1);
}
if let Some(x) = f_of(p, "angle") {
*angle = x;
@@ -912,7 +913,7 @@ fn overlay_bevel(bv: &mut Bevel, p: &Value) {
bv.texture = Some(photocraft_doc::BevelTexture {
name: t.to_string(),
id: t.to_string(),
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(0.01, 10.0),
scale: (f(p, "textureScale", 100.0) / 100.0).clamp(PATTERN_SCALE_RANGE.0, PATTERN_SCALE_RANGE.1),
depth: (f(p, "textureDepth", 100.0) / 100.0).clamp(-10.0, 10.0),
invert: b(p, "textureInvert", false),
link: b(p, "textureLink", true),
+1 -1
View File
@@ -24,7 +24,7 @@ pub(super) fn placement(anchor: (f64, f64), link: bool, phase: (f32, f32), scale
let base = if link { anchor } else { (0.0, 0.0) };
let origin = (base.0 + f64::from(phase.0), base.1 + f64::from(phase.1));
let s = f64::from(scale);
let inv_scale = if s.is_finite() && s > 1e-3 { 1.0 / s } else { 1.0 };
let inv_scale = if s.is_finite() && s > 0.0 { 1.0 / s.max(photocraft_compose::pattern::MIN_PLACEMENT_SCALE) } else { 1.0 };
let a = f64::from(angle).to_radians();
Placement { origin, cs: (a.cos(), a.sin()), inv_scale }
}
+22 -1
View File
@@ -106,7 +106,7 @@ pub fn work_layer(layer: &Layer, scale: f64) -> Layer {
l.vector_mask = None;
l.fill_cache = None;
l.blend = BlendMode::Normal;
photocraft_engine::image_cmds::scale_effects(&mut l.effects, scale as f32);
photocraft_engine::image_cmds::scale_effects_preview(&mut l.effects, scale as f32);
l.effects.reference = l.effects.reference.map(|(x, y)| (x * scale, y * scale));
l
}
@@ -468,6 +468,27 @@ mod tests {
l
}
fn pattern_stroke_scale(l: &Layer) -> Option<f32> {
l.effects.items.iter().find_map(|e| match e {
Effect::Stroke(StrokeFx { paint: FxPaint::Pattern { scale, .. }, .. }) => Some(*scale),
_ => None,
})
}
/// The preview draws a pattern at the work scale exactly: the dialog's 1..1000 % range is for
/// stored styles, so a 2 % pattern at a quarter of the size is 0.5 %, and an imported 2000 %
/// one stays 2000 % at full size, as the committed style draws them.
#[test]
fn work_layer_scales_patterns_past_the_dialog_range() {
let with_pattern = |scale: f32| {
let Effect::Stroke(mut st) = stroke(3.0) else { panic!("a stroke") };
st.paint = FxPaint::Pattern { name: "Bubbles".into(), id: String::new(), scale };
styled(vec![Effect::Stroke(st)])
};
assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(0.02), 0.25)), Some(0.005));
assert_eq!(pattern_stroke_scale(&work_layer(&with_pattern(20.0), 1.0)), Some(20.0));
}
fn doc() -> Document {
Document::new("t", photocraft_geom::Size::new(200, 200), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8)
}