Merge pull request #169 from storytold/fix/issue-122-halftone
Fix #122: Object > Vector Halftone
This commit is contained in:
commit
242e8a5fd5
|
|
@ -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"}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue