diff --git a/crates/compose/src/pattern.rs b/crates/compose/src/pattern.rs index b351bdef06..30c64c7bdf 100644 --- a/crates/compose/src/pattern.rs +++ b/crates/compose/src/pattern.rs @@ -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()]; diff --git a/crates/doc/src/effects.rs b/crates/doc/src/effects.rs index 4e3f32a97f..a71aa883dd 100644 --- a/crates/doc/src/effects.rs +++ b/crates/doc/src/effects.rs @@ -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 { diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index fa6a50e0a1..8b2e60c6f6 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -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 { diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index c967f86964..0dcd7c2333 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -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, Option, Option) { + 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::>() + }; + 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"); diff --git a/crates/engine/src/layer_style.rs b/crates/engine/src/layer_style.rs index 9bb6ab7693..f89779954b 100644 --- a/crates/engine/src/layer_style.rs +++ b/crates/engine/src/layer_style.rs @@ -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), diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs index 202f0216e1..4bda862af1 100644 --- a/crates/gpu/src/plan/lower.rs +++ b/crates/gpu/src/plan/lower.rs @@ -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 } } diff --git a/crates/ui-egui/src/tools/transform_fx.rs b/crates/ui-egui/src/tools/transform_fx.rs index 0ad888536d..5be2428c85 100644 --- a/crates/ui-egui/src/tools/transform_fx.rs +++ b/crates/ui-egui/src/tools/transform_fx.rs @@ -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 { + 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) }