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