Add-ons: Color Effects — live Vector Halftone effect

Effect › Color Effects › Vector Halftone… (addon.color.halftone, also in
Object › Plug-ins › Color Effects) draws its object as the halftone dots
Object › Vector Halftone makes, live: mono (one dot colour, or each dot in
the art's colour under it) or CMYK screens, the five dot shapes,
frequency, angle, invert and clip to the art's outline.

- The screens, dots and caps move to vectorcraft_effects::addons::halftone,
  shared by the command (its behaviour and tests unchanged) and the effect.
- The renderer samples the art (vectorcraft_render::addons::halftone) and
  keeps the dots by the object's content and what it reads from the
  document, so unchanged art isn't sampled again; past 100000 cells per
  screen the object draws as it is.
- Expand Appearance turns the object into its dots, and the SVG, PDF, EPS
  and AI exporters write them as paths.
- Its dialog reuses the command's fields, without Keep Original.

Deferred: stochastic and radial patterns, a dot-gain curve, per-screen
separation settings, type or symbols as dots.
This commit is contained in:
bflatastic
2026-10-30 22:13:33 +07:00
parent efcc334553
commit f4c245286e
49 changed files with 1027 additions and 243 deletions
@@ -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<u8> {
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", &params).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<usize> = ["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");
}
+12 -1
View File
@@ -69,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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);
+14 -219
View File
@@ -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<CommandSpec> {
@@ -33,102 +25,6 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// 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") {
@@ -153,7 +49,7 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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<Node> = roots
.iter()
.filter_map(|id| st.doc.node(*id).cloned())
@@ -173,64 +66,18 @@ fn halftone(s: &mut Session, p: &Value) -> Result<Value> {
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<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));
}
}
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<Value> {
}
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)
}