mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Fix #122: Object > Vector Halftone
The issue asked for a vector halftone that turns art or an image into vector dots, lines or shapes by tone, with angle, frequency, shape, mono or CMYK separations and clipping to the object. Nothing like it existed. object.vectorHalftone renders the selection offscreen, samples its tone once per cell of a grid turned by the screen angle and places a shape whose area follows the tone (circle, ellipse, square, diamond or line). Mono screens follow lightness in one colour; CMYK screens follow the ink amounts the CMYK output separates, one screen per ink at the classic angles, multiplied over each other. Dots are clipped to the art's outline as real geometry (only the dots crossing it go through a boolean). It is a command rather than a live effect: the live pipeline can't sample rendered tone when exporting. Its dialog previews live and OK keeps it as one undo step. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9920156bb6
commit
285d94a3e6
11 files changed
+458
-5
No files matched your search
+1
-1
@@ -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 |
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<Self> {
|
||||
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<u8>,
|
||||
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<Value> {
|
||||
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<Node> = 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<Screen> = 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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> = 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}");
|
||||
}
|
||||
}
|
||||
@@ -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<Value, String> {
|
||||
form::commit_preview(app, CMD, form::params(d))
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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<Item>)> {
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"}}}
|
||||
```
|
||||
Reference in new issue
Block a user