From b8846ee00f78e085d910dcd569bad85712fb7a9e Mon Sep 17 00:00:00 2001 From: datatimp <12644819+datatimp@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:16:40 -0400 Subject: [PATCH] Color Halftone: Max Radius in pixels of the raster effects resolution (#3260) --- crates/doc/src/halftone_radii.rs | 145 ++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/doc/src/limits.rs | 5 +- crates/effects/src/catalog.rs | 19 ++- crates/effects/src/lib.rs | 2 +- crates/effects/src/params.rs | 9 +- crates/effects/src/pixel.rs | 15 +- crates/effects/src/pixel/pixelate.rs | 7 +- crates/effects/src/pixel/tests.rs | 75 +++++---- crates/effects/src/pixel/tests_sketch.rs | 2 +- crates/effects/src/tests.rs | 25 ++- crates/engine/src/cmd/fileio/tests_pdfedit.rs | 29 ++++ crates/engine/src/cmd/fileio/tests_svgedit.rs | 2 +- crates/format/src/lib.rs | 25 +++ crates/render/src/fx/pixels.rs | 16 +- crates/render/src/fx/raster.rs | 8 +- crates/ui-egui/src/dialogs/form.rs | 10 ++ crates/ui-egui/src/dialogs/tests.rs | 72 ++++++--- crates/ui-egui/src/panels/appearance.rs | 2 + docs/effects-parity.md | 1 + docs/target-app-parity.md | 5 +- 21 files changed, 391 insertions(+), 84 deletions(-) create mode 100644 crates/doc/src/halftone_radii.rs diff --git a/crates/doc/src/halftone_radii.rs b/crates/doc/src/halftone_radii.rs new file mode 100644 index 000000000..6c3d4544d --- /dev/null +++ b/crates/doc/src/halftone_radii.rs @@ -0,0 +1,145 @@ +//! Color Halftone's Max Radius was a distance in points until documents of version 2; since then +//! it is in pixels of the Document Raster Effects resolution, as public documentation gives it +//! (#3260). [`Document::migrate_halftone_radii`] converts the radii of older documents. + +use std::sync::Arc; + +use serde_json::{Value, json}; + +use crate::limits::FORMAT_VERSION; +use crate::{Appearance, Document, Effect, Node}; + +/// The first document version whose Color Halftone radii are in pixels. +const PIXEL_RADII_SINCE: u32 = 2; +const HALFTONE: &str = "pixelate.colorHalftone"; +const RADIUS: &str = "maxRadius"; +/// The radius (points) an older document's halftone without one drew with. +const OLD_DEFAULT: f64 = 8.0; +/// The range the radius was kept within in points, and is kept within in pixels. +const RANGE: std::ops::RangeInclusive = 4.0..=127.0; + +impl Document { + /// Saved before version 2: each Color Halftone's Max Radius in points becomes the pixels of + /// the document's raster effects resolution that draw the same dots, within the 4 to 127 px + /// the effect takes (a radius over 127 px, from a large radius at a high resolution, draws + /// smaller dots than before). In the art, symbols, patterns, graph designs, opacity masks and + /// graphic styles. + pub fn migrate_halftone_radii(&mut self) { + if self.version >= PIXEL_RADII_SINCE { + return; + } + self.version = FORMAT_VERSION; + let k = self.raster_ppi() / 72.0; + let fix = &mut |e: &mut Effect| to_pixels(e, k); + for l in &mut self.layers { + node(l, fix); + } + for s in &mut self.symbols { + node(&mut s.art, fix); + } + for p in &mut self.patterns { + for a in &mut p.art { + node(a, fix); + } + } + for g in &mut self.graph_designs { + node(&mut g.art, fix); + } + if self.graphic_styles.iter().any(|s| has_halftone(&s.appearance)) { + for s in self.graphic_styles.iter_mut() { + appearance(&mut s.appearance, fix); + } + } + } +} + +/// `e`'s radius in points as pixels, `k` pixels a point, when it is a Color Halftone. +fn to_pixels(e: &mut Effect, k: f64) { + if e.id != HALFTONE { + return; + } + let pt = e.params.get(RADIUS).and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(OLD_DEFAULT); + let px = pt.clamp(*RANGE.start(), *RANGE.end()) * k; + if !e.params.is_object() { + e.params = Value::Object(Default::default()); + } + e.params[RADIUS] = json!(px.clamp(*RANGE.start(), *RANGE.end())); +} + +fn has_halftone(a: &Appearance) -> bool { + a.effects.iter().chain(a.items.iter().flat_map(|i| i.effects())).any(|e| e.id == HALFTONE) +} + +fn appearance(a: &mut Appearance, fix: &mut dyn FnMut(&mut Effect)) { + for e in a.effects.iter_mut().chain(a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut())) { + fix(e); + } +} + +/// Fix the effects of `n`'s subtree and its opacity masks (copying only what changes). +fn node(n: &mut Arc, fix: &mut dyn FnMut(&mut Effect)) { + let n = Arc::make_mut(n); + if has_halftone(&n.appearance) { + appearance(&mut n.appearance, fix); + } + if let Some(m) = n.extras_if_any().and_then(|e| e.mask.as_mut()) { + node(&mut m.art, fix); + } + if let Some(ch) = n.children_mut() { + for c in ch { + node(c, fix); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn halftone(radius: Option) -> Effect { + let params = radius.map_or_else(|| json!({"channel1": 108.0}), |r| json!({"maxRadius": r, "channel1": 108.0})); + Effect { id: HALFTONE.into(), params, visible: true } + } + + fn radius(e: &Effect) -> f64 { + e.params[RADIUS].as_f64().unwrap() + } + + #[test] + fn older_radii_become_the_pixels_that_draw_the_same_dots() { + for (ppi, pt, px) in [(72.0, 8.0, 8.0), (300.0, 8.0, 33.333333333333336), (300.0, 4.0, 16.666666666666668), (150.0, 127.0, 127.0)] { + let mut e = halftone(Some(pt)); + to_pixels(&mut e, ppi / 72.0); + assert!((radius(&e) - px).abs() < 1e-9, "{pt} pt at {ppi} ppi: {}", radius(&e)); + assert_eq!(e.params["channel1"], json!(108.0), "the angles stay"); + } + // A radius left out drew at the old default, 8 pt. + let mut e = halftone(None); + to_pixels(&mut e, 300.0 / 72.0); + assert!((radius(&e) - 33.333333333333336).abs() < 1e-9); + // Other effects keep their parameters. + let mut blur = Effect { id: "blur.gaussian".into(), params: json!({"radius": 8.0}), visible: true }; + to_pixels(&mut blur, 300.0 / 72.0); + assert_eq!(blur.params, json!({"radius": 8.0})); + } + + #[test] + fn only_documents_before_version_2_are_converted_once() { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = 300.0; + let mut style = d.graphic_styles.first().cloned().unwrap(); + style.appearance.effects.push(halftone(Some(8.0))); + d.graphic_styles.push(style); + let fresh = d.clone(); + d.migrate_halftone_radii(); + assert_eq!(d, fresh, "a new document's radii are already pixels"); + d.version = 1; + d.migrate_halftone_radii(); + assert_eq!(d.version, FORMAT_VERSION); + let r = d.graphic_styles.last().map(|s| radius(&s.appearance.effects[0])).unwrap(); + assert!((r - 33.333333333333336).abs() < 1e-9, "{r}"); + let once = d.clone(); + d.migrate_halftone_radii(); + assert_eq!(d, once, "converted documents are left alone"); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b6a9a60ef..0b3c3f1fb 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -16,6 +16,7 @@ pub mod corners; pub mod depth; pub mod dimension; pub mod graph; +mod halftone_radii; pub mod hit; mod id_index; pub mod inks; diff --git a/crates/doc/src/limits.rs b/crates/doc/src/limits.rs index 027148ade..c143e545f 100644 --- a/crates/doc/src/limits.rs +++ b/crates/doc/src/limits.rs @@ -13,7 +13,10 @@ pub enum DocError { Invalid(String), } -pub const FORMAT_VERSION: u32 = 1; +/// The version of the document model new documents are made in: 2 measures Color Halftone's Max +/// Radius in pixels of the raster effects resolution (older documents are converted on opening, +/// [`crate::Document::migrate_halftone_radii`]). +pub const FORMAT_VERSION: u32 = 2; /// Node, slice and asset ids are handed out below this: no document gets near it by counting, so /// larger ids in a file are damage (or made by hand) and the counter never follows them (#1718). diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 32e611c62..b060bed1b 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -43,13 +43,25 @@ pub fn is_length(id: &str, key: &str, relative: bool) -> bool { } /// The range in points that distance parameter `key` of effect `id` is kept within, as its -/// parameter documentation gives it (`{maxRadius: pt 4..127 (8), …}`); `None` when it gives none. +/// parameter documentation gives it (`{edgeWidth: pt 1..14 (2), …}`); `None` when it gives none. pub fn length_range(id: &str, key: &str) -> Option> { + documented_range(id, key, "pt") +} + +/// The range of parameter `key` of effect `id` when it is a size in pixels of the Document Raster +/// Effects resolution (`{maxRadius: px 4..127 (8), …}`), which no unit setting changes and which +/// stays in pixels when the resolution does; `None` for other parameters. +pub fn raster_px_range(id: &str, key: &str) -> Option> { + documented_range(id, key, "px") +} + +/// The range parameter `key` of effect `id` is documented with in `unit` (`key: unit lo..hi`). +fn documented_range(id: &str, key: &str, unit: &str) -> Option> { let doc = catalog_index().get(id)?.params; let starts = |at: usize| doc.get(..at).and_then(|s| s.chars().last()).is_some_and(|c| c == '{' || c == ' '); let rest = doc.match_indices(key).map(|(at, _)| at).filter(|at| starts(*at)).find_map(|at| { let rest = doc.get(at + key.len()..)?; - rest.strip_prefix(": pt ").or_else(|| rest.strip_prefix("?: pt ")) + rest.strip_prefix(": ").or_else(|| rest.strip_prefix("?: "))?.strip_prefix(unit)?.strip_prefix(' ') })?; let (lo, hi) = rest.split_whitespace().next()?.split_once("..")?; let (lo, hi) = (lo.parse::().ok()?, hi.trim_end_matches(',').parse::().ok()?); @@ -81,7 +93,6 @@ fn lengths_of(id: &str) -> Lengths { "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), "brushStrokes.sumiE" => always(&["strokeWidth"]), - "pixelate.colorHalftone" => always(&["maxRadius"]), "pixelate.crystallize" | "pixelate.pointillize" => always(&["cellSize"]), "texture.craquelure" => always(&["crackSpacing"]), "texture.mosaicTiles" => always(&["tileSize"]), @@ -633,7 +644,7 @@ pub fn effect_catalog() -> Vec { "pixelate.colorHalftone", "Color Halftone…", PIXELATE, - "{maxRadius: pt 4..127 (8; the radius of a dot at full strength, which fills its square cell), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", + "{maxRadius: px 4..127 (8; the radius of a dot at full strength, which fills its square cell, in pixels of the Document Raster Effects resolution), channel1: screen angle deg -360..360 (108), channel2: deg (162), channel3: deg (90), channel4: deg (45)} screens each colour channel (red, green, blue: channels 1 to 3; in CMYK documents cyan, magenta, yellow, black: 1 to 4) at its angle into dots whose area follows the channel's mean over their cell", json!({"maxRadius": 8.0, "channel1": 108.0, "channel2": 162.0, "channel3": 90.0, "channel4": 45.0}), ), r( diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b15a642c3..c080784c8 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -95,7 +95,7 @@ mod catalog; mod geometry; mod params; -pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects}; +pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects, raster_px_range}; pub(crate) use geometry::{Budget, apply_one}; pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with}; #[cfg(test)] diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index d64ac7b78..e4b478423 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -95,13 +95,16 @@ pub fn new_effect(id: &str, params: &Value) -> Option { } /// Scale the distance parameters of `e` by `s` (Scale Strokes & Effects; see -/// [`EffectInfo::lengths`]). A distance left at its default is written out scaled; one the -/// effect doesn't have (Outline Stroke's weight following its stroke) stays absent. +/// [`EffectInfo::lengths`]), and its sizes in raster pixels ([`crate::raster_px_range`]), which +/// keep their share of the object so. A distance left at its default is written out scaled; one +/// the effect doesn't have (Outline Stroke's weight following its stroke) stays absent. pub fn scale_effect(e: &mut Effect, s: f64) { let Some(info) = catalog_index().get(e.id.as_str()) else { return }; let merged = merged_params(&e.id, &e.params); let relative = util::flag(&merged, "relative", false); - for k in info.lengths.keys(relative) { + let mut keys: Vec<&str> = info.lengths.keys(relative).collect(); + keys.extend(merged.as_object().into_iter().flat_map(|m| m.keys()).map(String::as_str).filter(|k| crate::raster_px_range(&e.id, k).is_some())); + for k in keys { let v = util::num(&merged, k, f64::NAN); if v.is_finite() { if !e.params.is_object() { diff --git a/crates/effects/src/pixel.rs b/crates/effects/src/pixel.rs index 8885c9def..2a122bb66 100644 --- a/crates/effects/src/pixel.rs +++ b/crates/effects/src/pixel.rs @@ -254,7 +254,8 @@ pub enum PixelFx { /// Stylize › Glowing Edges: bright coloured Sobel outlines on black. GlowingEdges { width: f64, brightness: f64, smoothness: f64 }, /// Pixelate › Color Halftone: each colour channel screened at its angle (`angles`° of channels - /// 1 to 4) into dots of up to `max_radius`, whose area follows the channel's strength. + /// 1 to 4) into dots of up to `max_radius` pixels of the Document Raster Effects resolution + /// ([`PixelSpace::line`] each), whose area follows the channel's strength. ColorHalftone { max_radius: f64, angles: [f64; 4] }, /// Pixelate › Crystallize: polygons of solid colour around random points about `cell` apart. Crystallize { cell: f64 }, @@ -668,9 +669,10 @@ impl PixelFx { } } - /// How far (document units) from a pixel the content that decides it lies; `None` when that - /// is anywhere in the object (Radial Blur). - pub fn reach(&self) -> Option { + /// How far (document units) from a pixel the content that decides it lies, with lines of the + /// raster grid `line` apart ([`PixelSpace::line`]); `None` when that is anywhere in the object + /// (Radial Blur). + pub fn reach(&self, line: f64) -> Option { match *self { PixelFx::RadialBlur { .. } => None, PixelFx::SmartBlur { radius, mode: SmartBlurMode::Normal, .. } => Some(radius), @@ -703,7 +705,10 @@ impl PixelFx { PixelFx::Stamp { smoothness, .. } => Some(sketch_ink::stamp_reach(smoothness)), PixelFx::GlowingEdges { width, smoothness, .. } => Some(width.max(smoothness * 3.0)), // The cells around the pixel's, and the cells around theirs. - PixelFx::ColorHalftone { max_radius, .. } => Some(3.0 * std::f64::consts::SQRT_2 * max_radius), + // (Without a usable grid there are no dots: see `pixelate::color_halftone`.) + PixelFx::ColorHalftone { max_radius, .. } => { + Some(3.0 * std::f64::consts::SQRT_2 * max_radius * line).filter(|d| d.is_finite()).or(Some(0.0)) + } PixelFx::Crystallize { cell } => Some(1.5 * cell), PixelFx::ColoredPencil { width, .. } => Some(artistic::colored_pencil_reach(width)), PixelFx::ChalkCharcoal { .. } => Some(sketch::CHALK_CHARCOAL_REACH), diff --git a/crates/effects/src/pixel/pixelate.rs b/crates/effects/src/pixel/pixelate.rs index e9997b0cf..0318eb5f4 100644 --- a/crates/effects/src/pixel/pixelate.rs +++ b/crates/effects/src/pixel/pixelate.rs @@ -436,11 +436,12 @@ impl Screen { } /// Color Halftone: each channel screened at its angle in square cells sized so that a dot of -/// `max_radius` fills one; each cell's dot covers the share of it that is the channel's mean -/// there (white stays white, black black). Coverage stays as it is. +/// `max_radius` (pixels of the document's raster grid, `space.line` each) fills one; each cell's +/// dot covers the share of it that is the channel's mean there (white stays white, black black). +/// Coverage stays as it is. pub(super) fn color_halftone(px: &mut [[u8; 4]], w: usize, h: usize, space: &PixelSpace, max_radius: f64, angles: [f64; 4]) { let Some(place) = Place::new(space, w, h) else { return }; - let side = max_radius * std::f64::consts::SQRT_2; + let side = max_radius * space.line * std::f64::consts::SQRT_2; // Cells under 3 pixels can't show their dots: on average a screen is the image itself. if !(side.is_finite() && side / place.px >= 3.0) { return; diff --git a/crates/effects/src/pixel/tests.rs b/crates/effects/src/pixel/tests.rs index ef4d9d7ab..8261d408a 100644 --- a/crates/effects/src/pixel/tests.rs +++ b/crates/effects/src/pixel/tests.rs @@ -80,7 +80,7 @@ fn film_grain_parameters_are_bounded_and_neutral_settings_preserve_the_art() { neutral.apply(&mut out, w, h, &space(w, h)); assert_eq!(out, src); assert_eq!(neutral.outset(Rect::new(0.0, 0.0, w as f64, h as f64)), 0.0); - assert_eq!(neutral.reach(), Some(0.0)); + assert_eq!(neutral.reach(1.0), Some(0.0)); } #[test] @@ -520,12 +520,12 @@ fn outsets_follow_the_object() { assert!((spin.outset(b) - (far - 25.0)).abs() < 1e-9); assert!(fx("blur.radial", json!({"method": "zoom", "amount": 100})).outset(b) > 0.4 * far); assert_eq!(fx("blur.smart", json!({"radius": 4})).outset(b), 4.0); - assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(), Some(4.0)); + assert_eq!(fx("blur.smart", json!({"radius": 4})).reach(1.0), Some(4.0)); // The edge modes also read the copy blurred over a wide radius's samples. - assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(), Some(5.0)); + assert_eq!(fx("blur.smart", json!({"radius": 4, "quality": "low", "blurMode": "edgeOnly"})).reach(1.0), Some(5.0)); assert_eq!(fx("sharpen.unsharpMask", json!({})).outset(b), 0.0); - assert_eq!(spin.reach(), None); - assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(), Some(6.0)); + assert_eq!(spin.reach(1.0), None); + assert_eq!(fx("sharpen.unsharpMask", json!({"radius": 2})).reach(1.0), Some(6.0)); } #[test] @@ -962,7 +962,7 @@ fn pixelate_params_take_defaults_and_stay_in_range() { assert_eq!(fx("pixelate.crystallize", json!({"cellSize": 20})).outset(b), 30.0); assert_eq!(fx("pixelate.pointillize", json!({"cellSize": 20})).outset(b), 30.0); assert_eq!(fx("pixelate.colorHalftone", json!({})).outset(b), 0.0); - assert_eq!(fx("pixelate.mezzotint", json!({})).reach(), Some(0.0)); + assert_eq!(fx("pixelate.mezzotint", json!({})).reach(1.0), Some(0.0)); } /// Fraction of `d`'s pixels for which `f` holds. @@ -1002,6 +1002,25 @@ fn color_halftone_screens_midtones_into_dots_and_keeps_white_and_black() { assert!(lit(8) < lit(40), "{} < {}", lit(8), lit(40)); } +#[test] +fn color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution() { + // Max Radius counts the document's raster grid pixels (#3260): 16 px at 144 ppi (lines half a + // point apart) are the 8 px of 72 ppi, and draw the same dots. + let (w, h) = (64, 64); + let src = image(w, h, |x, y| [(x * 4) as u8, (y * 4) as u8, 128, 255]); + let run = |radius: f64, line: f64| { + let mut d = src.clone(); + fx("pixelate.colorHalftone", json!({"maxRadius": radius})).apply(&mut d, w, h, &PixelSpace { line, ..space(w, h) }); + d + }; + assert_eq!(run(16.0, 0.5), run(8.0, 1.0)); + assert_ne!(run(8.0, 0.5), run(8.0, 1.0), "the same radius at a finer resolution draws finer dots"); + // It looks as far around a pixel as its cells reach, in document units. + let halftone = fx("pixelate.colorHalftone", json!({"maxRadius": 8})); + assert_eq!(halftone.reach(0.5), Some(3.0 * std::f64::consts::SQRT_2 * 4.0)); + assert_eq!(halftone.reach(f64::NAN), Some(0.0), "no grid, no dots"); +} + #[test] fn color_halftone_screens_inks_in_cmyk_documents() { let (w, h) = (64, 64); @@ -1241,8 +1260,8 @@ fn texture_params_take_defaults_and_stay_in_range() { for id in ["texture.craquelure", "texture.grain", "texture.mosaicTiles", "texture.texturizer"] { assert_eq!(fx(id, json!({})).outset(b), 0.0, "{id}"); } - assert_eq!(fx("texture.grain", json!({})).reach(), Some(0.0)); - assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(), Some(6.0)); + assert_eq!(fx("texture.grain", json!({})).reach(1.0), Some(0.0)); + assert_eq!(fx("texture.craquelure", json!({"crackSpacing": 12})).reach(1.0), Some(6.0)); } /// The pixels of `d` (`w` wide) in the square `lo..hi` on both axes. @@ -1509,9 +1528,9 @@ fn distort_params_take_defaults_and_stay_in_range() { // Shifted content reaches past the bounds by the longest shift, and comes from as far. let b = Rect::new(0.0, 0.0, 100.0, 50.0); let glass = fx("distort.glass", json!({"distortion": 20})); - assert_eq!((glass.outset(b), glass.reach()), (10.0, Some(10.0))); + assert_eq!((glass.outset(b), glass.reach(1.0)), (10.0, Some(10.0))); let ripple = fx("distort.oceanRipple", json!({})); - assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach() == Some(ripple.outset(b))); + assert!((ripple.outset(b) - 0.03 * 9.0 * 11.0).abs() < 1e-9 && ripple.reach(1.0) == Some(ripple.outset(b))); assert_eq!(fx("distort.diffuseGlow", json!({})).outset(b), 0.0); } @@ -1621,7 +1640,7 @@ fn ocean_ripple_moves_content_no_farther_than_its_magnitude() { fx("distort.oceanRipple", json!({"rippleMagnitude": 0})).apply(&mut d, w, h, &space(w, h)); assert_eq!(d, src, "no magnitude, no ripples"); let f = fx("distort.oceanRipple", json!({"rippleSize": 6, "rippleMagnitude": 20})); - let most = f.reach().unwrap(); + let most = f.reach(1.0).unwrap(); let mut d = src.clone(); f.apply(&mut d, w, h, &space(w, h)); assert_ne!(d, src); @@ -1765,19 +1784,19 @@ fn brush_strokes_outsets() { let reach = brushstrokes::stroke_reach; assert!((reach(20.0) - (0.75 * 20.0 + 1.5 / 15.0 * 20.0 + 1.0)).abs() < 1e-9); assert_eq!(fx("brushStrokes.accentedEdges", json!({})).outset(b), 0.0); - assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0)); + assert_eq!(fx("brushStrokes.accentedEdges", json!({})).reach(1.0), Some(3.0 * (0.35 * 2.0 + 0.5) + 1.0)); assert_eq!(fx("brushStrokes.angledStrokes", json!({})).outset(b), reach(15.0)); assert_eq!(fx("brushStrokes.crosshatch", json!({})).outset(b), reach(9.0) + 0.9); assert_eq!(fx("brushStrokes.darkStrokes", json!({})).outset(b), reach(9.0)); assert_eq!(fx("brushStrokes.inkOutlines", json!({"strokeLength": 50})).outset(b), reach(50.0)); let spatter = fx("brushStrokes.spatter", json!({})); - assert_eq!((spatter.outset(b), spatter.reach()), (4.0, Some(4.0))); + assert_eq!((spatter.outset(b), spatter.reach(1.0)), (4.0, Some(4.0))); assert_eq!(fx("brushStrokes.spatter", json!({"sprayRadius": 0})).outset(b), 0.0); assert_eq!(fx("brushStrokes.sprayedStrokes", json!({})).outset(b), reach(12.0) + 0.3 * 7.0); assert_eq!(fx("brushStrokes.sumiE", json!({})).outset(b), 0.0); for id in brush_strokes() { let f = fx(id, json!({})); - assert!(f.reach().is_some_and(|r| r >= f.outset(b)), "{id}"); + assert!(f.reach(1.0).is_some_and(|r| r >= f.outset(b)), "{id}"); } } @@ -1961,7 +1980,7 @@ fn spatter_scatters_content_no_farther_than_its_radius() { let src = image(w, h, |x, _| if x < 32 { grey(255) } else { [0; 4] }); assert_eq!(run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 0})), src, "no radius, no spatter"); let f = fx("brushStrokes.spatter", json!({"sprayRadius": 15})); - let most = f.reach().unwrap(); + let most = f.reach(1.0).unwrap(); let d = run_fx(&src, w, h, "brushStrokes.spatter", json!({"sprayRadius": 15})); assert_ne!(d, src); let inside = |v: usize, n: usize| (v as f64 + 0.5).min(n as f64 - v as f64 - 0.5) > most + 1.0; @@ -1990,7 +2009,7 @@ fn brush_strokes_look_no_farther_than_their_reach() { ("brushStrokes.sumiE", json!({"strokeWidth": 15})), ]); for (id, p) in cases { - let reach = fx(id, p.clone()).reach().unwrap(); + let reach = fx(id, p.clone()).reach(1.0).unwrap(); // Everything from `cut` on differs; the pixels left of 20 can't tell. let cut = 20 + reach.ceil() as usize + 1; assert!(cut < w, "{id}"); @@ -2047,7 +2066,7 @@ fn chalk_charcoal_params_take_defaults_and_stay_in_range() { assert!(doc.contains("chalkArea") && doc.contains("charcoalArea") && doc.contains("strokePressure"), "{doc}"); let f = fx("sketch.chalkCharcoal", json!({"strokePressure": 5})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); - assert!(f.reach().unwrap() > 0.0); + assert!(f.reach(1.0).unwrap() > 0.0); } #[test] @@ -2108,7 +2127,7 @@ fn graphic_pen_params_take_defaults_and_stay_in_range() { assert!(STROKE_DIRECTIONS.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}"); let f = fx("sketch.graphicPen", json!({"strokeLength": 15})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), brushstrokes::stroke_reach(15.0)); - assert_eq!(f.reach(), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH))); + assert_eq!(f.reach(1.0), Some(brushstrokes::stroke_reach(15.0).max(brushstrokes::TONE_REACH))); assert_eq!( fx("sketch.graphicPen", json!({"strokeLength": 99})), PixelFx::GraphicPen { length: 15.0, balance: 50.0, direction: StrokeDirection::RightDiagonal } @@ -2148,7 +2167,7 @@ fn halftone_pattern_params_take_defaults_and_stay_in_range() { assert!(HALFTONE_TYPES.iter().all(|(_, v)| doc.contains(&format!("\"{v}\""))), "{doc}"); let f = fx("sketch.halftonePattern", json!({"size": 12})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); - assert_eq!(f.reach(), Some(0.0)); + assert_eq!(f.reach(1.0), Some(0.0)); } #[test] @@ -2162,7 +2181,7 @@ fn note_paper_params_take_defaults_and_stay_in_range() { assert!(doc.contains("imageBalance") && doc.contains("graininess") && doc.contains("relief"), "{doc}"); let f = fx("sketch.notePaper", json!({"relief": 12})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); - assert_eq!(f.reach(), Some(0.0)); + assert_eq!(f.reach(1.0), Some(0.0)); } /// Note Paper embosses a horizontal gradient and graininess adds fibre. @@ -2198,7 +2217,7 @@ fn water_paper_params_take_defaults_and_stay_in_range() { ); let f = fx("sketch.waterPaper", json!({"fiberLength": 40})); assert!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)) > 0.0); - assert!(f.reach().unwrap_or(0.0) > 0.0); + assert!(f.reach(1.0).unwrap_or(0.0) > 0.0); let doc = crate::effect_info("sketch.waterPaper").unwrap().params; assert!(doc.contains("unverified"), "{doc}"); } @@ -2281,7 +2300,7 @@ fn plaster_params_take_defaults_and_stay_in_range() { let doc = crate::effect_info("sketch.plaster").unwrap().params; assert!(doc.contains("\"bottomRight\""), "{doc}"); let f = fx("sketch.plaster", json!({"smoothness": 24})); - assert_eq!(f.reach(), Some(super::sketch::plaster_reach(15.0))); + assert_eq!(f.reach(1.0), Some(super::sketch::plaster_reach(15.0))); } /// Plaster changes the image and different light directions give different results. @@ -2356,8 +2375,8 @@ fn chrome_params_take_defaults_and_stay_in_range() { assert!(doc.contains("detail") && doc.contains("smoothness"), "{doc}"); let f = fx("sketch.chrome", json!({})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); - assert_eq!(f.reach(), Some(sketch::chrome_reach(4.0))); - assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(), Some(sketch::chrome_reach(10.0))); + assert_eq!(f.reach(1.0), Some(sketch::chrome_reach(4.0))); + assert_eq!(fx("sketch.chrome", json!({"smoothness": 10})).reach(1.0), Some(sketch::chrome_reach(10.0))); } /// Chrome redraws in greyscale, keeps coverage, and more detail changes the pattern. @@ -2389,7 +2408,7 @@ fn reticulation_params_take_defaults_and_stay_in_range() { ); let f = fx("sketch.reticulation", json!({})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 100.0, 50.0)), 0.0); - assert_eq!(f.reach(), Some(0.0)); + assert_eq!(f.reach(1.0), Some(0.0)); } #[test] @@ -2431,7 +2450,7 @@ fn torn_edges_params_take_defaults_and_stay_in_range() { for junk in [json!({"imageBalance": "x"}), json!({"smoothness": null})] { assert_eq!(fx("sketch.tornEdges", junk), fx("sketch.tornEdges", json!({}))); } - assert_eq!(fx("sketch.tornEdges", json!({})).reach(), None); + assert_eq!(fx("sketch.tornEdges", json!({})).reach(1.0), None); } #[test] @@ -2496,7 +2515,7 @@ fn artistic_params_take_defaults_and_stay_in_range() { fx("artistic.coloredPencil", json!({"pencilWidth": 0, "strokePressure": 200, "paperBrightness": -5})), PixelFx::ColoredPencil { width: 1.0, pressure: 15.0, paper: 0.0 } ); - assert_eq!(fx("artistic.coloredPencil", json!({})).reach(), Some(artistic::colored_pencil_reach(5.0))); + assert_eq!(fx("artistic.coloredPencil", json!({})).reach(1.0), Some(artistic::colored_pencil_reach(5.0))); assert_eq!(fx("artistic.cutout", json!({})), PixelFx::Cutout { levels: 4.0, simplicity: 4.0, fidelity: 2.0 }); assert_eq!( fx("artistic.cutout", json!({"levels": 1e308, "edgeSimplicity": -5, "edgeFidelity": "NaN"})), @@ -2860,7 +2879,7 @@ fn dry_brush_repaints_and_preserves_coverage() { let before = clear.clone(); fx("artistic.dryBrush", json!({})).apply(&mut clear, w, h, &space(w, h)); assert_eq!(clear, before); - let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach(); + let reach = fx("artistic.dryBrush", json!({"brushSize": 10, "brushDetail": 1})).reach(1.0); assert!(reach.is_some_and(|r| r > 0.0)); } diff --git a/crates/effects/src/pixel/tests_sketch.rs b/crates/effects/src/pixel/tests_sketch.rs index 2f463f52f..fd467a23c 100644 --- a/crates/effects/src/pixel/tests_sketch.rs +++ b/crates/effects/src/pixel/tests_sketch.rs @@ -176,7 +176,7 @@ fn sketch_filters_reach_beyond_nothing_they_do_not_paint() { for id in ["sketch.basRelief", "sketch.charcoal", "sketch.conteCrayon", "sketch.photocopy", "sketch.stamp"] { let f = fx(id, json!({})); assert_eq!(f.outset(Rect::new(0.0, 0.0, 50.0, 50.0)), 0.0, "{id} keeps the object's coverage"); - assert!(f.reach().is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach"); + assert!(f.reach(1.0).is_some_and(|r| r.is_finite() && r > 0.0), "{id} reach"); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index 198da329e..fba045763 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -1210,14 +1210,29 @@ fn a_repeat_of_a_symbol_whose_art_is_a_repeat_shares_the_instance_budget() { #[test] fn a_distance_parameters_range_comes_from_its_documentation() { - assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0)); + assert_eq!(length_range("stylize.glowingEdges", "edgeWidth"), Some(1.0..=14.0)); + // A distance documented without a range, and unknown effects and parameters, have none. + assert_eq!(length_range("path.offsetPath", "offset"), None); + assert_eq!(length_range("stylize.glowingEdges", "edgeBrightness"), None); + assert_eq!(length_range("no.such", "edgeWidth"), None); +} + +#[test] +fn color_halftones_radius_is_in_raster_pixels_not_a_distance() { + // Pixels of the Document Raster Effects resolution, as public documentation gives it (#3260): + // no unit setting changes it, so it isn't a distance in points. + assert_eq!(raster_px_range("pixelate.colorHalftone", "maxRadius"), Some(4.0..=127.0)); + assert!(!is_length("pixelate.colorHalftone", "maxRadius", false)); + assert_eq!(length_range("pixelate.colorHalftone", "maxRadius"), None); + assert_eq!(raster_px_range("pixelate.colorHalftone", "channel1"), None); + assert_eq!(raster_px_range("stylize.glowingEdges", "edgeWidth"), None); // The effect applies the range's floor to a radius below it. assert_eq!( crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 1})), crate::pixel::parse("pixelate.colorHalftone", &json!({"maxRadius": 4})) ); - // A distance documented without a range, and unknown effects and parameters, have none. - assert_eq!(length_range("path.offsetPath", "offset"), None); - assert_eq!(length_range("pixelate.colorHalftone", "channel1"), None); - assert_eq!(length_range("no.such", "maxRadius"), None); + // Scale Strokes & Effects scales it with the object, as it does distances. + let mut e = crate::new_effect("pixelate.colorHalftone", &json!({"maxRadius": 10})).unwrap(); + crate::scale_effect(&mut e, 2.0); + assert_eq!((e.params["maxRadius"].as_f64(), e.params["channel1"].as_f64()), (Some(20.0), Some(108.0))); } diff --git a/crates/engine/src/cmd/fileio/tests_pdfedit.rs b/crates/engine/src/cmd/fileio/tests_pdfedit.rs index 35d394540..597f67a03 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfedit.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfedit.rs @@ -403,3 +403,32 @@ fn ai_export_without_artboards_keeps_art_outside_the_original_pages() { assert_eq!(comparable(&s.doc().unwrap().doc), before); assert!(s.execute("document.export", &json!({"format": "ai", "useArtboards": "yes"})).is_err()); } + +/// Color Halftone's Max Radius is in pixels of the raster effects resolution since document +/// version 2 (#3260). A PDF saved with Preserve Editing carries the native document, so one from +/// before that opens with the radius converted, drawing the same dots; one saved since opens as +/// it was. +#[test] +fn a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels() { + let halftone = |old: bool| { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 300, "height": 200})).unwrap(); + s.execute("document.rasterEffectsSettings", &json!({"resolution": 300})).unwrap(); + let r = super::tests_svgedit::rect(&mut s, 10.0, 10.0); + s.execute("effect.apply", &json!({"effect": "pixelate.colorHalftone", "params": {"maxRadius": 8}, "ids": [r]})).unwrap(); + if old { + // As VectorCraft 0.18 wrote it: the document model's version 1, the radius in points. + std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).version = 1; + } + let pdf = b64(&s.execute("document.exportPdf", &json!({})).unwrap()); + let opened = open(&mut s, "halftone.pdf", &pdf); + assert_eq!(opened["restored"], json!(true), "{opened}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.version, vectorcraft_doc::FORMAT_VERSION); + let n = d.node(vectorcraft_doc::NodeId(r)).unwrap(); + n.appearance.effects.iter().find(|e| e.id == "pixelate.colorHalftone").and_then(|e| e.params["maxRadius"].as_f64()).unwrap() + }; + // 8 pt at 300 ppi are 33⅓ px. + assert!((halftone(true) - 8.0 * 300.0 / 72.0).abs() < 1e-9, "{}", halftone(true)); + assert_eq!(halftone(false), 8.0); +} diff --git a/crates/engine/src/cmd/fileio/tests_svgedit.rs b/crates/engine/src/cmd/fileio/tests_svgedit.rs index 06f988846..4b0998c64 100644 --- a/crates/engine/src/cmd/fileio/tests_svgedit.rs +++ b/crates/engine/src/cmd/fileio/tests_svgedit.rs @@ -12,7 +12,7 @@ fn new_id(v: &Value) -> u64 { v["id"].as_u64().or_else(|| v["ids"][0].as_u64()).unwrap_or_else(|| panic!("no id in {v}")) } -fn rect(s: &mut Session, x: f64, y: f64) -> u64 { +pub(super) fn rect(s: &mut Session, x: f64, y: f64) -> u64 { new_id(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 20, "height": 20})).unwrap()) } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index ad9df39ff..d9fb08c0b 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -607,6 +607,8 @@ fn load_file_within(bytes: &[u8], max_nodes: usize, max_values: usize) -> Result doc.migrate_overprint_black(); // Saved before v3: the assigned profiles move out of `unknown`. doc.migrate_color_profiles(); + // A document before version 2: Color Halftone's Max Radius in points becomes pixels (#3260). + doc.migrate_halftone_radii(); doc.fix_next_id(); // Untrusted: the bleed and type proportions, within the ranges Document Setup takes. doc.setup.keep_in_range(); @@ -781,6 +783,29 @@ mod tests { use vectorcraft_doc::{Appearance, ImageObject, Node, NodeKind}; use vectorcraft_geom::{Affine, Rect, shapes}; + /// Color Halftone's Max Radius is in pixels of the raster effects resolution since document + /// version 2 (#3260): a file from before opens with its radius in points converted, one saved + /// since opens as it was. + #[test] + fn halftone_radii_of_older_documents_open_in_pixels() { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = 300.0; + let (l, id) = (d.layers[0].id, d.alloc_id()); + let mut look = Appearance::default_art(); + look.effects.push(vectorcraft_doc::Effect { + id: "pixelate.colorHalftone".into(), + params: serde_json::json!({"maxRadius": 8.0}), + visible: true, + }); + d.insert(Some(l), 0, Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 10.0, 10.0)), look)).unwrap(); + let radius = |d: &Document| d.node(id).unwrap().appearance.effects[0].params["maxRadius"].as_f64().unwrap(); + assert_eq!(radius(&load(&save(&d, false)).unwrap()), 8.0, "saved in pixels, opened in pixels"); + d.version = 1; + let old = load(&save(&d, false)).unwrap(); + assert_eq!(old.version, vectorcraft_doc::FORMAT_VERSION); + assert!((radius(&old) - 8.0 * 300.0 / 72.0).abs() < 1e-9, "8 pt at 300 ppi: {}", radius(&old)); + } + #[test] fn roundtrip() { let mut d = Document::new(100.0, 100.0); diff --git a/crates/render/src/fx/pixels.rs b/crates/render/src/fx/pixels.rs index 900e435bc..5fc706a30 100644 --- a/crates/render/src/fx/pixels.rs +++ b/crates/render/src/fx/pixels.rs @@ -66,7 +66,11 @@ impl Renderer { } let fits = full.area() <= PIXEL_BUDGET; // Device pixels from a pixel to the content deciding it (`None`: anywhere). - let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx).map(|d| m + d / f.px)); + // De-Interlace's field lines and Color Halftone's pixels: the rows of the document's + // raster grid (1 to 2400 ppi). + let ppi = doc_raster_ppi(f.doc); + let line = 72.0 / ppi; + let margin = chain.iter().try_fold(2.0, |m, fx| looks_around(fx, line).map(|d| m + d / f.px)); let linear = linear_key(f.view); let key = content.cache.map(|slot| (content.node as *const Node as usize, slot, f.ink)); // Made again at this zoom and rotation (after a pan), a raster cropped to the view covers a @@ -100,13 +104,14 @@ impl Renderer { Ink::Display | Ink::RichBlack if f.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk => effects::pixel::Channels::Cmyk, _ => effects::pixel::Channels::Rgb, }; - // De-Interlace's field lines: the rows of the document's raster grid (1 to 2400 ppi). - let ppi = doc_raster_ppi(f.doc); // What the content reads (as the raster kept for it found it, else found now), with the - // channels the effects run on and, under De-Interlace or Rasterize, the resolution. + // channels the effects run on and, under De-Interlace, Color Halftone (its dots are sized + // in the grid's pixels) or Rasterize, the resolution. let inputs = key.map(|key| { let reads = self.pixel_fx.get(&key).filter(|e| e.node.is(content.node)).map_or_else(|| Reads::of(content.node), |e| e.inputs.reads); - let interlaced = chain.iter().any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. }) | RasterFx::Rasterize(_))); + let interlaced = chain + .iter() + .any(|fx| matches!(fx, RasterFx::Pixel(PixelFx::DeInterlace { .. } | PixelFx::ColorHalftone { .. }) | RasterFx::Rasterize(_))); self.raster_inputs(f, content.node, reads).with((std::mem::discriminant(&channels), interlaced.then_some(ppi.to_bits()))) }); let interacting = f.opts.interacting; @@ -149,7 +154,6 @@ impl Renderer { let to_doc = view.inverse(); let px = f.px / k; let frame = Frame { mt: false, view, visible: to_doc.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), px, ..*f }; - let line = 72.0 / ppi; let (before, decoded) = (self.stats.moved, crate::paint::decode_failures()); // A Rasterize first in the chain draws the content on its image's own grid instead, unless // that image is too large: the content is drawn once. diff --git a/crates/render/src/fx/raster.rs b/crates/render/src/fx/raster.rs index ca6a2fd37..5f44cd50b 100644 --- a/crates/render/src/fx/raster.rs +++ b/crates/render/src/fx/raster.rs @@ -70,12 +70,12 @@ pub(super) fn hard_edged(chain: &[&RasterFx], px: f64) -> bool { matches!(chain.first(), Some(RasterFx::Rasterize(r)) if !r.anti_alias && r.cell(72.0) >= px) } -/// How far (document units) from a pixel the content deciding it after `fx` lies; `None` when -/// anywhere in the object. -pub(super) fn looks_around(fx: &RasterFx) -> Option { +/// How far (document units) from a pixel the content deciding it after `fx` lies, with the +/// document's raster grid lines `line` apart; `None` when anywhere in the object. +pub(super) fn looks_around(fx: &RasterFx, line: f64) -> Option { match fx { RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius } => Some(1.5 * radius), - RasterFx::Pixel(p) => p.reach(), + RasterFx::Pixel(p) => p.reach(line), RasterFx::Rasterize(r) => Some(r.reach()), _ => Some(0.0), } diff --git a/crates/ui-egui/src/dialogs/form.rs b/crates/ui-egui/src/dialogs/form.rs index ce456fb26..bb2c187f9 100644 --- a/crates/ui-egui/src/dialogs/form.rs +++ b/crates/ui-egui/src/dialogs/form.rs @@ -384,6 +384,16 @@ pub(super) fn param_fields( changed |= length_within(ui, d, &k, unit, 140.0, range); return; } + // A size in pixels of the raster effects resolution (Color Halftone's Max Radius) + // stays in pixels whatever the document's units, within its documented range. + if let Some(range) = vectorcraft_effects::raster_px_range(&d.str("__effect"), &k) { + let x = v.as_f64().unwrap_or(*range.start()); + if let Some(x) = crate::widgets::range_field_named(ui, ("fx-px", &k), None, x, range, &unit_suffix("px"), 2, 140.0) { + d.fields.insert(k.clone(), typed_number(x)); + changed = true; + } + return; + } if let Some(options) = choices(&k) { let cur = v.as_str().unwrap_or_default(); let label = options.iter().find(|(_, value)| *value == cur).map_or(cur, |(l, _)| choice_label(l)); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index d4b6698a8..658501559 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -2488,11 +2488,11 @@ fn artboard_options_selected_fit_spans_other_boards_and_off_board_art() { } /// An effect dialog keeps a distance typed outside the effect's range to the range, so the field -/// shows what the effect applies: Color Halftone's Max Radius below 4 pt is 4 pt (#4637). +/// shows what the effect applies: Crystallize's Cell Size below 3 pt is 3 pt (#4637). #[test] fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { let enter = || egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; - for (typed, applied) in [("1 pt", 4.0), ("0.5 px", 4.0), ("200 pt", 127.0), ("10 pt", 10.0)] { + for (typed, applied) in [("1 pt", 3.0), ("0.5 px", 3.0), ("400 pt", 300.0), ("10 pt", 10.0)] { let mut app = app(); let id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); let ctx = egui::Context::default(); @@ -2503,21 +2503,23 @@ fn an_effect_dialog_keeps_a_typed_distance_within_the_effects_range() { out.textures_delta.clear(); }; app.run("select.all", json!({})).unwrap(); - app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); - // Max Radius, the first field, is focused: type over it and press Enter. + app.run("effect.dialog", json!({"effect": "pixelate.crystallize"})).unwrap(); + // Cell Size, the only field, is focused: type over it and press Enter. frame(&mut app, vec![]); frame(&mut app, vec![egui::Event::Text(typed.into())]); frame(&mut app, vec![enter()]); assert!(app.ui.dialog.is_none(), "{typed}"); let node = app.session.doc().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().clone(); - assert_eq!(node.appearance.effects.last().map(|e| e.params["maxRadius"].clone()), Some(json!(applied)), "{typed}"); + assert_eq!(node.appearance.effects.last().map(|e| e.params["cellSize"].clone()), Some(json!(applied)), "{typed}"); } } /// #4638: typing invalid input in an effect length field does not silently revert to a sub-minimum /// or out-of-range value; sub-minimum input is clamped immediately and invalid input clears cleanly. +/// (Crystallize's Cell Size, 3 to 300 pt: Color Halftone's Max Radius, which #4638 was found on, is +/// in pixels since #3260.) #[test] -fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { +fn effect_length_invalid_input_does_not_revert_to_sub_minimum_value() { let mut app = app(); let _id = app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap()["id"].as_u64().unwrap(); let ctx = egui::Context::default(); @@ -2529,20 +2531,20 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { out }; app.run("select.all", json!({})).unwrap(); - app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + app.run("effect.dialog", json!({"effect": "pixelate.crystallize"})).unwrap(); frame(&mut app, vec![]); - // 1. Enter 0.5 px (sub-minimum): clamped immediately to 4 pt on commit + // 1. Enter 0.5 px (sub-minimum): clamped immediately to 3 pt on commit frame(&mut app, vec![egui::Event::Text("0.5 px".into())]); frame(&mut app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["cellSize"], serde_json::json!(3.0)); let s1 = frame(&mut app, vec![]); let shows_clamped_text = s1.shapes.iter().any(|s| match &s.shape { - egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + egui::Shape::Text(t) => t.galley.text().trim() == "3 pt", _ => false, }); - assert!(shows_clamped_text, "rendered field must show clamped '4 pt' text after sub-minimum commit"); + assert!(shows_clamped_text, "rendered field must show clamped '3 pt' text after sub-minimum commit"); - // 2. Return focus to Max Radius with Shift+Tab, type invalid text, and commit with Enter + // 2. Return focus to Cell Size with Shift+Tab, type invalid text, and commit with Enter frame( &mut app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::SHIFT }], @@ -2552,9 +2554,9 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { &mut app, vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], ); - // Dialog remains open and retains the clamped 4.0 pt rather than reverting to 0.5 px + // Dialog remains open and retains the clamped 3.0 pt rather than reverting to 0.5 px assert!(app.ui.dialog.is_some(), "dialog must remain open on invalid Enter commit"); - assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(4.0)); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["cellSize"], serde_json::json!(3.0)); // The field paints with rejection highlight (width 1.5, accent_strong) let next_out = frame(&mut app, vec![]); @@ -2562,12 +2564,12 @@ fn color_halftone_invalid_input_does_not_revert_to_sub_minimum_value() { egui::Shape::Rect(r) => (r.stroke.width - 1.5).abs() < 1e-3 && r.stroke.color.a() > 0, _ => false, }); - assert!(has_rejection_highlight, "Max Radius must display rejection highlight after invalid input"); + assert!(has_rejection_highlight, "Cell Size must display rejection highlight after invalid input"); let shows_clamped_after_invalid = next_out.shapes.iter().any(|s| match &s.shape { - egui::Shape::Text(t) => t.galley.text().trim() == "4 pt", + egui::Shape::Text(t) => t.galley.text().trim() == "3 pt", _ => false, }); - assert!(shows_clamped_after_invalid, "rendered field must retain '4 pt' text after invalid input rejection"); + assert!(shows_clamped_after_invalid, "rendered field must retain '3 pt' text after invalid input rejection"); } #[test] @@ -2584,13 +2586,43 @@ fn bounded_field_with_inches_does_not_clamp_point_bounds_to_inch_numbers() { out.textures_delta.clear(); }; test_app.run("select.all", json!({})).unwrap(); - test_app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + test_app.run("effect.dialog", json!({"effect": "pixelate.crystallize"})).unwrap(); frame(&mut test_app, vec![]); - // Type 1 in: 72 pt is within 4..127 pt range and must not clamp to ~1.76 pt + // Type 1 in: 72 pt is within 3..300 pt range and must not clamp to ~4.17 pt frame(&mut test_app, vec![egui::Event::Text("1 in".into())]); frame( &mut test_app, vec![egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }], ); - assert_eq!(test_app.ui.dialog.as_ref().unwrap().fields["maxRadius"], serde_json::json!(72.0)); + assert_eq!(test_app.ui.dialog.as_ref().unwrap().fields["cellSize"], serde_json::json!(72.0)); +} + +/// Color Halftone's Max Radius is in pixels of the raster effects resolution whatever the +/// document's units (#3260): in a document in centimetres it still shows and takes pixels, within +/// the documented 4 to 127 px. +#[test] +fn color_halftones_max_radius_stays_in_pixels_whatever_the_units() { + let tab = || egui::Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }; + for (typed, applied, shown) in [("10", 10.0, "10 px"), ("2", 4.0, "4 px"), ("500", 127.0, "127 px"), ("4.5 px", 4.5, "4.5 px")] { + let mut app = app(); + app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); + app.run("document.setup", json!({"units": "Centimeters"})).unwrap(); + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + theme::apply(&ctx, Default::default()); + let frame = |app: &mut VectorcraftApp, events: Vec| { + let mut out = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| show(app, ui.ctx())); + out.textures_delta.clear(); + out + }; + app.run("select.all", json!({})).unwrap(); + app.run("effect.dialog", json!({"effect": "pixelate.colorHalftone"})).unwrap(); + frame(&mut app, vec![]); + frame(&mut app, vec![egui::Event::Text(typed.into())]); + frame(&mut app, vec![tab()]); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["maxRadius"].as_f64(), Some(applied), "{typed}"); + let out = frame(&mut app, vec![]); + let shows = out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Text(t) if t.galley.text().trim() == shown)); + assert!(shows, "{typed} shows as {shown}, not in centimetres"); + } } diff --git a/crates/ui-egui/src/panels/appearance.rs b/crates/ui-egui/src/panels/appearance.rs index 19a578137..dc56ae2b2 100644 --- a/crates/ui-egui/src/panels/appearance.rs +++ b/crates/ui-egui/src/panels/appearance.rs @@ -880,6 +880,8 @@ fn effect_editor(app: &mut VectorcraftApp, ui: &mut Ui, item: Option, k: let suffix = ui_keys.and_then(|u| u.unit.as_deref()).map(crate::dialogs::unit_suffix).unwrap_or_default(); let nx = if vectorcraft_effects::is_length(&e.id, key, relative) { widgets::num_field(ui, id, Some(x), unit, 70.0) + } else if let Some(range) = vectorcraft_effects::raster_px_range(&e.id, key) { + widgets::range_field_named(ui, id, None, x, range, &crate::dialogs::unit_suffix("px"), 2, 70.0) } else { widgets::plain_field(ui, id, x, &suffix, 2, 70.0) }; diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 0c0bc052d..ade2feab8 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -193,6 +193,7 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Pixelate › Color Halftone: Max Radius is in pixels of the Document Raster Effects resolution, 4–127 px, as the [filter effects reference](https://helpx.adobe.com/photoshop/using/filter-effects-reference.html) gives it, instead of points (#3260): fine screens are possible at print resolutions (4 px at 300 ppi ≈ 0.96 pt). The dialog and the Appearance panel show it in px whatever the document units; documents saved before (document version 1) open with each radius converted, so their dots stay the same size. Scores unchanged. Tests: `vectorcraft_effects` `color_halftone_sizes_its_dots_in_pixels_of_the_raster_effects_resolution` and `color_halftones_radius_is_in_raster_pixels_not_a_distance`, `vectorcraft_doc` `halftone_radii::tests`, `vectorcraft_format` `halftone_radii_of_older_documents_open_in_pixels`, `vectorcraft_engine` `a_preserve_editing_pdf_opens_with_its_halftone_radius_in_pixels`, `dialogs::tests::color_halftones_max_radius_stays_in_pixels_whatever_the_units`. | | 2026-10-30 | minor | Effect dialogs: bounded length fields (Color Halftone Max Radius) clamp on commit and visibly reject invalid input (#4638); scores unchanged. | | 2026-10-23 | minor | Pathfinder › Trap ([trapping](https://helpx.adobe.com/illustrator/using/trapping.html)): the Pathfinder panel's Trap… (`object.pathfinder.trap`, one undo step, the traps grouped above the topmost selected object and selected) and Effect › Pathfinder › Trap… (`pathfinder.trap`, live on groups) add an overprinting strip of the lighter colour inside the darker one where two solid colours meet (Reverse Traps: the darker into the lighter, but not a rich black), with Thickness, Height/Width, Tint Reduction and Traps with Process Color; the effect also has Precision and Remove Redundant Points. Recounted: Hard Mix (#3061) and Soft Mix had shipped, so Pathfinder 11/13 → 13/13 and Illustrator effects 46/50 → 49/50 (SVG Filters remains); remaining 6–10 → 4–8 h. Tests: `vectorcraft_pathops` `trap::tests`, `vectorcraft_effects` `trap::tests` and `group::tests::trap_effect_adds_an_overprinting_strip_above_the_members`, `vectorcraft_engine` `tests_trap`, `panels::pathfinder::tests::trap_opens_its_options_and_ok_traps_the_selection`. | | 2026-10-22 | minor | The Transform effect has Transform Objects, Transform Patterns and Scale Strokes & Effects, with visible checkboxes (#885); on a fill's or stroke's own Transform effect the last two are still missing. Counts unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e6e5bd60d..c2fdf938d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1128,8 +1128,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Glass's Load Texture:** Effect › Distort › Glass… has a Load Texture… button that reads a PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, PSD, PSB or PCX file and adds Loaded Texture to the Texture menu (`textureImage`: `{path}` or `{name?, dataBase64}`, stored in the effect and saved with the document as a grayscale PNG in base64). The texture lies one image pixel per point at Scaling 100%, centered on the object and repeated; each pixel's luma is the surface's height, from 0 for black to 1 pt for white, and a transparent pixel counts as middle gray. Scaling and Invert act on it as on the built-in textures. A Glass saved without `textureImage` draws and saves as before. -- **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Color Halftone Max Radius 4..127 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. -- **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace), Page Knockout Group, the substitution highlights (Highlight Substituted Fonts and Highlight Alternate Glyphs in Preferences › Type, Highlight Substituted Glyphs in Document Setup), a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). +- **Bounded effect length inputs (#4638):** Effect dialog fields with defined length ranges (such as Crystallize Cell Size 3..300 pt) clamp immediately on commit and reject invalid inputs visibly with an accent highlight without reverting to sub-minimum values. +- **Color Halftone in raster pixels (#3260):** Max Radius is 4–127 px of the Document Raster Effects resolution, as public documentation gives it, shown in px whatever the document units; documents from before open with their radii in points converted (document version 2). +- **Raster effects after document and view changes:** a cached drop shadow, outer glow or filtered content is made again when something its content reads from outside the object changes: a pattern's art or tiling, a symbol shown inside another symbol, a brush definition, an image's pixels, the raster resolution (De-Interlace, Color Halftone), Page Knockout Group, the substitution highlights (Highlight Substituted Fonts and Highlight Alternate Glyphs in Preferences › Type, Highlight Substituted Glyphs in Document Setup), a layer's Dim Images, the color settings, the fonts, Pixel Preview, Anti-aliased Artwork, Overprint Preview and isolation mode. A thumbnail under Color Halftone or Mezzotint is made again when the document's color mode changes. Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). - **Drop Shadow's Darkness:** Effect › Stylize › Drop Shadow… has Color and Darkness as radio buttons, each with its field, and the Appearance panel's inline editor has the two radio buttons in its Shade row (`shade`: `color` or `darkness`; `darkness`: 0 to 100%, 100 by default). Color is the default, and a shadow saved without these keys draws as before. With Darkness, the stored source-color model determines the adjustment before document/profile conversion and blur: subtract Darkness/100 from each RGB channel, clamped at zero, or add it to CMYK black K and Gray black, clamped at one, preserving CMYK C/M/Y. At the near-100% endpoint the shadow source becomes gray black before conversion. Shadow opacity stays separate. Original gradient stops follow the same application rule before interpolation; exact resource-level gradient parity remains open. Application SVG export keeps a live filter for simple uniform sRGB sources and uses rendered shadow images for other working RGB profiles, source models and gradients. Low-level SVG filter output warns when this preparation is needed; proportional intermediate SVG filters are not misread as additive Darkness. Exact spot/custom-color and composite-source parity remain open, and resulting CMYK ink values follow the application color pipeline. CSS export leaves the shadow out and marks the object unsupported. When a `.ai`, EPS or PDF opens from its editing data, a Drop Shadow with Darkness in an object's art style is read as one with Color is, with the shadow's Darkness percentage (100% when the style gives none). Behavior reference: Illustrator CC Help (2014, [PDF](https://help.adobe.com/archive/en/illustrator/cc/2014/illustrator_reference.pdf)), page 428 (PDF page 434).