diff --git a/ROADMAP.md b/ROADMAP.md index 64086b0f..dc901ac9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -181,7 +181,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale) | Type core | 9 | 78% | composer/hyphenation options, Optical Margin Alignment, hidden characters | 15–20 | | Type advanced | 4 | 35% | vertical type/CJK, tab leaders, spell check (open dictionary), Touch Type, Retype | 26–35 | | Symbols, blends, envelopes, Repeat, perspective | 5 | 75% | symbol libraries (original), dynamic symbols, perspective edge cases | 8–12 | -| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish | 6–10 | +| Image Trace, graphs, image tools | 3 | 70% | graph Design/Column/Marker, Create Object Mosaic, Crop Image polish (Vector Halftone is done) | 6–10 | | Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 | | View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, Snap to Pixel/Glyph (print tiling is done) | 9–14 | | Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 | diff --git a/crates/engine/src/cmd/halftone.rs b/crates/engine/src/cmd/halftone.rs new file mode 100644 index 00000000..4cf9e4f1 --- /dev/null +++ b/crates/engine/src/cmd/halftone.rs @@ -0,0 +1,298 @@ +//! Object → Vector Halftone: the selected art (vectors, type, images) as vector dots, lines or +//! shapes sized by its tone. +//! +//! 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; + +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 super::edit::selected_roots; +use super::menucmds::isolated_doc; +use super::*; + +pub fn specs() -> Vec { + vec![cmd!( + "object.vectorHalftone", + "Vector Halftone…", + ["Object"], + None, + "{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\"), invert?: bool (dots for light tones), clip?: true (clip the dots to the art's outline), keepOriginal?: false} the selection as vector halftone dots sized by its tone, a group of one path per screen (CMYK screens multiply), as one undo step → {id, dots}", + has_selection, + halftone + )] +} + +/// 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") { + Some(v) => DotShape::parse(v).ok_or_else(|| bad(C, format!("unknown shape `{v}` (circle|ellipse|square|diamond|line)")))?, + None => DotShape::Circle, + }; + let frequency = f64_or(p, "frequency", 20.0); + if !(1.0..=300.0).contains(&frequency) { + return Err(bad(C, "frequency must be between 1 and 300 lines per inch")); + } + let angle = f64_or(p, "angle", 45.0); + if !angle.is_finite() { + return Err(bad(C, "invalid angle")); + } + let mono = match str_param(p, "mode").unwrap_or("mono") { + "mono" => true, + "cmyk" => false, + other => return Err(bad(C, format!("unknown mode `{other}` (mono|cmyk)"))), + }; + let color = match p.get("color") { + Some(v) => color_value(v).ok_or_else(|| bad(C, "invalid color"))?, + 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 roots = selected_roots(s)?; + let st = s.doc()?; + let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "the selection has no bounds"))?; + 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 nodes: Vec = roots + .iter() + .filter_map(|id| st.doc.node(*id).cloned()) + .map(|mut n| { + n.visible = true; + 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 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() }) + }; + 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)); + } + } + 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 gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.name = Some("Vector Halftone".into()); + let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; + d.insert(par, idx + 1, g)?; + if !keep { + for r in &roots { + d.remove(*r)?; + } + } + sel.set([gid]); + Ok(gid) + })?; + Ok(json!({ "id": id.0, "dots": dots })) +} + +/// 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/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4d4f3108..5622ba5c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -27,6 +27,7 @@ mod fonts; pub(crate) mod freeform; pub(crate) mod gradient; pub(crate) mod graph; +mod halftone; pub mod help; mod layer; pub mod links; @@ -248,6 +249,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(printtiling::specs()); v.extend(plugin::specs()); v.extend(cut::specs()); + v.extend(halftone::specs()); v }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 708bb7a3..5b9f9160 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1178,6 +1178,8 @@ mod tests_gradient; #[cfg(test)] mod tests_gradpanel; #[cfg(test)] +mod tests_halftone; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_knockout; diff --git a/crates/engine/src/tests_halftone.rs b/crates/engine/src/tests_halftone.rs new file mode 100644 index 00000000..25ccf4cc --- /dev/null +++ b/crates/engine/src/tests_halftone.rs @@ -0,0 +1,53 @@ +//! Object → Vector Halftone. + +use serde_json::json; + +use super::*; +use crate::tests_adjust::{fill_hex, node, red_rect, session}; + +#[test] +fn halftone_turns_art_into_dots_by_tone() { + let mut s = session(); + let id = red_rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#000000"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = s.execute("object.vectorHalftone", &json!({"frequency": 18})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert!(s.doc().unwrap().doc.node(id).is_none(), "the original is replaced"); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let dots = r["dots"].as_u64().unwrap(); + // 60 pt at 18 lpi (4 pt cells) turned 45°: about 225 cells, all black. + assert!((180..=320).contains(&dots), "{dots}"); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 1); + let (pd, b) = (ch[0].path_data().unwrap(), ch[0].geometric_bounds().unwrap()); + assert!(pd.subpaths.len() as u64 >= dots / 2); + // Clipped to the art. + assert!(b.x0 >= 19.9 && b.y0 >= 19.9 && b.x1 <= 80.1 && b.y1 <= 80.1, "{b:?}"); +} + +#[test] +fn halftone_options() { + let mut s = session(); + let id = red_rect(&mut s); + // Red art in CMYK: magenta and yellow screens, no cyan or black. + let r = s.execute("object.vectorHalftone", &json!({"mode": "cmyk", "shape": "square", "keepOriginal": true})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).is_some(), "the original is kept"); + let g = node(&s, NodeId(r["id"].as_u64().unwrap())); + let inks: Vec = g.children().unwrap().iter().map(|c| fill_hex(c)).collect(); + assert_eq!(inks.len(), 2, "{inks:?}"); + assert!(g.children().unwrap().iter().all(|c| c.blend == vectorcraft_color::BlendMode::Multiply)); + // Light art gets no dots; inverted, it does. + let mut s = session(); + let id = red_rect(&mut s); + s.execute("paint.setFill", &json!({"color": "#ffffff"})).unwrap(); + assert!(s.execute("object.vectorHalftone", &json!({})).is_err()); + s.execute("select.set", &json!({"ids": [id.0]})).unwrap(); + assert!(s.execute("object.vectorHalftone", &json!({"invert": true, "shape": "line"})).is_ok()); + // Bad input is refused. + for bad in [json!({"frequency": 0}), json!({"shape": "star"}), json!({"mode": "rgb"}), json!({"color": "nope"})] { + let mut s = session(); + red_rect(&mut s); + assert!(s.execute("object.vectorHalftone", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/ui-egui/src/dialogs/halftone.rs b/crates/ui-egui/src/dialogs/halftone.rs new file mode 100644 index 00000000..c28cd6b1 --- /dev/null +++ b/crates/ui-egui/src/dialogs/halftone.rs @@ -0,0 +1,53 @@ +//! Object → Vector Halftone: the dot shape, the screen frequency and angle, mono (in one colour) +//! or CMYK screens, Invert, Clip to Art and Keep Original, previewed live on the canvas; OK keeps +//! the preview as one undo step (`object.vectorHalftone`). +//! +//! Fields: `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal` (the +//! command's parameters) and `preview`. + +use serde_json::{Value, json}; +use vectorcraft_color::Color; + +use super::{DialogSpec, form}; +use crate::panels::c32; +use crate::state::Dialog; +use crate::{VectorcraftApp, widgets}; + +/// The dialog kind of Vector Halftone. +pub const KIND: &str = "vectorHalftone"; + +const CMD: &str = "object.vectorHalftone"; + +/// Width of the label column. +const LABEL_W: f32 = 84.0; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading: |_| "Vector Halftone".into(), body, confirm, preview: true, ..DialogSpec::FORM }; + +/// The dialog's fields as it opens: the command's defaults, previewed. +pub fn fields() -> Value { + json!({"shape": "circle", "frequency": 20, "angle": 45, "mode": "mono", "color": "#000000", "invert": false, "clip": true, "keepOriginal": false, "preview": true}) +} + +fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let shapes = [("circle", "Circle"), ("ellipse", "Ellipse"), ("square", "Square"), ("diamond", "Diamond"), ("line", "Line")]; + form::choice(ui, d, "shape", "Shape", (LABEL_W, 140.0), &shapes); + form::slider_w(ui, d, ("frequency", "Frequency", LABEL_W), 1.0..=150.0, " lpi", &|x| c32(&Color::gray(1.0 - x))); + form::slider_w(ui, d, ("angle", "Angle", LABEL_W), -90.0..=90.0, "°", &|x| c32(&Color::gray(0.2 + 0.6 * x))); + form::choice(ui, d, "mode", "Screens", (LABEL_W, 140.0), &[("mono", "Mono"), ("cmyk", "CMYK")]); + if d.str("mode") != "cmyk" { + widgets::label_row(ui, "Color", LABEL_W, |ui| { + form::text(ui, d, "color", 120.0); + }); + } + ui.add_space(4.0); + for (key, label) in [("invert", "Invert"), ("clip", "Clip to Art"), ("keepOriginal", "Keep Original")] { + 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 { + form::commit_preview(app, CMD, form::params(d)) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 8f049f8a..5abff41a 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -29,6 +29,7 @@ pub mod flattener_presets; mod form; mod gradient_stop; pub mod graphic_style_options; +pub mod halftone; pub mod import_pdf; pub mod missing_links; pub mod new_color_group; @@ -247,6 +248,7 @@ registry! { PrintPreset: [print::PRESET_KIND] => print::PRESET_SPEC, PrintPresets: [print_presets::KIND] => print_presets::SPEC, Plugin: [plugin::KIND] => plugin::SPEC, + VectorHalftone: [halftone::KIND] => halftone::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 4b4329b8..c1743c07 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -361,7 +361,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "ui.menuDialog", "Menu Dialog", "", - "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", + "{command: object.move|object.rotate|object.scale|object.reflect|object.shear|object.transformEach|path.average|object.path.offsetPath|object.path.simplify|object.path.splitIntoGrid|object.vectorHalftone} open the dialog that command's menu item opens (dialog kind: move, rotate, scale, reflect, shear, transformEach, …, vectorHalftone; Scale and Transform Each have `corners` and `strokes`, from the preferences, which OK updates)", ), ( "ui.widthPointEdit", @@ -1414,6 +1414,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { "object.createObjectMosaic", json!({"columns": 10, "rows": 10, "spacingX": 0, "spacingY": 0, "gray": false, "deleteRaster": false}), ), + c("Vector Halftone…", "object.vectorHalftone"), c("Create Trim Marks", "object.createTrimMarks"), c("Flatten Transparency…", "ui.flattenTransparencyDialog"), Sep, @@ -1985,6 +1986,7 @@ fn menu_dialog(id: &str) -> Option<(&'static str, Value)> { "object.path.offsetPath" => ("offsetPath", json!({"offset": "10 pt", "joins": "miter", "miterLimit": 4})), "object.path.simplify" => ("simplify", json!({"tolerance": "1 pt"})), "object.path.splitIntoGrid" => ("splitIntoGrid", json!({"rows": 2, "columns": 2, "gutter": "12 pt"})), + "object.vectorHalftone" => (crate::dialogs::halftone::KIND, crate::dialogs::halftone::fields()), _ => return None, }) } diff --git a/crates/ui-egui/src/tests_adjust.rs b/crates/ui-egui/src/tests_adjust.rs index 15a673e2..8ab1056e 100644 --- a/crates/ui-egui/src/tests_adjust.rs +++ b/crates/ui-egui/src/tests_adjust.rs @@ -1,4 +1,5 @@ -//! The colour adjustment effect dialogs (Effect → Color Adjustments) in the menus. +//! The colour adjustment effect dialogs (Effect → Color Adjustments) and Object → Vector Halftone +//! in the menus. use serde_json::json; use vectorcraft_engine::Session; @@ -17,7 +18,7 @@ fn dialog_text(app: &mut VectorcraftApp) -> String { } #[test] -fn the_effect_menu_lists_the_adjustments() { +fn the_effect_menu_lists_the_adjustments_and_the_object_menu_the_halftone() { let tree = menus::menu_tree(); let items = |menu: &str| format!("{:?}", tree.iter().find(|(t, _)| *t == menu).unwrap().1); let effect = items("Effect"); @@ -25,6 +26,7 @@ fn the_effect_menu_lists_the_adjustments() { { assert!(effect.contains(label), "{label}"); } + assert!(items("Object").contains("Vector Halftone…")); } #[test] @@ -97,3 +99,30 @@ fn dragging_on_the_curves_graph_adds_and_moves_a_point() { let (x, y) = pts[1]; assert!((x - 0.25).abs() < 0.01 && (y - 0.6).abs() < 0.01, "{points}"); } + +#[test] +fn the_halftone_menu_item_opens_a_previewing_dialog() { + let mut app = app(); + app.run("paint.setFill", json!({"color": "#000000"})).unwrap(); + let id = app.session.active().unwrap().selection.objects[0]; + menus::invoke(&mut app, "object.vectorHalftone", json!({})); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND)); + // Agents open it the same way. + app.ui.dialog = None; + app.run("ui.menuDialog", json!({"command": "object.vectorHalftone"})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::halftone::KIND)); + let text = dialog_text(&mut app); + for s in ["Vector Halftone", "Shape", "Circle", "Frequency", "Angle", "Screens", "Mono", "Color", "Clip to Art", "Keep Original"] { + assert!(text.lines().any(|l| l == s), "{s} in {text}"); + } + assert!(app.session.in_interaction(), "the preview runs"); + assert!(app.session.active().unwrap().doc.node(id).is_none(), "the preview shows the halftone in place of the art"); + app.ui.dialog.as_mut().unwrap().fields.insert("mode".into(), json!("cmyk")); + assert!(!dialog_text(&mut app).lines().any(|l| l == "Color"), "CMYK screens have their own inks"); + let undo = app.session.doc().unwrap().history.undo.len(); + dialogs::confirm(&mut app).unwrap(); + assert!(!app.session.in_interaction()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().name.as_deref(), Some("Vector Halftone")); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e4dcc50f..213c886f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -440,4 +440,7 @@ agent can call directly. Mirror & Cut's tool options (`tool.setOption`, shown in Colour adjustment effects (Effect → Color Adjustments) open the `effect` dialog like the other effects (`effect.dialog {effect: "adjust.hueSaturation"}`): its fields are the effect's parameters (sliders for the amounts, `channel` for Curves and Levels, `points` for Curves, `color` for Shift to Color) and they preview live; set them with -`ui.dialog.set` and `ui.dialog.confirm` to apply (Curves also has a graph: drag its points). +`ui.dialog.set` and `ui.dialog.confirm` to apply (Curves also has a graph: drag its points). Object → Vector +Halftone… (`engine.execute {command: "ui.menuDialog", params: {command: "object.vectorHalftone"}}`) opens the +`vectorHalftone` dialog (fields `shape`, `frequency`, `angle`, `mode`, `color`, `invert`, `clip`, `keepOriginal`, +`preview`): it previews live and `ui.dialog.confirm` keeps the halftone as one undo step. diff --git a/docs/mcp.md b/docs/mcp.md index ed2f3d47..25ebdc5a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1704,3 +1704,12 @@ each colour keeping its colour model: `adjust.brightnessContrast`, `adjust.curve one fill or stroke; they stay editable (`effect.setParams`), export with the adjusted colours (an adjusted image as a recoloured copy) and become permanent with `effect.expandAppearance`. Patterns and the shadows and glows of raster effects keep their colours. + +## Vector halftone + +`object.vectorHalftone` turns the selection into vector dots, lines or shapes sized by its +tone (mono, or CMYK screens multiplied over each other), clipped to the art's outline by default: + +```json +{"name":"run_command","arguments":{"command":"object.vectorHalftone","params":{"shape":"circle","frequency":25,"angle":45,"mode":"cmyk"}}} +```