From 79f9ff4cdea688eb5b39696c2a5db5cb4e4f1a1d Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:28:49 +0200 Subject: [PATCH 1/5] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scale_effects scaled Pattern Overlay's and a pattern Stroke's scale but left Bevel & Emboss › Texture at its old size, so a texture aligned with the overlay drifted after Scale Effects, Image Size or Crop W x H. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/layer_menu_cmds/tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/engine/src/layer_menu_cmds/tests.rs b/crates/engine/src/layer_menu_cmds/tests.rs index b0cceb72a8..4a388c29e1 100644 --- a/crates/engine/src/layer_menu_cmds/tests.rs +++ b/crates/engine/src/layer_menu_cmds/tests.rs @@ -140,6 +140,26 @@ 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))); +} + #[test] fn global_light_rejects_numbers_beyond_f32() { let mut s = session(8, "rgb"); From 42ec692355a1b842c94dfd9c3104c7250d00d3db Mon Sep 17 00:00:00 2001 From: David Mann Date: Sun, 25 Oct 2026 02:34:00 +0200 Subject: [PATCH 2/5] Scale Effects and Image Size scale Bevel & Emboss Texture's scale (#9254) Multiply BevelTexture::scale by the effects factor in scale_effects, as Pattern Overlay's and a pattern Stroke's scale already are. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/image_cmds.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/image_cmds.rs b/crates/engine/src/image_cmds.rs index 6353f1f2cc..32eadaed41 100644 --- a/crates/engine/src/image_cmds.rs +++ b/crates/engine/src/image_cmds.rs @@ -150,6 +150,9 @@ 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 { + t.scale *= k; + } } Effect::PatternOverlay { scale, .. } => *scale *= k, Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} From 608cedc5eb3e164148a347fa80eb3bb02163b7c2 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 01:24:20 +0100 Subject: [PATCH 3/5] Address review: keep scaled pattern scales within 1..1000 % scale_effects (Image Size's Scale Styles, Layer Style > Scale Effects, the Free Transform style preview) now clamps the Pattern Overlay, Bevel & Emboss Texture and pattern Stroke scales to the 1..1000 % the Layer Style dialog allows, through one shared photocraft_doc::effects::PATTERN_SCALE_RANGE that the layer style param parser now uses too. Co-Authored-By: Claude Opus 5.5 --- crates/doc/src/effects.rs | 4 ++ crates/engine/src/cmd/image_cmds.rs | 16 ++++- .../engine/src/cmd/layer_menu_cmds/tests.rs | 66 +++++++++++++++++++ crates/engine/src/layer_style.rs | 5 +- 4 files changed, 86 insertions(+), 5 deletions(-) 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 6b4f06c3ee..e41fbf09bb 100644 --- a/crates/engine/src/cmd/image_cmds.rs +++ b/crates/engine/src/cmd/image_cmds.rs @@ -151,16 +151,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { b.size *= k; b.soften *= k; if let Some(t) = &mut b.texture { - t.scale *= k; + scale_pattern(&mut t.scale, k); } } - Effect::PatternOverlay { scale, .. } => *scale *= k, + Effect::PatternOverlay { scale, .. } => scale_pattern(scale, k), Effect::GradientOverlay { .. } | Effect::ColorOverlay { .. } => {} } if let Effect::Stroke(s) = e && let FxPaint::Pattern { scale, .. } = &mut s.paint { - *scale *= k; + scale_pattern(scale, k); } } // Effects changed: the preserved PSD block no longer applies. @@ -169,6 +169,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } +/// Scale a style's pattern scale, 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) { + let (min, max) = photocraft_doc::effects::PATTERN_SCALE_RANGE; + let v = *scale * k; + if !v.is_nan() { + *scale = v.clamp(min, max); + } +} + /// 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 758ba4c4ba..b60e984bd0 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -160,6 +160,72 @@ fn scale_effects_scales_the_bevel_texture_with_the_pattern_overlay() { 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))); +} + +#[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 176f08b264..af7180cc8a 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, @@ -795,7 +796,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; @@ -903,7 +904,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), From 1f1f42182b9be8d8c7ab7d6620cb65056588cc7c Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 14:20:22 +0100 Subject: [PATCH 4/5] Address CodeRabbit review: keep the pattern-scale clamp out of previews transform_fx::work_layer scales a style to the preview's work resolution with scale_effects, so the 1..1000 % clamp turned a 2 % pattern at a quarter of the size into 1 % instead of 0.5 %, and an imported 2000 % pattern into 1000 % at full size. scale_effects_preview scales patterns exactly; Scale Effects and Image Size's Scale Styles keep the clamp. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/image_cmds.rs | 30 +++++++++++++------ .../engine/src/cmd/layer_menu_cmds/tests.rs | 27 +++++++++++++++++ crates/ui-egui/src/tools/transform_fx.rs | 23 +++++++++++++- 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/cmd/image_cmds.rs b/crates/engine/src/cmd/image_cmds.rs index 0cac8be4b2..03683f1d00 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) => { @@ -153,16 +165,16 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { b.size *= k; b.soften *= k; if let Some(t) = &mut b.texture { - scale_pattern(&mut t.scale, k); + pattern(&mut t.scale); } } - Effect::PatternOverlay { scale, .. } => scale_pattern(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_pattern(scale, k); + pattern(scale); } } // Effects changed: the preserved PSD block no longer applies. @@ -171,13 +183,13 @@ pub fn scale_effects(fx: &mut Effects, k: f32) { } } -/// Scale a style's pattern scale, 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) { +/// 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 = v.clamp(min, max); + *scale = if clamp { v.clamp(min, max) } else { v }; } } diff --git a/crates/engine/src/cmd/layer_menu_cmds/tests.rs b/crates/engine/src/cmd/layer_menu_cmds/tests.rs index b60e984bd0..0dcd7c2333 100644 --- a/crates/engine/src/cmd/layer_menu_cmds/tests.rs +++ b/crates/engine/src/cmd/layer_menu_cmds/tests.rs @@ -218,6 +218,33 @@ fn scale_effects_keeps_pattern_scales_within_the_dialog_range() { 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 %. 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) } From ed0b2a726b617f8f17d57e34ecbd62d6c9f63034 Mon Sep 17 00:00:00 2001 From: David Mann Date: Fri, 6 Nov 2026 23:10:58 +0100 Subject: [PATCH 5/5] Address CodeRabbit review: draw pattern scales below 0.1 % (#9256) A preview at a reduced work scale (4 %) takes a stored 2 % pattern to 0.08 %, which Placement::anchored and the GPU planner's placement treated as invalid and drew at 100 %. Any positive finite scale now draws, floored at 1e-6 to keep tile coordinates finite. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/pattern.rs | 32 +++++++++++++++++++++++++++++++- crates/gpu/src/plan/lower.rs | 2 +- 2 files changed, 32 insertions(+), 2 deletions(-) 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/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 } }