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 1/4] 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). From f4f40285b0a180c45eb1d4651bf4c097b5e21083 Mon Sep 17 00:00:00 2001 From: datatimp <12644819+datatimp@users.noreply.github.com> Date: Fri, 30 Oct 2026 22:03:13 -0400 Subject: [PATCH 2/4] Color Halftone: radii in points for older-version saves; review fixes (#3260) --- crates/doc/src/halftone_radii.rs | 75 ++++++++++++++++++++++++++++---- crates/format/src/lib.rs | 17 ++++++++ docs/effects-parity.md | 4 +- docs/target-app-parity.md | 5 ++- 4 files changed, 89 insertions(+), 12 deletions(-) diff --git a/crates/doc/src/halftone_radii.rs b/crates/doc/src/halftone_radii.rs index 6c3d4544d..cbafeb0b9 100644 --- a/crates/doc/src/halftone_radii.rs +++ b/crates/doc/src/halftone_radii.rs @@ -1,6 +1,7 @@ //! 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. +//! (#3260). [`Document::migrate_halftone_radii`] converts the radii of older documents, and +//! [`Document::halftone_radii_to_points`] converts them back for a file saved for an older app. use std::sync::Arc; @@ -30,14 +31,34 @@ impl Document { } self.version = FORMAT_VERSION; let k = self.raster_ppi() / 72.0; - let fix = &mut |e: &mut Effect| to_pixels(e, k); + self.each_halftone(&mut |e| rescale(e, k)); + } + + /// For a file saved for an app before version 2 (every native file version before today's): + /// each Color Halftone's Max Radius in pixels becomes the points that draw the same dots, + /// within the 4 to 127 pt that app takes (a radius under 4 pt, from a fine screen at a high + /// resolution, draws bigger dots there), and the document becomes version 1, so reopening the + /// file here converts them back. + pub fn halftone_radii_to_points(&mut self) { + if self.version < PIXEL_RADII_SINCE { + return; + } + self.version = PIXEL_RADII_SINCE - 1; + let k = 72.0 / self.raster_ppi(); + self.each_halftone(&mut |e| rescale(e, k)); + } + + /// `fix` each effect in the art, symbols, patterns (and the one being edited as it was), graph + /// designs, opacity masks and graphic styles that use a Color Halftone. + fn each_halftone(&mut self, fix: &mut dyn FnMut(&mut Effect)) { 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 { + // Pattern editing is saved with the pattern as it was, which Cancel puts back. + for p in self.patterns.iter_mut().chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut())) { for a in &mut p.art { node(a, fix); } @@ -53,8 +74,9 @@ impl Document { } } -/// `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) { +/// `e`'s radius times `k` (pixels a point, or points a pixel), within the effect's range, when it +/// is a Color Halftone. Its default is 8 in either unit. +fn rescale(e: &mut Effect, k: f64) { if e.id != HALFTONE { return; } @@ -109,17 +131,17 @@ mod tests { 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); + rescale(&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); + rescale(&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); + rescale(&mut blur, 300.0 / 72.0); assert_eq!(blur.params, json!({"radius": 8.0})); } @@ -142,4 +164,41 @@ mod tests { d.migrate_halftone_radii(); assert_eq!(d, once, "converted documents are left alone"); } + + #[test] + fn radii_go_back_to_points_for_an_older_app_and_return_unchanged() { + 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(100.0))); + style.appearance.effects.push(halftone(Some(4.0))); + d.graphic_styles.push(style); + let r = |d: &Document, i: usize| d.graphic_styles.last().map(|s| radius(&s.appearance.effects[i])).unwrap(); + d.halftone_radii_to_points(); + assert_eq!(d.version, 1, "reopened here, it converts back"); + assert!((r(&d, 0) - 24.0).abs() < 1e-9, "100 px at 300 ppi is 24 pt: {}", r(&d, 0)); + assert_eq!(r(&d, 1), 4.0, "0.96 pt is under the 4 pt the older app takes"); + let points = d.clone(); + d.halftone_radii_to_points(); + assert_eq!(d, points, "already in points"); + d.migrate_halftone_radii(); + assert!((r(&d, 0) - 100.0).abs() < 1e-9, "{}", r(&d, 0)); + } + + #[test] + fn the_pattern_being_edited_is_converted_as_it_was() { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = 300.0; + let id = d.alloc_id(); + let mut look = Appearance::default_art(); + look.effects = vec![halftone(Some(8.0))]; + let square = vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0)); + let tile = Arc::new(Node::path(id, square, look)); + let original = Some(crate::PatternDef::new("Tile", vec![tile])); + d.pattern_edit = Some(crate::PatternEdit { pattern: "Tile".into(), layer: crate::NodeId(0), original }); + d.version = 1; + d.migrate_halftone_radii(); + let art = &d.pattern_edit.as_ref().unwrap().original.as_ref().unwrap().art[0]; + assert!((radius(&art.appearance.effects[0]) - 33.333333333333336).abs() < 1e-9, "the tile Cancel puts back"); + } } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index d9fb08c0b..4cc07c404 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -319,6 +319,10 @@ fn body(doc: &Document, o: &SaveOptions) -> Result { if version < 3 { d.store_legacy_color_profiles(); } + // Apps before this one's version take Color Halftone's Max Radius in points (#3260). + if version < VERSION { + d.halftone_radii_to_points(); + } let v1 = if version == 1 { let mut v = serde_json::to_value(&d).map_err(encode_err)?; legacy::anchors_to_v1(&mut v); @@ -804,6 +808,19 @@ mod tests { 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)); + // Saved for an older app, in every older version: the radius in points, which opens here + // in pixels again. + let px = radius(&old); + for version in MIN_VERSION..VERSION { + let bytes = save_with(&old, &SaveOptions { version, ..SaveOptions::default() }).unwrap(); + let json = String::from_utf8(unpack::Unpacked::new(&bytes).unwrap().json().to_vec()).unwrap(); + let at = json.find("\"maxRadius\":").unwrap() + "\"maxRadius\":".len(); + let saved: f64 = json[at..].split([',', '}']).next().unwrap().trim().parse().unwrap(); + assert!((saved - 8.0).abs() < 1e-9, "version {version} holds 8 pt: {saved}"); + let back = load(&bytes).unwrap(); + assert!((radius(&back) - px).abs() < 1e-9, "version {version}: {}", radius(&back)); + } + assert_eq!(radius(&old), px, "saving leaves the open document as it is"); } #[test] diff --git a/docs/effects-parity.md b/docs/effects-parity.md index ade2feab8..4474cff23 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -1,6 +1,6 @@ # Effects parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); previously: Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Pixelate › Color Halftone: Max Radius in pixels of the Document Raster Effects resolution, 4–127 px, with older documents converted on opening and radii written back in points for older app versions (#3260); previously: Effect dialogs clamp bounded length inputs immediately on commit and flash rejection on invalid input (#4638); Drop Shadow Darkness uses fixed RGB subtraction and CMYK black addition; scores unchanged; Pathfinder › Trap: the Pathfinder panel's Trap… and Effect › Pathfinder › Trap… add an overprinting strip of the lighter colour where two solid colours meet; recounted with Hard Mix (#3061) and Soft Mix already in the catalogue: Pathfinder 13/13, Illustrator effects 49/50) · **Target:** Adobe Illustrator 2026 (30.x) Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue (`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it @@ -193,7 +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 | 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, and a file saved for an older app version (native versions before today's) holds them in points again. 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 c2fdf938d..a770371f8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools add-on: the Parametric Shape Tool; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Effects: Color Halftone's Max Radius in pixels of the Document Raster Effects resolution (#3260); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1129,7 +1129,7 @@ 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 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). +- **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), and a file saved for an older app version holds them in points again. - **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). @@ -1275,6 +1275,7 @@ spot colours, carry a colour adjustment, or are RGB, CMYK or grayscale images | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Effects: Color Halftone's Max Radius is 4–127 px of the Document Raster Effects resolution, shown in px whatever the units; older documents open converted, and files saved for older app versions hold points (#3260); scores unchanged. | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool; evidence added; scores unchanged. | | 2026-10-30 | minor | Presentation Mode fits the active artboard and hides selections, guides, grids and artboard edges; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged | From 4a8b40ac8b01c0f6710a9a3e40deb6aefe059da7 Mon Sep 17 00:00:00 2001 From: datatimp <12644819+datatimp@users.noreply.github.com> Date: Sun, 1 Nov 2026 13:57:22 -0500 Subject: [PATCH 3/4] Color Halftone: test older files at 72 and 300 ppi (#3260) --- crates/format/src/lib.rs | 60 +++++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 4cc07c404..8a01955de 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -789,38 +789,40 @@ mod tests { /// 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. + /// since opens as it was. At 72 ppi a point is a pixel, so the number stays. #[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)); - // Saved for an older app, in every older version: the radius in points, which opens here - // in pixels again. - let px = radius(&old); - for version in MIN_VERSION..VERSION { - let bytes = save_with(&old, &SaveOptions { version, ..SaveOptions::default() }).unwrap(); - let json = String::from_utf8(unpack::Unpacked::new(&bytes).unwrap().json().to_vec()).unwrap(); - let at = json.find("\"maxRadius\":").unwrap() + "\"maxRadius\":".len(); - let saved: f64 = json[at..].split([',', '}']).next().unwrap().trim().parse().unwrap(); - assert!((saved - 8.0).abs() < 1e-9, "version {version} holds 8 pt: {saved}"); - let back = load(&bytes).unwrap(); - assert!((radius(&back) - px).abs() < 1e-9, "version {version}: {}", radius(&back)); + for ppi in [72.0, 300.0] { + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = ppi; + 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, "{ppi} ppi: 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 * ppi / 72.0).abs() < 1e-9, "8 pt at {ppi} ppi: {}", radius(&old)); + // Saved for an older app, in every older version: the radius in points, which opens + // here in pixels again. + let px = radius(&old); + for version in MIN_VERSION..VERSION { + let bytes = save_with(&old, &SaveOptions { version, ..SaveOptions::default() }).unwrap(); + let json = String::from_utf8(unpack::Unpacked::new(&bytes).unwrap().json().to_vec()).unwrap(); + let at = json.find("\"maxRadius\":").unwrap() + "\"maxRadius\":".len(); + let saved: f64 = json[at..].split([',', '}']).next().unwrap().trim().parse().unwrap(); + assert!((saved - 8.0).abs() < 1e-9, "{ppi} ppi, version {version} holds 8 pt: {saved}"); + let back = load(&bytes).unwrap(); + assert!((radius(&back) - px).abs() < 1e-9, "{ppi} ppi, version {version}: {}", radius(&back)); + } + assert_eq!(radius(&old), px, "{ppi} ppi: saving leaves the open document as it is"); } - assert_eq!(radius(&old), px, "saving leaves the open document as it is"); } #[test] From 64312427e9795550d110c55ac62db0b1060c0d45 Mon Sep 17 00:00:00 2001 From: datatimp <12644819+datatimp@users.noreply.github.com> Date: Wed, 4 Nov 2026 13:03:25 -0500 Subject: [PATCH 4/4] Color Halftone: convert radii in brush definitions and Shaper edits (#3260) --- crates/doc/src/halftone_radii.rs | 81 ++++++++++++++++++++++++++++++-- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/halftone_radii.rs b/crates/doc/src/halftone_radii.rs index 5828f543e..a96707c48 100644 --- a/crates/doc/src/halftone_radii.rs +++ b/crates/doc/src/halftone_radii.rs @@ -14,6 +14,9 @@ use crate::{Appearance, Document, Effect, Node}; const PIXEL_RADII_SINCE: u32 = 3; const HALFTONE: &str = "pixelate.colorHalftone"; const RADIUS: &str = "maxRadius"; +/// The key of `Document::unknown` that holds the brush definitions (`vectorcraft-brush`'s +/// `DOC_KEY`), whose art can carry effects. +const BRUSHES: &str = "brushes"; /// 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. @@ -49,7 +52,8 @@ impl Document { } /// `fix` each effect in the art, symbols, patterns (and the one being edited as it was), graph - /// designs, opacity masks and graphic styles that use a Color Halftone. + /// designs, opacity masks, Shaper edits, graphic styles and brush definitions that use a Color + /// Halftone. fn each_halftone(&mut self, fix: &mut dyn FnMut(&mut Effect)) { for l in &mut self.layers { node(l, fix); @@ -71,6 +75,25 @@ impl Document { appearance(&mut s.appearance, fix); } } + if let Some(b) = self.unknown.get_mut(BRUSHES) { + brushes(b, fix); + } + } +} + +/// `fix` each Color Halftone in JSON `v` (brush definitions): every object whose `id` is +/// [`HALFTONE`] is a serialized [`Effect`] (object ids are numbers). +fn brushes(v: &mut Value, fix: &mut dyn FnMut(&mut Effect)) { + match v { + Value::Object(m) if m.get("id").and_then(Value::as_str) == Some(HALFTONE) => { + let params = m.get("params").cloned().unwrap_or(Value::Null); + let mut e = Effect { id: HALFTONE.into(), params, visible: true }; + fix(&mut e); + m.insert("params".into(), e.params); + } + Value::Object(m) => m.values_mut().for_each(|v| brushes(v, fix)), + Value::Array(a) => a.iter_mut().for_each(|v| brushes(v, fix)), + _ => {} } } @@ -98,14 +121,24 @@ fn appearance(a: &mut Appearance, fix: &mut dyn FnMut(&mut Effect)) { } } -/// Fix the effects of `n`'s subtree and its opacity masks (copying only what changes). +/// Fix the effects of `n`'s subtree, its opacity masks and its Shaper edits (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(x) = n.extras_if_any() { + if let Some(m) = x.mask.as_mut() { + node(&mut m.art, fix); + } + // A Shaper Group's face and edge edits paint with appearances of their own. + if let Some(s) = x.shaper.as_mut() { + let faces = s.faces.iter_mut().filter_map(|f| f.paint.as_mut()); + for p in faces.chain(s.edges.iter_mut().filter_map(|e| e.paint.as_mut())) { + appearance(p, fix); + } + } } if let Some(ch) = n.children_mut() { for c in ch { @@ -185,6 +218,46 @@ mod tests { assert!((r(&d, 0) - 100.0).abs() < 1e-9, "{}", r(&d, 0)); } + /// Shaper face and edge edits paint with appearances of their own, and brush definitions keep + /// their art as JSON: their radii convert both ways too. + #[test] + fn shaper_edits_and_brushes_are_converted_both_ways() { + use crate::shaper::{FaceEdit, FaceSelector, ShaperSpec}; + let mut d = Document::new(100.0, 100.0); + d.raster_effects_ppi = 300.0; + d.version = 1; + let mut look = Appearance::default_art(); + look.effects = vec![halftone(Some(8.0))]; + let face = FaceEdit { + selector: FaceSelector { sources: vec![0], anchor: vectorcraft_geom::Point::ZERO }, + erase: false, + erase_edges: false, + paint_source: None, + merge: None, + paint: Some(look), + }; + let mut shaper = Node::group(d.alloc_id(), vec![]); + shaper.extras_mut().shaper = Some(Box::new(ShaperSpec { faces: vec![face], edges: vec![] })); + let l = d.layers[0].id; + d.insert(Some(l), 0, shaper).unwrap(); + let effect = serde_json::to_value(halftone(Some(8.0))).unwrap(); + d.unknown.insert(BRUSHES.into(), json!([{"name": "B", "art": {"id": 99, "appearance": {"effects": [effect]}}}])); + let radii = |d: &Document| { + let shaper = d.layers[0].children().and_then(|c| c.first()).cloned().unwrap(); + let paint = shaper.extras().shaper.as_ref().and_then(|s| s.faces[0].paint.clone()).unwrap(); + let brush = d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"][RADIUS].as_f64().unwrap(); + (radius(&paint.effects[0]), brush) + }; + d.migrate_halftone_radii(); + let (face, brush) = radii(&d); + assert!((face - 33.333333333333336).abs() < 1e-9, "the face edit: {face}"); + assert!((brush - 33.333333333333336).abs() < 1e-9, "the brush: {brush}"); + assert_eq!(d.unknown[BRUSHES][0]["art"]["appearance"]["effects"][0]["params"]["channel1"], json!(108.0)); + d.halftone_radii_to_points(); + let (face, brush) = radii(&d); + assert!((face - 8.0).abs() < 1e-9 && (brush - 8.0).abs() < 1e-9, "back in points: {face}, {brush}"); + } + #[test] fn the_pattern_being_edited_is_converted_as_it_was() { let mut d = Document::new(100.0, 100.0);