mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-13 21:52:54 +00:00
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:
7 files changed
+203
-9
No files matched your search
@@ -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()];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user