diff --git a/crates/effects/src/addons.rs b/crates/effects/src/addons.rs index 39e5bab97..d1ba0783d 100644 --- a/crates/effects/src/addons.rs +++ b/crates/effects/src/addons.rs @@ -2,5 +2,8 @@ //! //! - [`color`]: Color Effects' live Exposure, Desaturate and Invert Colors, colour adjustments //! evaluated with the others ([`crate::adjust`]). +//! - [`halftone`]: Color Effects' live Vector Halftone, and the screens Object › Vector Halftone +//! builds its dots with. pub mod color; +pub mod halftone; diff --git a/crates/effects/src/addons/halftone.rs b/crates/effects/src/addons/halftone.rs new file mode 100644 index 000000000..9b5771e7b --- /dev/null +++ b/crates/effects/src/addons/halftone.rs @@ -0,0 +1,489 @@ +//! Vector Halftone (Color Effects): the art as vector dots, lines or shapes sized by its tone, as a +//! live effect (Effect › Color Effects › Vector Halftone…, [`HALFTONE`]) and for Object › Vector +//! Halftone, which builds its paths with the same screens ([`Halftone::layers`]). +//! +//! The art is sampled from pixels ([`Samples`], drawn by the renderer) once per halftone cell on a +//! grid turned by the screen angle; each cell gets a shape whose area follows the tone there. Mono +//! screens follow lightness in one colour (or, with `fromArt`, each dot takes the art's colour under +//! it); CMYK screens follow the art's ink amounts (as CMYK output separates it), one screen per ink at +//! the classic angles, multiplied over each other. The dots can be clipped to the art's outline as +//! real geometry. +//! +//! The renderer, the exporters ([`crate::bake_document`]) and Expand Appearance draw an object with +//! the effect as [`halftone_art`]: the renderer samples the art, so it registers how ([`set_art`]). + +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock}; + +use serde_json::{Value, json}; +use vectorcraft_doc::color::{BlendMode, Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Node, NodeId}; +use vectorcraft_geom::hit::fill_contains; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; +use vectorcraft_pathops::{self as po, BoolOp}; + +use crate::catalog::{EffectInfo, Lengths}; +use crate::util::{flag, num, text}; + +/// Vector Halftone, the live effect. +pub const HALFTONE: &str = "addon.color.halftone"; + +/// Most halftone cells per screen. +pub const MAX_CELLS: f64 = 100_000.0; +/// Most pixels the tone is sampled from. +pub const MAX_SAMPLE_PIXELS: f64 = 16.0e6; +/// Cells lighter than this get no dot. +const MIN_TONE: f64 = 0.01; +/// Levels per channel the art's colours are rounded to with `fromArt` (one path per colour). +const COLOR_LEVELS: f64 = 15.0; + +/// The effect's catalogue entry. +pub(crate) fn catalog() -> Vec { + vec![EffectInfo { + id: HALFTONE, + label: "Vector Halftone…", + menu: &["Effect", "Color Effects"], + params: "{shape: \"circle\"|\"ellipse\"|\"square\"|\"diamond\"|\"line\" (circle), frequency: lines per inch 1..300 (20), angle: deg (45; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it), mode: \"mono\"|\"cmyk\" (mono), color: mono dot colour (\"#000000\"), fromArt: bool (false; mono: each dot takes the art's colour under it), invert: bool (false; dots for light tones), clip: bool (true; the dots clipped to the art's outline)} draws the object as vector halftone dots sized by its tone, as object.vectorHalftone makes them (CMYK screens multiply); the object is sampled as drawn with its other effects; at most 100000 cells per screen, past that the object draws as it is", + defaults: json!({"shape": "circle", "frequency": 20.0, "angle": 45.0, "mode": "mono", "color": "#000000", "fromArt": false, "invert": false, "clip": true}), + raster: false, + lengths: Lengths::default(), + }] +} + +/// Does `n` carry a visible Vector Halftone effect? +pub fn has_halftone(n: &Node) -> bool { + n.appearance.effects.iter().any(|e| e.visible && e.id == HALFTONE) +} + +/// The shape of a halftone dot. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum DotShape { + Circle, + Ellipse, + Square, + Diamond, + Line, +} + +impl DotShape { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "circle" | "round" | "dot" => DotShape::Circle, + "ellipse" => DotShape::Ellipse, + "square" => DotShape::Square, + "diamond" => DotShape::Diamond, + "line" | "lines" => DotShape::Line, + _ => return None, + }) + } + + /// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen + /// running along x. + fn path(self, t: f64, s: f64) -> PathData { + let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side))); + match self { + DotShape::Circle => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r))) + } + DotShape::Ellipse => { + let r = s * (t / std::f64::consts::PI).sqrt(); + shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3))) + } + DotShape::Square => square(s * t.sqrt()), + DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)), + // Cells along a row join into a line as thick as the tone. + DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))), + } + } +} + +/// One screen: its ink and angle, and how to read its tone from a sampled pixel. +struct Screen { + paint: Color, + angle: f64, + /// Index into the sampled pixel (unused for mono, CMYK inks for cmyk). + channel: usize, +} + +/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt. +pub struct Samples { + px: Vec, + w: usize, + h: usize, + region: Rect, + scale: f64, + /// Mono: tones are 1 − lightness of RGBA pixels drawn on white; CMYK: ink amounts. + mono: bool, + invert: bool, +} + +impl Samples { + /// Pixels `px` (`w`×`h`, 4 bytes each) over `region` at `scale` px/pt; `None` when there are + /// fewer bytes than that. + pub fn new(px: Vec, (w, h): (usize, usize), region: Rect, scale: f64, mono: bool, invert: bool) -> Option { + (px.len() >= w.checked_mul(h)?.checked_mul(4)?).then_some(Self { px, w, h, region, scale, mono, invert }) + } + + /// The index of the pixel at document point `p`, `None` outside the samples. + fn index(&self, p: Point) -> Option { + let x = ((p.x - self.region.x0) * self.scale).floor(); + let y = ((p.y - self.region.y0) * self.scale).floor(); + (x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h).then(|| (y as usize * self.w + x as usize) * 4) + } + + fn byte(&self, i: usize, k: usize) -> f64 { + self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0 + } + + /// The tone (0..1) of `channel` at document point `p`. + fn tone(&self, p: Point, channel: usize) -> f64 { + let Some(i) = self.index(p) else { return if self.invert { 1.0 } else { 0.0 } }; + let t = + if self.mono { 1.0 - (0.2126 * self.byte(i, 0) + 0.7152 * self.byte(i, 1) + 0.0722 * self.byte(i, 2)) } else { self.byte(i, channel) }; + if self.invert { 1.0 - t } else { t } + } + + /// The 3 × 3 sample points of a cell of side `s` at `c` turned by `rot`. + fn cell_points(c: Point, s: f64, rot: Affine) -> impl Iterator { + (0..9).map(move |k| c + (rot * Point::new((f64::from(k / 3) - 1.0) * s / 3.0, (f64::from(k % 3) - 1.0) * s / 3.0)).to_vec2()) + } + + /// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples). + fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 { + Self::cell_points(c, s, rot).map(|p| self.tone(p, channel)).sum::() / 9.0 + } + + /// The mean colour over a cell (as [`Self::cell_tone`]; white outside the samples), each channel + /// rounded to [`COLOR_LEVELS`] steps. + fn cell_color(&self, c: Point, s: f64, rot: Affine) -> [u8; 3] { + let mut sum = [0.0; 3]; + for p in Self::cell_points(c, s, rot) { + for (k, v) in sum.iter_mut().enumerate() { + *v += self.index(p).map_or(1.0, |i| self.byte(i, k)); + } + } + sum.map(|v| (v / 9.0 * COLOR_LEVELS).round().clamp(0.0, COLOR_LEVELS) as u8) + } +} + +/// A halftone's settings. +#[derive(Clone, Debug, PartialEq)] +pub struct Halftone { + pub shape: DotShape, + /// Lines per inch. + pub frequency: f64, + /// Degrees. + pub angle: f64, + /// One screen in `color` (else four CMYK screens). + pub mono: bool, + pub color: Color, + /// Mono: each dot in the art's colour under it instead of `color`. + pub from_art: bool, + /// Dots for light tones. + pub invert: bool, + /// The dots clipped to the art's outline. + pub clip: bool, +} + +impl Halftone { + /// The settings of `n`'s last visible Vector Halftone effect, its values brought into range. + pub fn of(n: &Node) -> Option { + let e = n.appearance.effects.iter().rev().find(|e| e.visible && e.id == HALFTONE)?; + Some(Self::from_params(&e.params)) + } + + fn from_params(p: &Value) -> Self { + Self { + shape: DotShape::parse(text(p, "shape", "circle")).unwrap_or(DotShape::Circle), + frequency: num(p, "frequency", 20.0).clamp(1.0, 300.0), + angle: num(p, "angle", 45.0), + mono: !text(p, "mode", "mono").eq_ignore_ascii_case("cmyk"), + color: crate::raster::color_param(p, Color::BLACK), + from_art: flag(p, "fromArt", false), + invert: flag(p, "invert", false), + clip: flag(p, "clip", true), + } + } + + /// The side of a cell in points. + pub fn cell(&self) -> f64 { + 72.0 / self.frequency + } + + /// Where the art over `bounds` is sampled and at how many pixels per point: about four pixels per + /// cell, at most [`MAX_SAMPLE_PIXELS`]. `None` when `bounds` have no area or the grid turned by + /// the screen angle would have more than [`MAX_CELLS`] cells. + pub fn plan(&self, bounds: Rect) -> Option<(Rect, f64)> { + let cell = self.cell(); + if !(bounds.width() > 0.0 && bounds.height() > 0.0 && bounds.area().is_finite()) { + return None; + } + // The grid turned by the screen angle covers at most (w + h)² / 2. + if (bounds.width() + bounds.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS { + return None; + } + let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / bounds.area()).sqrt()).clamp(0.01, 16.0); + Some((bounds.inflate(cell, cell), scale)) + } + + /// The screens, bottom to top. + fn screens(&self) -> Vec { + let angle = self.angle; + if self.mono { + return vec![Screen { paint: self.color, angle, channel: 0 }]; + } + // Bottom to top: yellow, magenta, cyan, black. + vec![ + Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 }, + Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 }, + Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 }, + Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 }, + ] + } + + /// The dots of the art `samples` show over `bounds`, one path per ink (per colour with + /// `fromArt`) in paint order, each clipped to `outline` when given, and how many dots there are. + pub fn layers(&self, samples: &Samples, bounds: Rect, outline: Option<&(BezPath, FillRule)>) -> (Vec<(PathData, Color)>, usize) { + let cell = self.cell(); + let mut layers = vec![]; + let mut count = 0; + for sc in self.screens() { + let dots = screen_dots(samples, bounds, cell, &sc, self.shape); + count += dots.len(); + if self.mono && self.from_art { + let rot = Affine::rotate(sc.angle.to_radians()); + let mut by_color: BTreeMap<[u8; 3], Vec> = BTreeMap::new(); + for (dot, c) in dots { + by_color.entry(samples.cell_color(c, cell, rot)).or_default().push(dot); + } + for (rgb, dots) in by_color { + let [r, g, b] = rgb.map(|v| (f64::from(v) / COLOR_LEVELS) as f32); + layers.push((merged(dots, outline), Color::rgb(r, g, b))); + } + } else { + layers.push((merged(dots.into_iter().map(|d| d.0).collect(), outline), sc.paint)); + } + } + layers.retain(|(path, _)| !path.is_empty()); + (layers, count) + } +} + +/// The dots of one screen over `bounds`, each with its cell's centre. +fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape) -> Vec<(PathData, Point)> { + let rot = Affine::rotate(sc.angle.to_radians()); + let inv = rot.inverse(); + // The grid covers the bounds in screen space. + let corners = + [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q); + let (mut lo, mut hi) = (corners[0], corners[0]); + for q in &corners[1..] { + lo = Point::new(lo.x.min(q.x), lo.y.min(q.y)); + hi = Point::new(hi.x.max(q.x), hi.y.max(q.y)); + } + let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1); + let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1); + let near = bounds.inflate(cell, cell); + let mut dots = vec![]; + for j in j0..=j1 { + for i in i0..=i1 { + let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell); + if !near.contains(c) { + continue; + } + let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0); + if t < MIN_TONE { + continue; + } + let place = Affine::translate(c.to_vec2()) * rot; + dots.push((shape.path(t, cell).transformed(place), c)); + } + } + dots +} + +/// The outline Clip to Art clips the dots of `nodes` to: their clip outline taken together. +pub fn art_outline(nodes: &[Node]) -> Option<(BezPath, FillRule)> { + crate::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())) +} + +/// `dots` as one path, clipped to `outline` when given (dots wholly inside it need no clipping). +fn merged(dots: Vec, outline: Option<&(BezPath, FillRule)>) -> PathData { + let (mut inside, mut edge) = (vec![], vec![]); + for dot in dots { + let whole = match (outline, dot.control_bounds()) { + (Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)] + .iter() + .all(|q| fill_contains(bp, *rule, *q)), + _ => true, + }; + if whole { inside.push(dot) } else { edge.push(dot) } + } + let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect(); + if let Some((bp, rule)) = outline + && !edge.is_empty() + { + let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect()); + let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect); + subs.extend(clipped.subpaths); + } else { + subs.extend(edge.into_iter().flat_map(|p| p.subpaths)); + } + PathData::new(subs) +} + +/// The paths of `layers` ([`Halftone::layers`]), each filled with its ink and ids from `id`; CMYK +/// screens (`mono` false) multiply over each other. +pub fn dot_nodes(layers: Vec<(PathData, Color)>, mono: bool, id: &mut dyn FnMut() -> NodeId) -> Vec> { + layers + .into_iter() + .map(|(path, ink)| { + let mut n = Node::path(id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0)); + if !mono { + n.blend = BlendMode::Multiply; + } + Arc::new(n) + }) + .collect() +} + +/// `n` split for its halftone: the object the effect samples (`n` visible, without its Vector +/// Halftone effects, transparency, mask and link) and an empty group that takes over its identity, +/// transparency, mask and link, for the dots. +pub fn split(n: &Node) -> (Node, Node) { + let mut object = n.clone(); + object.appearance.effects.retain(|e| e.id != HALFTONE); + object.visible = true; + let mut g = Node::group(n.id, vec![]); + (g.visible, g.locked) = (n.visible, n.locked); + g.name = object.name.take(); + g.opacity = std::mem::replace(&mut object.opacity, 1.0); + g.blend = std::mem::take(&mut object.blend); + g.isolate = std::mem::take(&mut object.isolate); + g.extras_mut().mask = object.extras_mut().mask.take(); + g.extras_mut().attrs = object.extras_mut().attrs.take(); + (object, g) +} + +/// How far the dots of `n`'s halftone reach past the bounds of the object it samples: nothing when +/// they are clipped to its outline, else up to two cells (a dot's centre lies within a cell of the +/// bounds, and the dot within another). +pub fn reach(n: &Node) -> f64 { + Halftone::of(n).filter(|h| !h.clip).map_or(0.0, |h| 2.0 * h.cell()) +} + +/// Makes the art of an object with a visible Vector Halftone effect ([`halftone_art`]). +pub type ArtHook = fn(&Document, &Node) -> Option; + +static ART: OnceLock = OnceLock::new(); + +/// Register how the art of an object with Vector Halftone is made (the renderer samples it, above +/// this crate; the first registration stays). +pub fn set_art(f: ArtHook) { + // Already set: the same renderer, registered by another session. + let _ = ART.set(f); +} + +/// The art of `n` with a visible Vector Halftone effect in `d`: the group [`split`] makes, holding +/// the dot paths ([`dot_nodes`], ids `n`'s). `None` without the effect, while nothing is registered +/// ([`set_art`]), and when the art has no area or would take too many cells ([`Halftone::plan`]). +pub fn halftone_art(d: &Document, n: &Node) -> Option { + if !has_halftone(n) { + return None; + } + ART.get()?(d, n) +} + +#[cfg(test)] +mod tests { + use super::*; + use vectorcraft_doc::Effect; + + /// A black square's samples: `side` points of black from (10, 10), white around it. + fn black_square(side: f64, h: &Halftone) -> (Samples, Rect) { + let b = Rect::new(10.0, 10.0, 10.0 + side, 10.0 + side); + let (region, scale) = h.plan(b).unwrap(); + let (w, hh) = ((region.width() * scale).round() as usize, (region.height() * scale).round() as usize); + let mut px = vec![255u8; w * hh * 4]; + for y in 0..hh { + for x in 0..w { + let p = Point::new(region.x0 + (x as f64 + 0.5) / scale, region.y0 + (y as f64 + 0.5) / scale); + if b.contains(p) { + px[(y * w + x) * 4..][..3].copy_from_slice(&[200, 0, 0]); + } + } + } + (Samples::new(px, (w, hh), region, scale, true, h.invert).unwrap(), b) + } + + fn effect(params: Value) -> Node { + let mut n = Node::path(NodeId(7), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default()); + n.appearance.effects.push(Effect { id: HALFTONE.into(), params, visible: true }); + n + } + + #[test] + fn the_effect_is_in_color_effects_with_its_defaults() { + let info = crate::effect_info(HALFTONE).unwrap(); + assert_eq!(info.menu, ["Effect", "Color Effects"]); + assert!(!crate::is_geometry(HALFTONE) && !crate::is_raster(HALFTONE) && !crate::is_adjustment(HALFTONE)); + let h = Halftone::of(&effect(info.defaults)).unwrap(); + assert_eq!((h.shape, h.frequency, h.angle, h.mono, h.from_art, h.invert, h.clip), (DotShape::Circle, 20.0, 45.0, true, false, false, true)); + assert_eq!(h.color, Color::BLACK); + } + + #[test] + fn values_out_of_range_are_brought_in() { + let h = Halftone::of(&effect(json!({"frequency": 1e308, "angle": "NaN", "shape": 7, "mode": "CMYK", "color": "nope"}))).unwrap(); + assert_eq!((h.frequency, h.angle, h.shape, h.mono, h.color), (300.0, 45.0, DotShape::Circle, false, Color::BLACK)); + assert_eq!(Halftone::of(&effect(json!({"frequency": -5}))).unwrap().frequency, 1.0); + let mut hidden = effect(json!({})); + hidden.appearance.effects[0].visible = false; + assert!(Halftone::of(&hidden).is_none() && !has_halftone(&hidden)); + } + + #[test] + fn dots_follow_the_tone_and_the_settings() { + let h = Halftone::of(&effect(json!({"frequency": 18}))).unwrap(); + let (s, b) = black_square(60.0, &h); + let (layers, dots) = h.layers(&s, b, None); + assert_eq!(layers.len(), 1); + assert!((150..=320).contains(&dots), "{dots}"); + // Inverted, the dark square gets small dots and the white around it full ones. + let inv = Halftone { invert: true, ..h.clone() }; + let (s2, _) = black_square(60.0, &inv); + assert!(inv.layers(&s2, b, None).1 > dots); + // The colour of the art under each dot. + let art = Halftone { from_art: true, ..h.clone() }; + let (layers, _) = art.layers(&s, b, None); + assert!(layers.iter().any(|(_, c)| c.to_hex() == "#cc0000"), "{:?}", layers.iter().map(|l| l.1.to_hex()).collect::>()); + // CMYK screens: one layer per ink with dots (red: magenta and yellow). + let cmyk = Halftone { mono: false, ..h }; + let one = Samples::new([0u8, 255, 255, 0].repeat(400), (20, 20), Rect::new(0.0, 0.0, 20.0, 20.0), 1.0, false, false).unwrap(); + assert_eq!(cmyk.layers(&one, Rect::new(2.0, 2.0, 18.0, 18.0), None).0.len(), 2); + } + + #[test] + fn the_caps_refuse_too_many_cells() { + let h = Halftone::of(&effect(json!({"frequency": 300}))).unwrap(); + assert!(h.plan(Rect::new(0.0, 0.0, 10_000.0, 10_000.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, f64::INFINITY, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 0.0, 10.0)).is_none()); + assert!(h.plan(Rect::new(0.0, 0.0, 40.0, 40.0)).is_some()); + assert!(Samples::new(vec![0; 3], (1, 1), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + assert!(Samples::new(vec![], (usize::MAX, 2), Rect::new(0.0, 0.0, 1.0, 1.0), 1.0, true, false).is_none()); + } + + #[test] + fn the_group_takes_the_objects_identity() { + let mut n = effect(json!({"clip": false, "frequency": 36})); + n.opacity = 0.5; + n.name = Some("art".into()); + let (object, g) = split(&n); + assert!(!has_halftone(&object)); + assert_eq!((object.opacity, g.opacity, g.id, g.name.as_deref()), (1.0, 0.5, n.id, Some("art"))); + assert_eq!(reach(&n), 4.0); + assert_eq!(reach(&effect(json!({}))), 0.0); + } +} diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index c92947ac3..e713e0ffa 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -52,6 +52,7 @@ pub fn needs_bake(n: &Node) -> bool { || crate::has_solid(n) || has_adjustment(n) || has_crop_marks(n) + || crate::addons::halftone::has_halftone(n) || has_pathfinder(n) || has_own_paint(n) || has_geometry(&n.appearance.effects) @@ -104,6 +105,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if let Some(m) = crop_marks_art(n) { return Some(bake_pieces(d, m)); } + // Vector Halftone: the object as its dots. + if let Some(m) = crate::addons::halftone::halftone_art(d, n) { + return Some(bake_pieces(d, m)); + } if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) { let m = evaluate_container(Some(d), &m).unwrap_or(m); return Some(bake_pieces(d, m)); diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 32e611c62..5fdef797c 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -792,6 +792,7 @@ pub fn effect_catalog() -> Vec { } } v.extend(crate::addons::color::catalog()); + v.extend(crate::addons::halftone::catalog()); v.push(g(TRAP, "Trap…", PATHFINDER, trap::EFFECT_PARAMS_DOC, trap::effect_defaults())); v.push(g( CROP_MARKS, diff --git a/crates/effects/src/params.rs b/crates/effects/src/params.rs index d64ac7b78..c8cea8259 100644 --- a/crates/effects/src/params.rs +++ b/crates/effects/src/params.rs @@ -118,14 +118,16 @@ pub fn is_raster(id: &str) -> bool { || PIXEL_EFFECTS.contains(&id) } -/// Does `id` change geometry? (Crop Marks adds art of its own instead, [`crop_marks_art`]; colour -/// adjustments recolour, [`adjust`].) Plug-in effects do, installed or not (a missing plug-in +/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead, +/// [`crop_marks_art`] and [`crate::addons::halftone::halftone_art`]; colour adjustments recolour, +/// [`adjust`].) Plug-in effects do, installed or not (a missing plug-in /// leaves the geometry as it is). pub fn is_geometry(id: &str) -> bool { !is_raster(id) && !is_pathfinder(id) && !is_adjustment(id) && id != CROP_MARKS + && id != crate::addons::halftone::HALFTONE && !is_three_d(id) && (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some()) } diff --git a/crates/engine/src/cmd/addons/color/tests.rs b/crates/engine/src/cmd/addons/color/tests.rs index 29034c51e..5094c2019 100644 --- a/crates/engine/src/cmd/addons/color/tests.rs +++ b/crates/engine/src/cmd/addons/color/tests.rs @@ -154,3 +154,87 @@ fn select_by_color_kind_finds_spot_colours_effects_and_image_models() { assert!(s.execute("addon.color.select", &json!({"kind": "pantone"})).is_err()); assert!(s.execute("addon.color.select", &json!({})).is_err()); } + +/// Vector Halftone, the live effect. +const HALFTONE: &str = "addon.color.halftone"; + +/// The dots in group `g`: each path's geometry, ink and blend mode. +fn dots(g: &vectorcraft_doc::Node) -> Vec<(vectorcraft_geom::PathData, String, vectorcraft_color::BlendMode)> { + let ink = |c: &vectorcraft_doc::Node| c.appearance.fill_paint().color().unwrap().to_hex(); + g.children().unwrap().iter().map(|c| (c.path_data().unwrap().clone(), ink(c), c.blend)).collect() +} + +/// `format` as `document.export` writes the active document. +fn exported(s: &mut Session, format: &str) -> Vec { + let v = s.execute("document.export", &json!({"format": format})).unwrap(); + vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap() +} + +/// How many subpaths the path data of `svg` starts. +fn svg_moves(svg: &[u8]) -> usize { + let svg = String::from_utf8_lossy(svg); + svg.split(" d=\"").skip(1).map(|d| d.split('"').next().unwrap_or("").matches(['M', 'm']).count()).sum() +} + +#[test] +fn expand_appearance_gives_the_dots_object_vector_halftone_makes() { + for params in [ + json!({"frequency": 18}), + json!({"frequency": 12, "mode": "cmyk", "shape": "square", "angle": 15}), + json!({"shape": "line", "invert": true, "clip": false}), + json!({"shape": "ellipse", "color": "#336699", "angle": -30}), + ] { + let (mut live, id) = red_rect(); + live.execute("effect.apply", &json!({"effect": HALFTONE, "params": params})).unwrap(); + assert_eq!(fill_hex(&live, id), "#ff0000", "the effect leaves the object as it is"); + live.execute("effect.expandAppearance", &json!({})).unwrap(); + let expanded = live.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(expanded.appearance.effects.is_empty()); + let (mut made, _) = red_rect(); + let r = made.execute("object.vectorHalftone", ¶ms).unwrap(); + let g = made.doc().unwrap().doc.node(NodeId(r["id"].as_u64().unwrap())).cloned().unwrap(); + assert!(!dots(&g).is_empty(), "{params}"); + assert_eq!(dots(&expanded), dots(&g), "{params}"); + // Ids of their own. + let ids: std::collections::HashSet<_> = expanded.children().unwrap().iter().map(|c| c.id).chain([id]).collect(); + assert_eq!(ids.len(), expanded.children().unwrap().len() + 1); + } +} + +#[test] +fn exports_write_the_dots_as_paths() { + let (mut s, _) = red_rect(); + let plain: Vec = ["pdf", "eps", "ai"].map(|f| exported(&mut s, f).len()).to_vec(); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 18}})).unwrap(); + // The exporters' copy of the document has the dots in the object's place. + let baked = vectorcraft_render::effects::bake_document(&s.doc().unwrap().doc).unwrap(); + let mut paths = 0; + baked.walk(|n| { + assert!(n.appearance.effects.iter().all(|e| e.id != HALFTONE)); + paths += n.path_data().map_or(0, |p| p.subpaths.len()); + }); + assert!(paths > 100, "{paths} subpaths"); + // SVG: path data of a subpath per dot. + let moves = svg_moves(&exported(&mut s, "svg")); + assert!(moves > 100, "{moves} subpaths"); + // PDF, EPS and AI: the dots' paths make each file larger. + for (f, plain) in ["pdf", "eps", "ai"].into_iter().zip(plain) { + let with = exported(&mut s, f).len(); + assert!(with > plain + 1000, "{f}: {with} bytes, {plain} without the effect"); + } +} + +#[test] +fn a_halftone_past_the_caps_leaves_the_object_as_it_is() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); + let id = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 9000, "height": 9000})).unwrap()["id"].as_u64().unwrap()); + s.execute("effect.apply", &json!({"effect": HALFTONE, "params": {"frequency": 300}})).unwrap(); + // Drawn as it is, exported and expanded without dots and without a panic. + pixel(&s); + assert!(svg_moves(&exported(&mut s, "svg")) < 5); + let r = s.execute("effect.expandAppearance", &json!({})); + assert!(!matches!(r, Err(crate::EngineError::Internal { .. })), "{r:?}"); + let n = s.doc().unwrap().doc.node(id).cloned().unwrap(); + assert!(n.children().is_none_or(|c| c.len() < 5), "no dots"); +} diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index d7c584d9d..bdd252da4 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -69,7 +69,7 @@ pub fn specs() -> Vec { "Expand Appearance", ["Object"], None, - "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", + "{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}", can_expand, expand_appearance ), @@ -646,6 +646,17 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br out.extend(expanded.into_iter().map(|(id, at)| (id, at + 1))); return Some(m); } + // Vector Halftone: the object becomes its dots, a group (with `id`) of paths with ids of their own. + if let Some(mut m) = vectorcraft_render::addons::halftone::art(d, &n) { + let mut seen = std::collections::HashSet::from([id]); + for c in m.children_mut().into_iter().flatten() { + effects::fresh_ids(d, Arc::make_mut(c), &mut seen); + } + if !listed_n { + out.push((id, 0)); + } + return Some(m); + } let image = raster_image(d, &n); let mut v = n.clone(); rasterfx::strip_raster(&mut v, false); diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs index b97d92ee4..a205adc46 100644 --- a/crates/engine/src/cmd/halftone.rs +++ b/crates/engine/src/cmd/halftone.rs @@ -1,24 +1,16 @@ //! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or -//! shapes sized by its tone. +//! shapes sized by its tone, as one undo step. //! -//! The art is rendered offscreen and sampled once per halftone cell on a grid turned by the screen -//! angle; each cell gets a shape whose area follows the tone there. Mono screens follow lightness -//! in one colour; CMYK screens follow the art's ink amounts (as CMYK output separates it), one -//! screen per ink at the classic angles, multiplied over each other. The dots can be clipped to the -//! art's outline as real geometry. - -use std::sync::Arc; +//! The screens, dots and caps are Color Effects' Vector Halftone's +//! ([`vectorcraft_render::effects::addons::halftone`]), which draws the same dots live; the art is +//! sampled as [`vectorcraft_render::addons::halftone::sample`] draws it. use serde_json::{Value, json}; -use vectorcraft_color::{BlendMode, Color, Paint}; -use vectorcraft_doc::{Appearance, Node, NodeId}; -use vectorcraft_geom::hit::fill_contains; -use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, shapes}; -use vectorcraft_pathops as po; -use vectorcraft_pathops::BoolOp; +use vectorcraft_color::Color; +use vectorcraft_doc::Node; +use vectorcraft_render::effects::addons::halftone::{DotShape, Halftone, art_outline, dot_nodes}; use super::edit::selected_roots; -use super::menucmds::isolated_doc; use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, color_value, f64_or, has_selection, str_param}; pub fn specs() -> Vec { @@ -33,102 +25,6 @@ pub fn specs() -> Vec { )] } -/// Most halftone cells per screen. -const MAX_CELLS: f64 = 100_000.0; -/// Most pixels the tone is sampled from. -const MAX_SAMPLE_PIXELS: f64 = 16.0e6; -/// Cells lighter than this get no dot. -const MIN_TONE: f64 = 0.01; - -#[derive(Clone, Copy, Debug, PartialEq)] -enum DotShape { - Circle, - Ellipse, - Square, - Diamond, - Line, -} - -impl DotShape { - fn parse(s: &str) -> Option { - Some(match s.to_ascii_lowercase().as_str() { - "circle" | "round" | "dot" => DotShape::Circle, - "ellipse" => DotShape::Ellipse, - "square" => DotShape::Square, - "diamond" => DotShape::Diamond, - "line" | "lines" => DotShape::Line, - _ => return None, - }) - } - - /// The shape for tone `t` (0..1) in a cell of side `s` centred on the origin, the screen - /// running along x. - fn path(self, t: f64, s: f64) -> PathData { - let square = |side: f64| shapes::rectangle(Rect::from_center_size(Point::ZERO, (side, side))); - match self { - DotShape::Circle => { - let r = s * (t / std::f64::consts::PI).sqrt(); - shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.0 * r, 2.0 * r))) - } - DotShape::Ellipse => { - let r = s * (t / std::f64::consts::PI).sqrt(); - shapes::ellipse(Rect::from_center_size(Point::ZERO, (2.6 * r, 2.0 * r / 1.3))) - } - DotShape::Square => square(s * t.sqrt()), - DotShape::Diamond => square(s * t.sqrt()).transformed(Affine::rotate(std::f64::consts::FRAC_PI_4)), - // Cells along a row join into a line as thick as the tone. - DotShape::Line => shapes::rectangle(Rect::from_center_size(Point::ZERO, (s * 1.001, s * t))), - } - } -} - -/// One screen: its ink and angle, and how to read its tone from a sampled pixel. -struct Screen { - paint: Color, - angle: f64, - /// Index into the sampled pixel (RGBA lightness for mono, CMYK inks for cmyk). - channel: usize, -} - -/// The sampled art: `w`×`h` pixels of 4 bytes over `region` at `scale` px/pt. -struct Samples { - px: Vec, - w: usize, - h: usize, - region: Rect, - scale: f64, - /// Mono: tones are 1 − lightness of RGBA pixels; CMYK: ink amounts. - mono: bool, - invert: bool, -} - -impl Samples { - /// The tone (0..1) of `channel` at document point `p`. - fn tone(&self, p: Point, channel: usize) -> f64 { - let x = ((p.x - self.region.x0) * self.scale).floor(); - let y = ((p.y - self.region.y0) * self.scale).floor(); - if !(x >= 0.0 && y >= 0.0 && (x as usize) < self.w && (y as usize) < self.h) { - return if self.invert { 1.0 } else { 0.0 }; - } - let i = (y as usize * self.w + x as usize) * 4; - let byte = |k: usize| self.px.get(i + k).copied().unwrap_or(0) as f64 / 255.0; - let t = if self.mono { 1.0 - (0.2126 * byte(0) + 0.7152 * byte(1) + 0.0722 * byte(2)) } else { byte(channel) }; - if self.invert { 1.0 - t } else { t } - } - - /// The mean tone over a cell of side `s` at `c` turned by `rot` (3 × 3 samples). - fn cell_tone(&self, c: Point, s: f64, rot: Affine, channel: usize) -> f64 { - let mut sum = 0.0; - for i in 0..3 { - for j in 0..3 { - let off = Point::new((i as f64 - 1.0) * s / 3.0, (j as f64 - 1.0) * s / 3.0); - sum += self.tone(c + (rot * off).to_vec2(), channel); - } - } - sum / 9.0 - } -} - fn halftone(s: &mut Session, p: &Value) -> Result { const C: &str = "object.vectorHalftone"; let shape = match str_param(p, "shape") { @@ -153,7 +49,7 @@ fn halftone(s: &mut Session, p: &Value) -> Result { None => Color::BLACK, }; let (invert, clip, keep) = (bool_or(p, "invert", false), bool_or(p, "clip", true), bool_or(p, "keepOriginal", false)); - let cell = 72.0 / frequency; + let ht = Halftone { shape, frequency, angle, mono, color, from_art: false, invert, clip }; let roots = selected_roots(s)?; let st = s.doc()?; @@ -161,10 +57,7 @@ fn halftone(s: &mut Session, p: &Value) -> Result { if b.width() <= 0.0 || b.height() <= 0.0 || !b.area().is_finite() { return Err(bad(C, "the selection has no area")); } - // The grid turned by the screen angle covers at most (w + h)² / 2. - if (b.width() + b.height()).powi(2) / 2.0 / (cell * cell) > MAX_CELLS { - return Err(bad(C, "too many dots for this size: lower the frequency")); - } + let (region, scale) = ht.plan(b).ok_or_else(|| bad(C, "too many dots for this size: lower the frequency"))?; let nodes: Vec = roots .iter() .filter_map(|id| st.doc.node(*id).cloned()) @@ -173,64 +66,18 @@ fn halftone(s: &mut Session, p: &Value) -> Result { n }) .collect(); - let outline = if clip { - vectorcraft_render::effects::clip_outline(&Node::group(NodeId(u64::MAX), nodes.iter().cloned().map(Arc::new).collect())) - } else { - None - }; - // Sample the art at about four pixels per cell. - let scale = (4.0 / cell).min((MAX_SAMPLE_PIXELS / b.area()).sqrt()).clamp(0.01, 16.0); - let region = b.inflate(cell, cell); - let tmp = isolated_doc(&st.doc, nodes); - let mut r = vectorcraft_render::Renderer::new(); - let (w, h) = vectorcraft_render::region_pixels(region, scale); - let (w, h) = (w as usize, h as usize); + let outline = if clip { art_outline(&nodes) } else { None }; let failed = vectorcraft_render::decode_failures(); - let px = if mono { - r.render_region(&tmp, region, scale, true).pixels - } else { - r.render_region_inks(&tmp, region, scale, &vectorcraft_render::RenderOptions { skip_templates: true, ..Default::default() }) - }; + let samples = vectorcraft_render::addons::halftone::sample(&st.doc, nodes, region, scale, mono, invert); let warning = super::rasterfx::undecoded_warning(failed); - if px.len() < w * h * 4 { - return Err(EngineError::Other("the art could not be sampled".into())); - } - let samples = Samples { px, w, h, region, scale, mono, invert }; - let screens: Vec = if mono { - vec![Screen { paint: color, angle, channel: 0 }] - } else { - // Bottom to top: yellow, magenta, cyan, black. - vec![ - Screen { paint: Color::cmyk(0.0, 0.0, 1.0, 0.0), angle: angle - 45.0, channel: 2 }, - Screen { paint: Color::cmyk(0.0, 1.0, 0.0, 0.0), angle: angle + 30.0, channel: 1 }, - Screen { paint: Color::cmyk(1.0, 0.0, 0.0, 0.0), angle: angle - 30.0, channel: 0 }, - Screen { paint: Color::cmyk(0.0, 0.0, 0.0, 1.0), angle, channel: 3 }, - ] - }; - let mut layers = vec![]; - let mut dots = 0; - for sc in &screens { - let (path, n) = screen_dots(&samples, b, cell, sc, shape, outline.as_ref()); - dots += n; - if !path.is_empty() { - layers.push((path, sc.paint)); - } - } + let samples = samples.ok_or_else(|| EngineError::Other("the art could not be sampled".into()))?; + let (layers, dots) = ht.layers(&samples, b, outline.as_ref()); if layers.is_empty() { return Err(EngineError::Other("the art is too light for any dots".into())); } let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?; let id = s.edit("Vector Halftone", |d, sel| { - let children = layers - .into_iter() - .map(|(path, ink)| { - let mut n = Node::path(d.alloc_id(), path, Appearance::basic(Paint::solid(ink), Paint::None, 0.0)); - if !mono { - n.blend = BlendMode::Multiply; - } - Arc::new(n) - }) - .collect(); + let children = dot_nodes(layers, mono, &mut || d.alloc_id()); let gid = d.alloc_id(); let mut g = Node::group(gid, children); g.name = Some("Vector Halftone".into()); @@ -249,55 +96,3 @@ fn halftone(s: &mut Session, p: &Value) -> Result { } Ok(out) } - -/// The dots of one screen over `bounds` as one path, with their count. -fn screen_dots(samples: &Samples, bounds: Rect, cell: f64, sc: &Screen, shape: DotShape, outline: Option<&(BezPath, FillRule)>) -> (PathData, usize) { - let rot = Affine::rotate(sc.angle.to_radians()); - let inv = rot.inverse(); - // The grid covers the bounds in screen space. - let corners = - [bounds.origin(), Point::new(bounds.x1, bounds.y0), Point::new(bounds.x1, bounds.y1), Point::new(bounds.x0, bounds.y1)].map(|q| inv * q); - let (mut lo, mut hi) = (corners[0], corners[0]); - for q in &corners[1..] { - lo = Point::new(lo.x.min(q.x), lo.y.min(q.y)); - hi = Point::new(hi.x.max(q.x), hi.y.max(q.y)); - } - let (i0, i1) = ((lo.x / cell).floor() as i64 - 1, (hi.x / cell).ceil() as i64 + 1); - let (j0, j1) = ((lo.y / cell).floor() as i64 - 1, (hi.y / cell).ceil() as i64 + 1); - let near = bounds.inflate(cell, cell); - let (mut inside, mut edge) = (vec![], vec![]); - for j in j0..=j1 { - for i in i0..=i1 { - let c = rot * Point::new((i as f64 + 0.5) * cell, (j as f64 + 0.5) * cell); - if !near.contains(c) { - continue; - } - let t = samples.cell_tone(c, cell, rot, sc.channel).clamp(0.0, 1.0); - if t < MIN_TONE { - continue; - } - let place = Affine::translate(c.to_vec2()) * rot; - let dot = shape.path(t, cell).transformed(place); - // Dots wholly inside the outline need no clipping. - let whole = match (outline, dot.control_bounds()) { - (Some((bp, rule)), Some(db)) => [db.origin(), Point::new(db.x1, db.y0), Point::new(db.x1, db.y1), Point::new(db.x0, db.y1)] - .iter() - .all(|q| fill_contains(bp, *rule, *q)), - _ => true, - }; - if whole { inside.push(dot) } else { edge.push(dot) } - } - } - let count = inside.len() + edge.len(); - let mut subs: Vec<_> = inside.into_iter().flat_map(|p| p.subpaths).collect(); - if let Some((bp, rule)) = outline - && !edge.is_empty() - { - let edge = PathData::new(edge.into_iter().flat_map(|p| p.subpaths).collect()); - let clipped = po::boolean(&edge, FillRule::NonZero, &PathData::from_bezpath(bp), *rule, BoolOp::Intersect); - subs.extend(clipped.subpaths); - } else { - subs.extend(edge.into_iter().flat_map(|p| p.subpaths)); - } - (PathData::new(subs), count) -} diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 472ce8ab2..a331144d5 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -5,8 +5,9 @@ use super::{ impl Session { /// A session with no document open, the default preferences and the Selection tool. It also - /// installs the hooks the document crate calls back into: the loader and file reader of placed - /// documents, and where an image's effects draw its fills and strokes, for clicks. + /// installs the hooks the document and effects crates call back into: the loader and file reader + /// of placed documents, where an image's effects draw its fills and strokes, for clicks, and how + /// Vector Halftone's dots are sampled, for exports. pub fn new() -> Self { // Placed documents are read by the native format's loader. vectorcraft_doc::placed_document::set_loader(cmd::place::document::read_document); @@ -19,6 +20,8 @@ impl Session { }); // Clicks find an image's own fills and strokes where their effects draw them. vectorcraft_doc::hit::set_image_paint(vectorcraft_render::effects::image_paint); + // Exports bake Vector Halftone into the dots the renderer samples. + vectorcraft_render::effects::addons::halftone::set_art(vectorcraft_render::addons::halftone::art); // OpenType SVG glyphs are drawn as the SVG importer reads their documents. vectorcraft_text::set_svg_glyph_reader(vectorcraft_svg::glyph_art); Self { diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 2e6d9a678..bf28fa057 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -403,6 +403,9 @@ fn structured_junk() { ("effect.apply", json!({"effect": "distort.diffuseGlow", "params": {"graininess": "x", "glowAmount": 1e308, "clearAmount": -1e308}})), ("effect.apply", json!({"effect": "distort.glass", "params": {"distortion": 1e308, "smoothness": "NaN", "texture": 3, "scaling": -1}})), ("effect.apply", json!({"effect": "distort.oceanRipple", "params": {"rippleSize": [1], "rippleMagnitude": 1e308}})), + ("effect.apply", json!({"effect": "addon.color.halftone", "params": {"frequency": 1e308, "angle": -1e308, "shape": "star", "mode": "rgb"}})), + ("effect.apply", json!({"effect": "addon.color.halftone", "params": {"frequency": 300, "invert": true, "clip": false, "fromArt": true}})), + ("effect.apply", json!({"effect": "addon.color.halftone", "params": {"frequency": -1e308, "mode": "cmyk", "color": "#123456", "target": 3}})), ("effect.apply", json!({"effect": "no.such.effect"})), ("effect.remove", json!({"index": 99})), ("effect.setParams", json!({"index": 0, "params": null})), diff --git a/crates/render/src/addons.rs b/crates/render/src/addons.rs new file mode 100644 index 000000000..8ab1d2b82 --- /dev/null +++ b/crates/render/src/addons.rs @@ -0,0 +1,5 @@ +//! The renderer's part of VectorCraft's built-in add-ons, one module per add-on. +//! +//! - [`halftone`]: Color Effects' Vector Halftone, which samples the art it turns into dots. + +pub mod halftone; diff --git a/crates/render/src/addons/halftone.rs b/crates/render/src/addons/halftone.rs new file mode 100644 index 000000000..9b1cfe709 --- /dev/null +++ b/crates/render/src/addons/halftone.rs @@ -0,0 +1,94 @@ +//! Color Effects' Vector Halftone: the art sampled as the dots read it ([`sample`], which Object › +//! Vector Halftone uses too), and the art of an object with the live effect ([`art`]), which the +//! renderer draws, and the exporters and Expand Appearance write, in its place. +//! +//! The art is kept by the object's content and what it reads from the document (its symbols, +//! patterns, fonts and colour settings, as the live cache finds them): drawing, measuring or +//! exporting an object that hasn't changed takes the dots made before instead of sampling it again. + +use std::sync::{Arc, Mutex, PoisonError}; + +use vectorcraft_doc::{Document, LayerColor, Node, NodeId}; +use vectorcraft_effects::addons::halftone::{Halftone, Samples, art_outline, dot_nodes, split}; +use vectorcraft_geom::Rect; + +use crate::live::Found; +use crate::{RenderOptions, Renderer, region_pixels}; + +/// How many halftone arts are kept, the most recently used first. +const KEPT: usize = 8; + +/// A halftone art kept: the object it was made for, what that read from the document, and the art +/// (`None`: the object draws as it is). +type Kept = (Node, Found, Option); + +static CACHE: Mutex> = Mutex::new(Vec::new()); + +#[cfg(test)] +thread_local! { + /// How many times this thread sampled art for the live effect. + pub(crate) static SAMPLED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// `nodes` from `doc` drawn over `region` at `scale` pixels per point, as the dots read them: on +/// white as RGBA for a mono screen, as CMYK ink amounts for CMYK screens (`mono` false). `None` +/// when the pixels couldn't be made. +pub fn sample(doc: &Document, nodes: Vec, region: Rect, scale: f64, mono: bool, invert: bool) -> Option { + let mut tmp = doc.clone(); + let mut layer = Node::layer(NodeId(u64::MAX), "halftone", LayerColor::Preset(0)); + if let Some(ch) = layer.children_mut() { + *ch = nodes.into_iter().map(Arc::new).collect(); + } + tmp.layers = vec![Arc::new(layer)]; + let mut r = Renderer::new(); + let (w, h) = region_pixels(region, scale); + let px = if mono { + r.render_region(&tmp, region, scale, true).pixels + } else { + r.render_region_inks(&tmp, region, scale, &RenderOptions { skip_templates: true, ..Default::default() }) + }; + Samples::new(px, (w as usize, h as usize), region, scale, mono, invert) +} + +/// The art of `n`, an object with a visible Vector Halftone effect, in `doc` +/// ([`vectorcraft_effects::addons::halftone::halftone_art`]): `n`'s dots as the effect's last +/// visible settings make them from `n` drawn without the effect, over its painted bounds, in a +/// group that takes `n`'s place. `None` without the effect, or when the art has no area or would +/// take too many cells: `n` then draws as it is. +pub fn art(doc: &Document, n: &Node) -> Option { + let ht = Halftone::of(n)?; + let found = Renderer::new().live_found(doc, &Arc::new(n.clone())); + let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(at) = cache.iter().position(|(node, was, _)| was.0 == found.0 && node == n) { + let kept = cache.remove(at); + let art = kept.2.clone(); + cache.insert(0, kept); + return art; + } + // Sampling draws other objects that may have the effect: not under the lock. + drop(cache); + let art = make(doc, n, &ht); + let mut cache = CACHE.lock().unwrap_or_else(PoisonError::into_inner); + cache.insert(0, (n.clone(), found, art.clone())); + cache.truncate(KEPT); + art +} + +/// [`art`] made: `n` sampled and its dots built. +fn make(doc: &Document, n: &Node, ht: &Halftone) -> Option { + #[cfg(test)] + SAMPLED.with(|c| c.set(c.get() + 1)); + let (object, mut group) = split(n); + let bounds = crate::drawn_bounds(doc, &object)?; + let (region, scale) = ht.plan(bounds)?; + let outline = if ht.clip { art_outline(std::slice::from_ref(&object)) } else { None }; + let samples = sample(doc, vec![object], region, scale, ht.mono, ht.invert)?; + let (layers, _) = ht.layers(&samples, bounds, outline.as_ref()); + if let Some(ch) = group.children_mut() { + *ch = dot_nodes(layers, ht.mono, &mut || n.id); + } + Some(group) +} + +#[cfg(test)] +mod tests; diff --git a/crates/render/src/addons/halftone/tests.rs b/crates/render/src/addons/halftone/tests.rs new file mode 100644 index 000000000..0a6f4309b --- /dev/null +++ b/crates/render/src/addons/halftone/tests.rs @@ -0,0 +1,111 @@ +//! Vector Halftone's live art: drawn as dots, following its settings, kept while the art doesn't +//! change, and refused past the caps. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node}; +use vectorcraft_effects::addons::halftone::HALFTONE; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use super::{SAMPLED, art}; +use crate::{RenderOptions, Renderer}; + +/// A document with a mid-grey square of side `side` at (`x`, 10) carrying Vector Halftone with +/// `params`, and the square. +fn doc(x: f64, side: f64, params: Value) -> (Document, Node) { + let mut d = Document::new(200.0, 200.0); + let grey = Paint::solid(Color::rgb(0.5, 0.5, 0.5)); + let mut n = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + side, 10.0 + side)), Appearance::basic(grey, Paint::None, 0.0)); + n.appearance.effects.push(Effect { id: HALFTONE.into(), params, visible: true }); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let n = (*d.layers[0].children().unwrap()[0]).clone(); + (d, n) +} + +fn sampled() -> usize { + SAMPLED.with(std::cell::Cell::get) +} + +/// The square's pixels (from (x, 10), `side` wide), drawn on white. +fn square_pixels(d: &Document, x: f64, side: f64) -> Vec<[u8; 4]> { + let r = Renderer::new().render(d, 200, 200, Affine::IDENTITY, &RenderOptions { background: Some([255; 4]), ..Default::default() }); + let (x0, y0, s) = (x as u32, 10, side as u32); + (y0..y0 + s).flat_map(|y| (x0..x0 + s).map(move |x| (x, y))).map(|(x, y)| r.pixel(x, y)).collect() +} + +#[test] +fn the_effect_draws_the_grey_as_black_dots_on_white() { + // 6 lpi: 12 pt cells, each half black for the mid-grey. + let (d, _) = doc(10.0, 60.0, json!({"frequency": 6})); + let px = square_pixels(&d, 10.0, 60.0); + let share = |f: &dyn Fn(&[u8; 4]) -> bool| px.iter().filter(|p| f(p)).count() as f64 / px.len() as f64; + let (black, white) = (share(&|p| p[0] < 40), share(&|p| p[0] > 215)); + assert!(black > 0.25 && white > 0.25, "black {black}, white {white}"); + // Without the effect, the square is grey all over. + let mut plain = d.clone(); + let id = d.layers[0].children().unwrap()[0].id; + plain.node_mut(id).unwrap().appearance.effects.clear(); + assert!(square_pixels(&plain, 10.0, 60.0).iter().all(|p| (100..=160).contains(&p[0]))); +} + +#[test] +fn the_settings_change_the_dots() { + let look = |params: Value| square_pixels(&doc(20.0, 60.0, params).0, 20.0, 60.0); + let base = look(json!({"frequency": 18})); + for other in [ + json!({"frequency": 9}), + json!({"frequency": 18, "angle": 0}), + json!({"frequency": 18, "shape": "square"}), + json!({"frequency": 18, "invert": true}), + json!({"frequency": 18, "mode": "cmyk"}), + ] { + assert_ne!(look(other.clone()), base, "{other}"); + } + // A red dot colour paints red dots. + let red = look(json!({"frequency": 18, "color": "#ff0000"})); + assert!(red.iter().any(|p| p[0] > 215 && p[1] < 40 && p[2] < 40)); + // The art's own colour under each dot. + let (d, _) = doc(20.0, 60.0, json!({"frequency": 18, "fromArt": true})); + assert!(square_pixels(&d, 20.0, 60.0).iter().any(|p| (100..=160).contains(&p[0]) && p[0] == p[1])); +} + +#[test] +fn unchanged_art_is_not_sampled_again() { + let (d, n) = doc(30.0, 50.0, json!({"frequency": 12})); + let before = sampled(); + let first = art(&d, &n).unwrap(); + assert_eq!(sampled(), before + 1); + // Drawn again, in a copy of the object or of the document, by another renderer: the same dots. + assert_eq!(art(&d.clone(), &n.clone()), Some(first.clone())); + square_pixels(&d, 30.0, 50.0); + square_pixels(&d, 30.0, 50.0); + assert_eq!(sampled(), before + 1); + // Changed settings, or a moved object, are sampled again. + let mut changed = n.clone(); + changed.appearance.effects[0].params = json!({"frequency": 13}); + assert_ne!(art(&d, &changed), Some(first)); + assert_eq!(sampled(), before + 2); + let (moved, m) = doc(31.0, 50.0, json!({"frequency": 12})); + art(&moved, &m).unwrap(); + assert_eq!(sampled(), before + 3); +} + +#[test] +fn too_many_cells_draw_the_object_as_it_is() { + let (d, n) = doc(40.0, 10_000.0, json!({"frequency": 300})); + assert!(art(&d, &n).is_none()); + // Drawn without dots, and without a panic. + assert!(square_pixels(&d, 40.0, 100.0).iter().all(|p| (100..=160).contains(&p[0]))); + let (d, n) = doc(40.0, 0.0, json!({})); + assert!(art(&d, &n).is_none(), "no area"); +} + +#[test] +fn unclipped_dots_reach_past_the_object() { + let (d, n) = doc(50.0, 40.0, json!({"frequency": 9, "invert": true, "clip": false})); + let b = crate::effect_bounds(&d.symbol_boxes(), &n).unwrap(); + let dots = art(&d, &n).unwrap().visual_bounds().unwrap(); + assert!(b.x0 <= dots.x0 && b.y0 <= dots.y0 && b.x1 >= dots.x1 && b.y1 >= dots.y1, "{b:?} holds {dots:?}"); + assert!(dots.x0 < 50.0, "the inverted white around the object gets dots: {dots:?}"); +} diff --git a/crates/render/src/fx/bounds.rs b/crates/render/src/fx/bounds.rs index 41c9eca2b..725a62f92 100644 --- a/crates/render/src/fx/bounds.rs +++ b/crates/render/src/fx/bounds.rs @@ -249,6 +249,11 @@ fn cull_bounds_in(symbols: &SymbolBoxes, n: &Node, after_reshape: bool) -> Optio match &n.kind { // The object and its crop marks. _ if effects::has_crop_marks(n) => effects::crop_marks_art(n).and_then(|art| inner(&art)), + // The object Vector Halftone samples, and as far as its dots reach past it. + _ if effects::addons::halftone::has_halftone(n) => { + let reach = effects::addons::halftone::reach(n); + inner(&effects::addons::halftone::split(n).0).map(|b| b.inflate(reach, reach)) + } // The evaluated art (its fills, strokes and geometry effects) and the raster effects' reach. _ if effects::has_container_appearance(n) => { let art = effects::evaluate_container(Some(symbols.document()), n); diff --git a/crates/render/src/fx/predicates.rs b/crates/render/src/fx/predicates.rs index f54d996a9..7fc85b71a 100644 --- a/crates/render/src/fx/predicates.rs +++ b/crates/render/src/fx/predicates.rs @@ -29,6 +29,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool { || effects::has_transform_art(n) || effects::has_container_appearance(n) || effects::has_crop_marks(n) + || effects::addons::halftone::has_halftone(n) || effects::has_adjustment(n) || effects::has_revolve(n) || effects::has_solid(n) @@ -51,8 +52,9 @@ pub(super) fn evaluates_live(n: &Node) -> bool { /// Can the art [`Renderer::fx_art`] makes for `n` change while `n` stays the same, other than /// through live objects it evaluates? It can when `n` holds type (laid out in the fonts installed), -/// a symbol instance (made from its symbol's art), or an object with a 3D effect or a color -/// adjustment (shaded or recolored through the color settings), in it or in the opacity masks of +/// a symbol instance (made from its symbol's art), an object with a 3D effect or a color +/// adjustment (shaded or recolored through the color settings), or one with Vector Halftone (its +/// dots sampled from everything it draws), in it or in the opacity masks of /// the objects in it. pub(super) fn reads_outside(n: &Node) -> bool { let mut reads = false; @@ -61,6 +63,7 @@ pub(super) fn reads_outside(n: &Node) -> bool { || effects::has_revolve(c) || effects::has_solid(c) || effects::has_adjustment(c) + || effects::addons::halftone::has_halftone(c) || c.extras().mask.as_ref().is_some_and(|m| reads_outside(&m.art)) }); reads diff --git a/crates/render/src/fx/renderer.rs b/crates/render/src/fx/renderer.rs index 53a17b25d..b053f3120 100644 --- a/crates/render/src/fx/renderer.rs +++ b/crates/render/src/fx/renderer.rs @@ -370,8 +370,13 @@ impl Renderer { let kept = cache && self.fx_arts.get(&(Arc::as_ptr(a) as usize), self.stamp).is_some_and(|e| Arc::ptr_eq(&e.1, &art)); let outer = self.made_again; self.made_again = outer || !kept; - // With Crop Marks, the object inside the art paints its own raster effects. - let rfx = if effects::has_crop_marks(a) { vec![] } else { effects::raster_effects(&a.appearance.effects) }; + // With Crop Marks, the object inside the art paints its own raster effects; Vector + // Halftone samples them with the object. + let rfx = if effects::has_crop_marks(a) || effects::addons::halftone::has_halftone(a) { + vec![] + } else { + effects::raster_effects(&a.appearance.effects) + }; match cull_bounds(f.symbols, &art) { Some(reach) if !f.opts.outline && !rfx.is_empty() => { // Feather, Inner Glow and Rasterize with Create Clipping Mask clip to the art's outline. @@ -426,6 +431,7 @@ impl Renderer { }; let container = is_container(a); let art = match effects::crop_marks_art(a) + .or_else(|| crate::addons::halftone::art(doc, a)) .or_else(|| effects::revolve_art(a)) .or_else(|| effects::solid_art(a)) .or_else(|| effects::reshape(Some(doc), a, symbol.as_ref())) @@ -436,7 +442,9 @@ impl Renderer { None => effects::outline_art(Some(doc), a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), }; let art = if effects::has_revolve(a) || effects::has_solid(a) { effects::evaluate_container(Some(doc), &art).unwrap_or(art) } else { art }; - let mut art = self.adjusted_art(art, effects::color_map(&a.appearance.effects).as_ref()); + // Vector Halftone samples the object with its colour adjustments: its dots keep their inks. + let outer = if effects::addons::halftone::has_halftone(a) { None } else { effects::color_map(&a.appearance.effects) }; + let mut art = self.adjusted_art(art, outer.as_ref()); // A path's geometry effects still apply when it is drawn. let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }); art.appearance.effects.retain(|e| path && effects::is_geometry(&e.id)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 7de2aa0ab..9343d1c2f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -6,6 +6,7 @@ //! gradients, images and text (via `vectorcraft-text` glyph outlines). #![forbid(unsafe_code)] +pub mod addons; mod brush_fx; mod changed; mod dither; diff --git a/crates/ui-egui/src/addons/color.rs b/crates/ui-egui/src/addons/color.rs index 7c27f5af2..e338137ce 100644 --- a/crates/ui-egui/src/addons/color.rs +++ b/crates/ui-egui/src/addons/color.rs @@ -1,7 +1,8 @@ //! Color Effects' panel and submenu (Object › Plug-ins › Color Effects). The panel picks a colour //! adjustment and its values, then adds it to the selection as a live effect or applies it as a //! filter, into the colours themselves (`addon.color.filter`). The submenu applies the add-on's -//! live effects (Effect › Color Effects has them too) and opens the panel. +//! live effects, Vector Halftone among them (Effect › Color Effects has them too), and opens the +//! panel. use egui::Ui; use serde_json::{Map, Value, json}; @@ -75,6 +76,7 @@ pub fn menu() -> Vec { Item::Cmd("Exposure…", "effect.dialog", json!({ "effect": "addon.color.exposure" })), Item::Cmd("Desaturate", "effect.apply", json!({ "effect": "addon.color.desaturate" })), Item::Cmd("Invert Colors", "effect.apply", json!({ "effect": "addon.color.invert" })), + Item::Cmd("Vector Halftone…", "effect.dialog", json!({ "effect": "addon.color.halftone" })), Item::Sep, Item::Sub( "Select by Color Kind", diff --git a/crates/ui-egui/src/addons/color/tests.rs b/crates/ui-egui/src/addons/color/tests.rs index e05cacbfb..a44f1d985 100644 --- a/crates/ui-egui/src/addons/color/tests.rs +++ b/crates/ui-egui/src/addons/color/tests.rs @@ -22,7 +22,7 @@ fn the_menus_list_the_effects_and_open_the_panel() { let at = |path: &[&str], label: &str| entries.iter().find(|e| e.path == path && e.label == label); let plugins = ["Object", "Plug-ins", "Color Effects"]; let item = at(&plugins, "Color Effects Panel").expect("listed"); - for label in ["Exposure…", "Desaturate", "Invert Colors"] { + for label in ["Exposure…", "Desaturate", "Invert Colors", "Vector Halftone…"] { assert!(at(&plugins, label).is_some(), "Object › Plug-ins › Color Effects › {label}"); assert!(at(&["Effect", "Color Effects"], label).is_some(), "Effect › Color Effects › {label}"); } @@ -59,3 +59,26 @@ fn the_panel_shows_the_values_and_its_buttons() { app.run("select.none", json!({})).unwrap(); assert!(painted_text(&mut app, show).contains("No selection")); } + +#[test] +fn the_halftone_effect_dialog_has_the_command_fields_and_edits_the_effect() { + let mut app = app(); + let text = |app: &mut VectorcraftApp| painted_text(app, |app, ui| crate::dialogs::show(app, ui.ctx())); + app.run("effect.dialog", json!({"effect": "addon.color.halftone"})).unwrap(); + let shown = text(&mut app); + for s in ["Vector Halftone", "Shape", "Frequency", "Angle", "Screens", "Dot Color from Art", "Color", "Invert", "Clip to Art", "Preview"] { + assert!(shown.lines().any(|l| l == s), "{s} in {shown}"); + } + assert!(!shown.lines().any(|l| l == "Keep Original"), "the effect keeps its object"); + assert!(app.session.in_interaction(), "the preview runs"); + // The art's colours instead of one: no colour field. + app.ui.dialog.as_mut().unwrap().fields.insert("fromArt".into(), json!(true)); + assert!(!text(&mut app).lines().any(|l| l == "Color")); + crate::dialogs::confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let fx = st.doc.node(st.selection.objects[0]).unwrap().appearance.effects.clone(); + assert_eq!((fx.len(), fx[0].id.as_str(), &fx[0].params["fromArt"]), (1, "addon.color.halftone", &json!(true))); + // The Appearance panel reopens it with its values. + app.run("effect.dialog", json!({"effect": "addon.color.halftone", "index": 0})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields["fromArt"], json!(true)); +} diff --git a/crates/ui-egui/src/dialogs/effect.rs b/crates/ui-egui/src/dialogs/effect.rs index 9e00f0927..d54fe8296 100644 --- a/crates/ui-egui/src/dialogs/effect.rs +++ b/crates/ui-egui/src/dialogs/effect.rs @@ -163,6 +163,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { Some(plugin) => form::schema_fields(app, ui, d, plugin.manifest()), // The Color Effects add-on's take the plain form of their parameters. None if vectorcraft_effects::is_adjustment(&id) && !vectorcraft_effects::addons::color::is_color_effect(&id) => adjust_fields(ui, d), + None if id == vectorcraft_effects::addons::halftone::HALFTONE => { + super::halftone::screen_fields(ui, d); + values_changed(d) + } None if id == GLASS => glass_fields(app, ui, d, GLASS), None if id == TEXTURIZER => glass_fields(app, ui, d, TEXTURIZER), None if id == TRANSFORM => transform_fields(ui, d, app.session.general_unit()), diff --git a/crates/ui-egui/src/dialogs/halftone.rs b/crates/ui-egui/src/dialogs/halftone.rs index 02a1364bf..653b16fc9 100644 --- a/crates/ui-egui/src/dialogs/halftone.rs +++ b/crates/ui-egui/src/dialogs/halftone.rs @@ -3,7 +3,8 @@ //! the preview as one undo step (`object.vectorHalftone`). //! //! Fields: `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal` (the -//! command's parameters) and `preview`. +//! command's parameters) and `preview`. The live effect's dialog shows the same fields but Keep +//! Original ([`screen_fields`]). use serde_json::{Value, json}; use vectorcraft_color::Color; @@ -29,6 +30,17 @@ pub fn fields() -> Value { } fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + screen_fields(ui, d); + form::check(ui, d, "keepOriginal", tl!("Keep Original")); + let p = form::params(d); + form::preview(app, ui, d, "Vector Halftone", CMD, p); + false +} + +/// The fields Object › Vector Halftone and the live effect (Effect › Color Effects › Vector +/// Halftone…) share: the dot shape, the screen frequency and angle, mono or CMYK screens, the dot +/// colour (or, with the effect's `fromArt`, the art's), Invert and Clip to Art. +pub(super) fn screen_fields(ui: &mut egui::Ui, d: &mut Dialog) { let shapes = [("circle", tl!("Circle")), ("ellipse", tl!("Ellipse")), ("square", tl!("Square")), ("diamond", tl!("Diamond")), ("line", tl!("Line"))]; form::choice(ui, d, "shape", tl!("Shape"), (LABEL_W, 140.0), &shapes); @@ -36,17 +48,19 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { form::slider_w(ui, d, ("angle", tl!("Angle"), LABEL_W), -90.0..=90.0, "°", &|x| c32(&Color::gray(0.2 + 0.6 * x))); form::choice(ui, d, "mode", tl!("Screens"), (LABEL_W, 140.0), &[("mono", tl!("Mono")), ("cmyk", tl!("CMYK"))]); if d.str("mode") != "cmyk" { - widgets::label_row(ui, tl!("Color"), LABEL_W, |ui| { - form::text(ui, d, "color", 120.0); - }); + if d.fields.contains_key("fromArt") { + form::check(ui, d, "fromArt", tl!("Dot Color from Art")); + } + if !d.bool("fromArt") { + widgets::label_row(ui, tl!("Color"), LABEL_W, |ui| { + form::text(ui, d, "color", 120.0); + }); + } } ui.add_space(4.0); - for (key, label) in [("invert", tl!("Invert")), ("clip", tl!("Clip to Art")), ("keepOriginal", tl!("Keep Original"))] { + for (key, label) in [("invert", tl!("Invert")), ("clip", tl!("Clip to Art"))] { form::check(ui, d, key, label); } - let p = form::params(d); - form::preview(app, ui, d, "Vector Halftone", CMD, p); - false } fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 3a715a29d..bb7116720 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5745,3 +5745,6 @@ anchor Points: Точки: CMYK Images CMYK изображения Grayscale Images Изображения в нюанси на сивото @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` трябва да е spotColors, colorEffects, rgbImages, cmykImages или grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Цвят на точките от илюстрацията diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index 9ecbd9ec5..1a1ce4c03 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -5846,3 +5846,6 @@ anchor Points: Punts: CMYK Images Imatges CMYK Grayscale Images Imatges en escala de grisos @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` ha de ser spotColors, colorEffects, rgbImages, cmykImages o grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Color dels punts de la il·lustració diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5e5e459ea..f92459d60 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5855,3 +5855,6 @@ anchor Points: Body: CMYK Images Obrazy CMYK Grayscale Images Obrazy ve stupních šedi @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` musí být spotColors, colorEffects, rgbImages, cmykImages nebo grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Barva bodů podle kresby diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 52eac9d03..e735ddd99 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5760,3 +5760,6 @@ anchor Points: Punkter: CMYK Images CMYK-billeder Grayscale Images Gråtonebilleder @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` skal være spotColors, colorEffects, rgbImages, cmykImages eller grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Prikfarve fra grafik diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 74bfb8ad1..9a41a838c 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -5440,3 +5440,6 @@ anchor Points: Punkte: CMYK Images CMYK-Bilder Grayscale Images Graustufenbilder @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` muss spotColors, colorEffects, rgbImages, cmykImages oder grayImages sein + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Punktfarbe aus Grafik diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index fc31f0850..fed8acea2 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5807,3 +5807,6 @@ anchor Points: Σημεία: CMYK Images Εικόνες CMYK Grayscale Images Εικόνες σε κλίμακα του γκρι @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages Το `kind` πρέπει να είναι spotColors, colorEffects, rgbImages, cmykImages ή grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Χρώμα κουκκίδων από το γραφικό diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 0214dd9c6..2956f03ab 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5798,3 +5798,6 @@ anchor Points: Puntos: CMYK Images Imágenes CMYK Grayscale Images Imágenes en escala de grises @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` debe ser spotColors, colorEffects, rgbImages, cmykImages o grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Color de puntos de la ilustración diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index b9be325c6..5942d952e 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5886,3 +5886,6 @@ anchor Points: Pisteet: CMYK Images CMYK-kuvat Grayscale Images Harmaasävykuvat @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` on oltava spotColors, colorEffects, rgbImages, cmykImages tai grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Pisteiden väri kuvituksesta diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ad57031ed..9d12a9422 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5854,3 +5854,6 @@ anchor Points: Points : CMYK Images Images CMJN Grayscale Images Images en niveaux de gris @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` doit être spotColors, colorEffects, rgbImages, cmykImages ou grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Couleur des points d’après l’illustration diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 38e97c940..dbb6993ce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5842,3 +5842,6 @@ anchor Points: Pontok: CMYK Images CMYK-képek Grayscale Images Szürkeárnyalatos képek @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages A `kind` értéke spotColors, colorEffects, rgbImages, cmykImages vagy grayImages lehet + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Pontszín a grafikából diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 8d9fb5cfb..084f32a74 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5760,3 +5760,6 @@ anchor Points: Titik: CMYK Images Gambar CMYK Grayscale Images Gambar Skala Abu-abu @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` harus spotColors, colorEffects, rgbImages, cmykImages, atau grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Warna titik dari karya seni diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 0baee70c0..ec98d42ba 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -5474,3 +5474,6 @@ anchor Points: Punti: CMYK Images Immagini CMYK Grayscale Images Immagini in scala di grigi @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` deve essere spotColors, colorEffects, rgbImages, cmykImages o grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Colore punti dalla grafica diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 583b7ae42..41e730518 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5543,3 +5543,6 @@ anchor Points: ポイント: CMYK Images CMYK 画像 Grayscale Images グレースケール画像 @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` は spotColors、colorEffects、rgbImages、cmykImages、grayImages のいずれかにしてください + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art ドットの色をアートから取得 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 5cd927a0f..545958500 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5845,3 +5845,6 @@ anchor Points: წერტილები: CMYK Images CMYK გამოსახულებები Grayscale Images ნაცრისფერის გრადაციის გამოსახულებები @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` უნდა იყოს spotColors, colorEffects, rgbImages, cmykImages ან grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art წერტილების ფერი ნახატიდან diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 53bc38d4a..6ba3b91b2 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -5437,3 +5437,6 @@ anchor Points: 포인트: CMYK Images CMYK 이미지 Grayscale Images 회색 음영 이미지 @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind`는 spotColors, colorEffects, rgbImages, cmykImages 또는 grayImages여야 합니다 + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art 아트워크에서 점 색상 가져오기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 535a5d6be..6b78604c6 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5804,3 +5804,6 @@ anchor Points: Punten: CMYK Images CMYK-afbeeldingen Grayscale Images Grijswaardenafbeeldingen @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` moet spotColors, colorEffects, rgbImages, cmykImages of grayImages zijn + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Puntkleur uit artwork diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 99f362151..cacdfbe82 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5710,3 +5710,6 @@ anchor Points: Punkty: CMYK Images Obrazy CMYK Grayscale Images Obrazy w skali szarości @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` musi mieć wartość spotColors, colorEffects, rgbImages, cmykImages lub grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Kolor kropek z kompozycji diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dc816dd38..1418bc61b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5773,3 +5773,6 @@ anchor Points: Pontos: CMYK Images Imagens CMYK Grayscale Images Imagens em escala de cinza @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` deve ser spotColors, colorEffects, rgbImages, cmykImages ou grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Cor dos Pontos da Arte diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 79d270ba7..ee4bb67aa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -5833,3 +5833,6 @@ anchor Points: Точки: CMYK Images Изображения CMYK Grayscale Images Изображения в оттенках серого @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` должен быть spotColors, colorEffects, rgbImages, cmykImages или grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Цвет точек из рисунка diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 24bded7f4..3864fa5e2 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5911,3 +5911,6 @@ shape Open Öppen CMYK Images CMYK-bilder Grayscale Images Gråskalebilder @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` måste vara spotColors, colorEffects, rgbImages, cmykImages eller grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Punktfärg från objekt diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b94cb137e..051726501 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -5813,3 +5813,6 @@ anchor Points: Noktalar: CMYK Images CMYK Görüntüler Grayscale Images Gri Tonlamalı Görüntüler @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` değeri spotColors, colorEffects, rgbImages, cmykImages veya grayImages olmalıdır + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Nokta Rengini Sanattan Al diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 4882ba94c..3d87bbb52 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -5454,3 +5454,6 @@ anchor Points: Точки: CMYK Images Зображення CMYK Grayscale Images Зображення у відтінках сірого @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` має бути spotColors, colorEffects, rgbImages, cmykImages або grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Колір точок із малюнка diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 311008cab..51092dc6a 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -5456,3 +5456,6 @@ anchor Points: Điểm: CMYK Images Ảnh CMYK Grayscale Images Ảnh thang độ xám @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` phải là spotColors, colorEffects, rgbImages, cmykImages hoặc grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art Màu chấm theo tác phẩm diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index a8eb60373..5085e1a31 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5697,3 +5697,6 @@ anchor Points: 点: CMYK Images CMYK 图像 Grayscale Images 灰度图像 @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` 必须是 spotColors、colorEffects、rgbImages、cmykImages 或 grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art 网点颜色取自图稿 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 3c19fbd98..6e0c66dd6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -5493,3 +5493,6 @@ anchor Points: 點: CMYK Images CMYK 影像 Grayscale Images 灰階影像 @msg `kind` must be spotColors, colorEffects, rgbImages, cmykImages or grayImages `kind` 必須是 spotColors、colorEffects、rgbImages、cmykImages 或 grayImages + +# Color Effects add-on: Vector Halftone (Effect › Color Effects). + Dot Color from Art 網點顏色取自圖稿 diff --git a/docs/mcp.md b/docs/mcp.md index e92143b41..8f6c84a94 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5904,3 +5904,35 @@ with a spot colour swatch, solid or in a gradient's stops), `colorEffects` (carr on the object or on one of its fills or strokes; a group carrying one is selected whole), `rgbImages`, `cmykImages` or `grayImages` (images whose pixels are RGB, CMYK, or grayscale or 1-bit, read from the image's header). It returns the `count` selected. + +### Vector Halftone (live) + +`addon.color.halftone` (Effect › Color Effects › Vector Halftone…, also in Object › Plug-ins › Color Effects) draws its +object as vector halftone dots sized by the object's tone, the dots `object.vectorHalftone` makes, and keeps them in step +as the art or the settings change. It goes on through `effect.apply` like any other effect; its dialog has the fields of +Object › Vector Halftone without Keep Original, and the Appearance panel reopens it. + +| Parameter | Values (default) | Does | +|---|---|---| +| `shape` | `circle`, `ellipse`, `square`, `diamond`, `line` (`circle`) | The dot shape; lines join along a row | +| `frequency` | lines per inch 1…300 (20) | Cells per inch of the screen | +| `angle` | degrees (45) | The screen angle; CMYK: the black screen's, cyan −30°, magenta +30°, yellow −45° from it | +| `mode` | `mono`, `cmyk` (`mono`) | One screen following lightness, or four ink screens following the art's ink amounts, multiplied over each other | +| `color` | `#rrggbb` (`#000000`) | The mono dot colour | +| `fromArt` | bool (false) | Mono: each dot takes the art's colour under it (one path per colour) | +| `invert` | bool (false) | Dots for light tones | +| `clip` | bool (true) | The dots clipped to the art's outline | + +The object is sampled as it draws with its other effects (its own transparency and opacity mask apply to the dots). +Each screen takes at most 100000 cells: past that, or for art without area, the object draws as it is. The dots are kept +while the object and what it reads from the document (symbols, patterns, fonts, colour settings) stay the same, so +redrawing, measuring or exporting unchanged art doesn't sample it again. Effect › Expand Appearance turns the object +into the dots, a group of one path per ink (per colour with `fromArt`), and the SVG, PDF, EPS and AI exporters write +those paths. + +```json +{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"addon.color.halftone","params":{"frequency":24,"shape":"diamond","angle":30}}}} +``` + +Not yet: stochastic (FM) and radial patterns, a dot-gain curve, per-screen separation settings, and type or symbols as +dots. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e6e5bd60d..b31bd92e0 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 (Color Effects add-on: live Vector Halftone; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1269,11 +1269,21 @@ identity), `cmd::addons::color::tests` in `vectorcraft-engine` (drawn live witho undo step, with any adjustment and its params; bad requests refused) and `addons::color::tests` in `vectorcraft-ui-egui` (the menus and the panel). Select by Color Kind (`addon.color.select`) selects the objects that use spot colours, carry a colour adjustment, or are RGB, CMYK or grayscale images -(`select_by_color_kind_finds_spot_colours_effects_and_image_models`). Commands: `docs/mcp.md` › Color Effects add-on. +(`select_by_color_kind_finds_spot_colours_effects_and_image_models`). Vector Halftone (`addon.color.halftone`) draws the +object as the dots Object › Vector Halftone makes, live: the screens and dots are shared +(`vectorcraft_effects::addons::halftone`), the renderer samples the art and keeps the dots while it doesn't change +(`vectorcraft_render::addons::halftone`), and Expand Appearance and the SVG, PDF, EPS and AI exporters write the paths. +Tests: `addons::halftone` in `vectorcraft-effects` (settings, tone, caps), `addons::halftone::tests` in +`vectorcraft-render` (drawn as dots, settings change them, unchanged art not sampled again, too many cells draw the +object as it is), `expand_appearance_gives_the_dots_object_vector_halftone_makes`, +`exports_write_the_dots_as_paths` and `a_halftone_past_the_caps_leaves_the_object_as_it_is` in `vectorcraft-engine`, +and `the_halftone_effect_dialog_has_the_command_fields_and_edits_the_effect` in `vectorcraft-ui-egui`. Commands: +`docs/mcp.md` › Color Effects add-on. ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates 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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 612c955c1..ffd993efb 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Path Precision Tools add-on: the Parametric Shape Tool and its panel; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Color Effects add-on: live Vector Halftone; scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x) ## Feature submission evidence @@ -329,7 +329,7 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po | Smart Pen add-on (VectorCraft addition): Object › Plug-ins › Smart Pen selects the Smart Pen Tool, which the All Tools drawer lists under its own heading but neither toolbar shows as shipped (no shortcut), and opens the Smart Pen panel (next segment straight or curved; Remove Points Keep Shape, Make Smooth, Make Corner, Retract Handles, Close or Join). The tool draws (click: straight or curved segment, a curve after a straight one tangent; drag: smooth anchor, Alt breaks the handle, Shift 45°, Space moves it; Tab switches a segment; rubber band; close, join, continue) and edits without switching tools (click selects a point, drag moves it, Alt-click removes it keeping the shape, Shift+Alt-click deletes it, double-click toggles smooth/corner; handle drags with Shift and Alt; segment drags reshape, Alt-click adds a point); an Alt-drag away from paths selects the points inside a marquee | done | `addon.pen.*`, `crates/tools/src/addons/smart_pen.rs`, `crates/ui-egui/src/addons/pen.rs` and their tests; not an Illustrator feature, so it is not scored | | Path Precision Tools add-on (VectorCraft addition): Object › Plug-ins › Path Precision Tools selects its three tools (Anchor Eraser Brush, Extend Path, Measure & Inspect), which the All Tools drawer lists under their own heading but neither toolbar shows as shipped, opens the Anchor Inspector panel (the selected anchor's X/Y and handle lengths and angles as fields; Make Smooth, Make Corner, Equalize Handles, Remove Points Keep Shape, Add Points, the three ways to close) and runs Add Points at / Split at Crossings; a double-click on the brush or Extend Path tool opens its options | done | `addon.pathtools.*`, `crates/tools/src/addons/`, `crates/ui-egui/src/addons/pathtools.rs` and their tests; not an Illustrator feature, so it is not scored | | Artwork Cleanup add-on (VectorCraft addition): Object › Plug-ins › Artwork Cleanup opens its panel, which checks the selection or the document for 16 kinds of issue, lists them with counts, and shows or fixes the picked rows or all of them, each fix one undo step; Simplify Anchors (Keep Shape), Rejoin Path Pieces and Deep Ungroup open their options first | done | `addon.cleanup.*`, `crates/ui-egui/src/addons/cleanup.rs` and its tests; not an Illustrator feature, so it is not scored | -| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | +| Color Effects add-on (VectorCraft addition): live Exposure, Desaturate and Invert Colors in Effect › Color Effects and Object › Plug-ins › Color Effects; its panel picks a colour adjustment and its values and adds it as a live effect or applies it as a filter, into the colours, as one undo step; Select by Color Kind selects the objects that use spot colours, carry colour effects, or are RGB, CMYK or grayscale images; Vector Halftone draws an object as halftone dots live (the dots Object › Vector Halftone makes), which Expand Appearance and the exporters write as paths | done | `addon.color.*`, `crates/ui-egui/src/addons/color.rs` and its tests; not an Illustrator feature, so it is not scored | ## Look and feel @@ -370,6 +370,7 @@ separate. Scores and hours unchanged. [Behavior and command details](development | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Color Effects add-on: live Vector Halftone; scores and estimates unchanged | | 2026-10-30 | minor | Path Precision Tools add-on: the Parametric Shape Tool (ten re-editable shape kinds), its panel and Convert to Parametric Shape; scores and estimates unchanged | | 2026-10-30 | minor | Smart Pen add-on: an Alt-drag away from paths selects points with a marquee; scores and estimates unchanged | | 2026-10-30 | minor | Color Effects add-on: Select by Color Kind; scores and estimates unchanged |