mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 20:01:43 +00:00
Merge branch 'main' into fix/4312-move-edges-snap-to-ruler-guides
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())
|
||||
}
|
||||
|
||||
@@ -114,10 +114,34 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
NodeKind::Compound { children, rule } => {
|
||||
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
|
||||
}
|
||||
// Type extrudes as its glyph outlines and stays type: editing it reshapes the solid (#4715).
|
||||
NodeKind::Text(_) => {
|
||||
let mut subpaths = vec![];
|
||||
crate::outline_text(n)?.walk(&mut |c| subpaths.extend(c.path_data().into_iter().flat_map(|p| p.subpaths.iter().cloned())));
|
||||
(!subpaths.is_empty()).then(|| (PathData::new(subpaths), FillRule::NonZero))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the
|
||||
/// fill and stroke of its first painted run (its characters' colours).
|
||||
fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
|
||||
let own: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
|
||||
if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) {
|
||||
return own;
|
||||
}
|
||||
let mut runs = vec![];
|
||||
if let Some(outlined) = crate::outline_text(n) {
|
||||
outlined.walk(&mut |c| {
|
||||
if runs.is_empty() && c.path_data().is_some() {
|
||||
runs = c.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
|
||||
}
|
||||
});
|
||||
}
|
||||
runs
|
||||
}
|
||||
|
||||
fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) -> Result<Vec<Facet>, String> {
|
||||
let view = view_options(p);
|
||||
match id {
|
||||
@@ -320,7 +344,8 @@ pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
}
|
||||
let (path, rule) = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let painted: Vec<&AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).collect();
|
||||
let paints = solid_paints(n);
|
||||
let painted: Vec<&AppearanceItem> = paints.iter().collect();
|
||||
let mut children = vec![];
|
||||
let remaining: Vec<Effect>;
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Path Precision Tools (`addon.pathtools.*`), a built-in add-on: the Anchor Eraser Brush's stroke,
|
||||
//! the Extend Path tool's extension, the Measure & Inspect tool's readouts, the Anchor Inspector's
|
||||
//! anchor edits and Split / Add Points at Crossings. Its tools, panel and submenu (Object ›
|
||||
//! Plug-ins › Path Precision Tools) are the tools crate's and the UI's.
|
||||
//! anchor edits, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]).
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
mod shape;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -37,7 +39,7 @@ const MAX_EXTEND: f64 = 1e6;
|
||||
const MAX_ADD: u64 = pt::MAX_ADDED_PER_SEGMENT as u64;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"addon.pathtools.erase",
|
||||
"Anchor Eraser Brush",
|
||||
@@ -137,7 +139,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
crossings
|
||||
),
|
||||
]
|
||||
];
|
||||
v.extend(shape::specs());
|
||||
v
|
||||
}
|
||||
|
||||
/// The editable paths (not guides) under `roots`, compound path members included.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The Parametric Shape Tool's commands (`addon.pathtools.shape.*`): draw a parametric shape, change
|
||||
//! one's numbers, and turn plain rectangles, ellipses and regular polygons into them. A parametric
|
||||
//! shape is an ordinary path whose numbers sit in one entry of its object data
|
||||
//! (`vectorcraft_tools::addons::parametric_shape`), which SVG export leaves out.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Point};
|
||||
use vectorcraft_pathops::addons::pathtools::shapes::{ParametricShape, ShapeKind, recognise};
|
||||
use vectorcraft_tools::addons::parametric_shape::{CREATE, EDIT, KEY, apply, read, stored, to_json};
|
||||
|
||||
use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, has_selection};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The undo step's name and the command's label.
|
||||
const CONVERT: &str = "Convert to Parametric Shape";
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.shape.create",
|
||||
"Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{center: [x, y], kind?: \"rectangle\"|\"ellipse\"|\"polygon\" (default)|\"star\"|\"ring\"|\"arrow\"|\"cross\"|\"heart\"|\"gear\"|\"bubble\" (a speech bubble), width?, height?: 0.01..16384 pt (100), rotation?: degrees clockwise (0: upright), count?: 3..1000 (a polygon's sides, a star's points, a gear's teeth), a?, b?: the kind's own values (star: inner radius 0.01..1 of the outer, 0.5; ring: hole 0.01..0.99 of the size, 0.5; arrow: head length and shaft width 0.05..0.95, 0.4 and 0.4; cross: bar offset −0.9..0.9 (0, − up) and arm thickness 0.05..0.95 (1/3); heart: notch depth and point roundness 0..1 (0.4 and 0); gear: tooth height 0.01..0.9 (0.2); bubble: tail height 0.05..0.9 (0.25) and tail tip −1..1 across (−0.4)), radius?: corner radius 0..16384 pt (0; no corner rounds past the middle of a side)} draw a parametric shape with the current paint, selected, as one undo step; it stays editable with addon.pathtools.shape.set until its anchors are edited (moving, turning or scaling it keeps it) → {id, shape}",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.shape.set",
|
||||
"Edit Parametric Shape",
|
||||
[],
|
||||
None,
|
||||
"{id, kind?, center?, width?, height?, rotation?, count?, a?, b?, radius?} change a parametric shape's numbers (as addon.pathtools.shape.create takes them; a new kind takes its own defaults for the values not given), as one undo step; with none, or the same ones, nothing changes → {id, shape (its numbers now)}",
|
||||
has_doc,
|
||||
set
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.shape.convert",
|
||||
"Convert to Parametric Shape",
|
||||
["Object", "Plug-ins", "Path Precision Tools"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection)} make each plain closed path that is a rectangle, an ellipse or a polygon with equal sides and corners (stretched along its own axes at most) a parametric shape of the same outline, as one undo step → {ids (converted)}",
|
||||
has_selection,
|
||||
convert
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Give path node `n` the outline `path` of shape `s` and keep its numbers; a live shape goes.
|
||||
fn shape_node(n: &mut Node, s: &ParametricShape, path: PathData) {
|
||||
let entry = stored(s, &path);
|
||||
if let NodeKind::Path { path: p, live, .. } = &mut n.kind {
|
||||
*p = path;
|
||||
*live = None;
|
||||
}
|
||||
n.edit_attrs(|a| match a.data.iter_mut().find(|(k, _)| k == KEY) {
|
||||
Some(e) => e.1 = entry,
|
||||
None => a.data.push((KEY.into(), entry)),
|
||||
});
|
||||
}
|
||||
|
||||
/// The outline of `s`, checked already.
|
||||
fn outline(s: &ParametricShape, cmd: &str) -> Result<PathData> {
|
||||
s.path().ok_or_else(|| bad(cmd, "the shape's numbers are out of range"))
|
||||
}
|
||||
|
||||
fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.create";
|
||||
if p.get("center").is_none_or(Value::is_null) {
|
||||
return Err(bad(C, "`center` must be [x, y]"));
|
||||
}
|
||||
let shape = apply(ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), p).map_err(|e| bad(C, e))?;
|
||||
let path = outline(&shape, C)?;
|
||||
let parent = s.doc()?.target_parent()?;
|
||||
let look = s.new_art_look(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
|
||||
// shortcut: always on top of the insertion layer, as Draw Normal; honour Draw Behind/Inside if asked.
|
||||
let id = s.edit(CREATE, |d, sel| {
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::new(id, NodeKind::Path { path: PathData::default(), rule: FillRule::NonZero, live: None, clipping: false, guide: false });
|
||||
shape_node(&mut n, &shape, path);
|
||||
look.apply(d, &mut n);
|
||||
d.insert(parent, usize::MAX, n)?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({ "id": id.0, "shape": to_json(&shape) }))
|
||||
}
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.set";
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(C, "the path is locked or hidden"));
|
||||
}
|
||||
let old = d.node(id).and_then(read).ok_or_else(|| bad(C, "the object is not an editable parametric shape"))?;
|
||||
let shape = apply(old, p).map_err(|e| bad(C, e))?;
|
||||
if shape != old {
|
||||
let path = outline(&shape, C)?;
|
||||
s.edit(EDIT, |d, sel| {
|
||||
shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path);
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "shape": to_json(&shape) }))
|
||||
}
|
||||
|
||||
fn convert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.shape.convert";
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut found = vec![];
|
||||
for id in roots {
|
||||
let Some(n) = d.node(id).filter(|n| d.is_editable(id) && read(n).is_none()) else { continue };
|
||||
if let NodeKind::Path { path, guide: false, clipping: false, .. } = &n.kind
|
||||
&& let Some(shape) = recognise(path)
|
||||
{
|
||||
found.push((id, shape, outline(&shape, C)?));
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(bad(C, "select plain rectangles, ellipses or regular polygons to convert"));
|
||||
}
|
||||
let ids: Vec<u64> = found.iter().map(|(id, ..)| id.0).collect();
|
||||
s.edit(CONVERT, |d, sel| {
|
||||
for (id, shape, path) in found {
|
||||
shape_node(d.node_mut(id).ok_or(EngineError::NoNode(id))?, &shape, path);
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids }))
|
||||
}
|
||||
@@ -273,3 +273,186 @@ fn crossings_add_anchors_or_cut_in_one_step() {
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
refuses(&mut s, "addon.pathtools.crossings", &[json!({"cut": "yes"}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
// ---------- Parametric Shape Tool ----------
|
||||
|
||||
/// The parametric shape object `id` is now, as the tool reads it.
|
||||
fn shape_of(s: &Session, id: NodeId) -> Option<vectorcraft_pathops::addons::pathtools::shapes::ParametricShape> {
|
||||
vectorcraft_tools::addons::parametric_shape::read(s.doc().unwrap().doc.node(id).unwrap())
|
||||
}
|
||||
|
||||
fn star(s: &mut Session) -> NodeId {
|
||||
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "star", "center": [200, 150], "width": 100, "height": 100})).unwrap();
|
||||
id_of(&r)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_draws_a_selected_parametric_shape_in_one_step() {
|
||||
let mut s = session();
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.shape.create", &json!({"kind": "gear", "center": [200, 150], "count": 10, "a": 0.3, "radius": 2})).unwrap();
|
||||
let id = id_of(&r);
|
||||
assert_eq!(steps(&s), n + 1, "one undo step");
|
||||
assert_eq!((r["shape"]["kind"].clone(), r["shape"]["count"].clone(), r["shape"]["width"].clone()), (json!("gear"), json!(10), json!(100.0)));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [id]);
|
||||
let g = shape_of(&s, id).unwrap();
|
||||
assert_eq!((g.count, g.a, g.radius), (10, 0.3, 2.0));
|
||||
assert!(path(&s, id).anchor_count() > 40, "40 tooth corners, rounded");
|
||||
// Every kind, with its defaults.
|
||||
for kind in ["rectangle", "ellipse", "polygon", "star", "ring", "arrow", "cross", "heart", "gear", "bubble"] {
|
||||
let id = id_of(&s.execute("addon.pathtools.shape.create", &json!({"kind": kind, "center": [50, 50]})).unwrap());
|
||||
assert_eq!(shape_of(&s, id).map(|s| s.kind.id()), Some(kind));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(steps(&s), n + 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_changes_the_numbers_in_one_step_and_nothing_without_a_change() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
assert_eq!(path(&s, id).anchor_count(), 10);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8, "a": 0.3, "rotation": 15})).unwrap();
|
||||
assert_eq!((r["shape"]["count"].clone(), r["shape"]["rotation"].clone()), (json!(8), json!(15.0)));
|
||||
assert_eq!(path(&s, id).anchor_count(), 16);
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// The same numbers again, or none: no step.
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 8})).unwrap();
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
// Another kind takes its own defaults.
|
||||
let r = s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "kind": "arrow"})).unwrap();
|
||||
assert_eq!((r["shape"]["a"].clone(), r["shape"]["rotation"].clone()), (json!(0.4), json!(15.0)));
|
||||
assert_eq!(path(&s, id).anchor_count(), 7);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, id).anchor_count(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_shape_params_are_refused() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
let rect = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
|
||||
let c = json!([10, 10]);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.shape.create",
|
||||
&[
|
||||
json!({}),
|
||||
json!({"center": null}),
|
||||
json!({"center": [1]}),
|
||||
json!({"center": "here"}),
|
||||
json!({"center": [1e300, 0]}),
|
||||
json!({"center": c, "kind": "cloud"}),
|
||||
json!({"center": c, "kind": 3}),
|
||||
json!({"center": c, "count": 2}),
|
||||
json!({"center": c, "count": 1001}),
|
||||
json!({"center": c, "count": 4.5}),
|
||||
json!({"center": c, "width": 0}),
|
||||
json!({"center": c, "height": 16385}),
|
||||
json!({"center": c, "width": "40"}),
|
||||
json!({"center": c, "kind": "star", "a": 2}),
|
||||
json!({"center": c, "kind": "bubble", "b": -1.5}),
|
||||
json!({"center": c, "radius": -1}),
|
||||
json!({"center": c, "rotation": "up"}),
|
||||
],
|
||||
);
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.shape.set",
|
||||
&[json!({}), json!({"id": rect.0, "count": 6}), json!({"id": 999_999}), json!({"id": id.0, "count": 0}), json!({"id": id.0, "width": -5})],
|
||||
);
|
||||
s.execute("object.setProps", &json!({"ids": [id.0], "locked": true})).unwrap();
|
||||
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 6})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_anchor_edit_makes_it_a_plain_path_and_transforms_keep_it_editable() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
// Moved, turned and scaled with the ordinary commands: still the shape, at its new place.
|
||||
s.execute("object.move", &json!({"dx": 20, "dy": -10})).unwrap();
|
||||
assert!(shape_of(&s, id).is_some_and(|s| s.center.distance(Point::new(220.0, 140.0)) < 1e-6));
|
||||
s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 140]})).unwrap();
|
||||
let r = shape_of(&s, id).unwrap();
|
||||
assert!((r.rotation + 30.0).abs() < 1e-6, "30° counter-clockwise is −30 here: {r:?}");
|
||||
s.execute("object.scale", &json!({"sx": 200, "origin": [220, 140]})).unwrap();
|
||||
let r = shape_of(&s, id).unwrap();
|
||||
assert!((r.width - 200.0).abs() < 1e-6 && r.center.distance(Point::new(220.0, 140.0)) < 1e-6, "{r:?}");
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.shape.set", &json!({"id": id.0, "count": 6})).unwrap();
|
||||
assert_eq!((path(&s, id).anchor_count(), steps(&s)), (12, n + 1));
|
||||
assert!((shape_of(&s, id).unwrap().rotation + 30.0).abs() < 1e-6, "the turn stays");
|
||||
// Stretched across its own axes (it is turned): skewed, so no longer.
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 100, "origin": [220, 140]})).unwrap();
|
||||
assert_eq!(shape_of(&s, id), None);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(shape_of(&s, id).is_some(), "undo brings it back");
|
||||
// An anchor moved by hand: a plain path, which `set` refuses.
|
||||
select_anchors(&mut s, id, &[2]);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"x": 0})).unwrap();
|
||||
assert_eq!(shape_of(&s, id), None);
|
||||
refuses(&mut s, "addon.pathtools.shape.set", &[json!({"id": id.0, "count": 7})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_makes_plain_shapes_parametric_in_one_step() {
|
||||
let mut s = session();
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 10, "y": 20, "width": 100, "height": 50})).unwrap());
|
||||
let p = id_of(&s.execute("shape.polygon", &json!({"cx": 200, "cy": 150, "radius": 40, "sides": 7})).unwrap());
|
||||
let st = id_of(&s.execute("shape.star", &json!({"cx": 300, "cy": 150, "radius1": 40, "radius2": 15})).unwrap());
|
||||
select(&mut s, &[r, p, st]);
|
||||
let n = steps(&s);
|
||||
let out = s.execute("addon.pathtools.shape.convert", &json!({})).unwrap();
|
||||
assert_eq!(out["ids"], json!([r.0, p.0]));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(shape_of(&s, r).map(|s| s.kind.id()), Some("rectangle"));
|
||||
assert_eq!(shape_of(&s, p).map(|s| (s.kind.id(), s.count)), Some(("polygon", 7)));
|
||||
assert_eq!(shape_of(&s, st), None);
|
||||
assert_eq!(path(&s, r).bounds().map(|b| b.center()), Some(Point::new(60.0, 45.0)));
|
||||
// Nothing left to convert.
|
||||
select(&mut s, &[r, st]);
|
||||
refuses(&mut s, "addon.pathtools.shape.convert", &[json!({}), json!({"ids": [st.0]}), json!({"ids": [123456]})]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shape_data_stays_out_of_svg_export() {
|
||||
let mut s = session();
|
||||
let id = star(&mut s);
|
||||
s.execute("object.setProps", &json!({"ids": [id.0], "data": {"pivot": "1,2"}})).unwrap();
|
||||
let key = vectorcraft_tools::addons::parametric_shape::KEY;
|
||||
let node = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
assert!(node.extras().attrs.as_deref().is_some_and(|a| a.data.iter().any(|(k, _)| k == key)), "kept with the object");
|
||||
assert!(shape_of(&s, id).is_some(), "setting other data keeps it a shape");
|
||||
let svg = vectorcraft_svg::export(&s.doc().unwrap().doc, &Default::default());
|
||||
assert!(svg.contains("data-pivot=\"1,2\""), "the object's own data still goes out: {svg}");
|
||||
assert!(!svg.contains(key) && !svg.contains("fp"") && !svg.contains("\"fp\""), "{svg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
|
||||
let mut s = session();
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("parametricShape", view).unwrap();
|
||||
s.set_tool_option("kind", &json!("star"));
|
||||
let n = steps(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 150.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 100.0), view).unwrap();
|
||||
s.tool_key(vectorcraft_tools::ToolKey::Up, vectorcraft_tools::Mods::default(), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 90.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 90.0), view).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let id = *s.doc().unwrap().selection.objects.first().unwrap();
|
||||
let shape = shape_of(&s, id).unwrap();
|
||||
assert_eq!((shape.kind.id(), shape.count, shape.height, shape.rotation), ("star", 6, 120.0, 0.0));
|
||||
// Its size handle (lower right of its box) drags into one more step.
|
||||
let corner = shape.frame() * Point::new(shape.width / 2.0, shape.height / 2.0);
|
||||
for (k, x) in [(PointerKind::Down, corner.x), (PointerKind::Drag, corner.x + 30.0), (PointerKind::Up, corner.x + 30.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, corner.y), view).unwrap();
|
||||
}
|
||||
assert_eq!(steps(&s), n + 2);
|
||||
assert!((shape_of(&s, id).unwrap().width - 180.0).abs() < 1e-9);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -77,12 +77,35 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects.len(), 0, "{effect}: back to the plain rectangle");
|
||||
}
|
||||
// Type has no outline to extrude.
|
||||
}
|
||||
|
||||
/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays
|
||||
/// type: editing its text reshapes the solid.
|
||||
#[test]
|
||||
fn type_extrudes_as_its_glyphs_and_stays_editable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 50, "text": "3D"})).unwrap();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [t["id"]], "item": null})).unwrap_err();
|
||||
assert!(err.to_string().contains("requires a path"), "{err}");
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "3D", "size": 72})).unwrap();
|
||||
let id = NodeId(t["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
|
||||
let art = effects::solid_art(&n).expect("a solid of the glyphs");
|
||||
let mut faces = 0;
|
||||
let mut tinted = false;
|
||||
art.walk(&mut |c| {
|
||||
if c.path_data().is_some() {
|
||||
faces += 1;
|
||||
// Shaded from the characters' red: red stays the strongest channel.
|
||||
if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
|
||||
tinted |= r > g && r > b;
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(faces > 2 && tinted, "{faces} faces, tinted {tinted}");
|
||||
s.execute("text.setText", &json!({"ids": [id.0], "text": "3DD"})).unwrap();
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text");
|
||||
}
|
||||
|
||||
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -99,10 +99,18 @@ macro_rules! style_cmds {
|
||||
concat!($p, ".delete"),
|
||||
concat!("Delete ", $what, " Style"),
|
||||
&["Window", "Type", $menu],
|
||||
"{name} (text keeps its look)",
|
||||
"{name} | {names: [name…]} delete styles in one undo step (text keeps its look; never the Normal style)",
|
||||
|s, p| delete(s, p, $kind),
|
||||
true,
|
||||
),
|
||||
spec(
|
||||
concat!($p, ".unused"),
|
||||
"Select All Unused",
|
||||
&["Window", "Type", $menu],
|
||||
"{} → {names: [the styles no text uses, in panel order; never the Normal style]} (the panel highlights them)",
|
||||
|s, _| unused(s, $kind),
|
||||
false,
|
||||
),
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -291,21 +299,33 @@ fn text_mut(d: &mut Document, id: NodeId) -> Option<&mut vectorcraft_doc::TextOb
|
||||
}
|
||||
}
|
||||
|
||||
/// How many runs (char) or paragraphs (para) use `name`.
|
||||
fn uses(d: &Document, kind: Kind, name: &str) -> usize {
|
||||
let target = (name != kind.normal()).then_some(name);
|
||||
let mut n = 0;
|
||||
d.walk(|node| {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
n += match kind {
|
||||
Kind::Char => t.runs.iter().filter(|r| r.style.style_name.as_deref() == target).count(),
|
||||
Kind::Para => (0..t.paragraph_count()).filter(|&i| t.para_at(i).style_name.as_deref() == target).count(),
|
||||
};
|
||||
}
|
||||
});
|
||||
/// How many runs (char) or paragraphs (para) use each style, by style name (`None`: the Normal
|
||||
/// style). Type in the art (opacity masks included), symbols, graph designs, pattern tiles and the
|
||||
/// art of brushes counts.
|
||||
fn uses(d: &Document, kind: Kind) -> std::collections::HashMap<Option<String>, usize> {
|
||||
let mut n = std::collections::HashMap::new();
|
||||
let mut count = |name: &Option<String>| *n.entry(name.clone()).or_insert(0) += 1;
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let trees = d.layers.iter().map(|l| &**l).chain(d.symbols.iter().map(|s| &*s.art)).chain(d.graph_designs.iter().map(|g| &*g.art));
|
||||
let trees = trees.chain(d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a))).chain(brushes.iter().flat_map(|b| b.art()));
|
||||
for tree in trees {
|
||||
vectorcraft_doc::swatches::walk_with_masks(tree, &mut |node| {
|
||||
if let NodeKind::Text(t) = &node.kind {
|
||||
match kind {
|
||||
Kind::Char => t.runs.iter().for_each(|r| count(&r.style.style_name)),
|
||||
Kind::Para => (0..t.paragraph_count()).for_each(|i| count(&t.para_at(i).style_name)),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// How many runs (char) or paragraphs (para) use `name` (see [`uses`]).
|
||||
fn uses_of(uses: &std::collections::HashMap<Option<String>, usize>, kind: Kind, name: &str) -> usize {
|
||||
uses.get(&(name != kind.normal()).then(|| name.to_string())).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Update every user of `name` from `old` to `new` attributes (overrides kept).
|
||||
fn update_users(d: &mut Document, kind: Kind, name: &str, old: &Map<String, Value>, new: &Map<String, Value>) {
|
||||
let target = (name != kind.normal()).then_some(name.to_string());
|
||||
@@ -434,7 +454,8 @@ fn list(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut names = vec![kind.normal().to_string()];
|
||||
names.extend(kind.defs(d).iter().map(|s| s.name.clone()).filter(|n| n != kind.normal()));
|
||||
let styles: Vec<Value> = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses(d, kind, n) })).collect();
|
||||
let uses = uses(d, kind);
|
||||
let styles: Vec<Value> = names.iter().map(|n| json!({ "name": n, "attrs": kind.attrs(d, n), "uses": uses_of(&uses, kind, n) })).collect();
|
||||
Ok(json!({ "styles": styles }))
|
||||
}
|
||||
|
||||
@@ -616,16 +637,26 @@ fn rename(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
let c = kind.cmd("delete");
|
||||
let name = name_param(p, kind, "delete")?.to_string();
|
||||
if name == kind.normal() || !kind.defs(&s.doc()?.doc).iter().any(|s| s.name == name) {
|
||||
let names: Vec<String> = match p.get("names") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().map(|n| n.as_str().map(str::to_string)).collect::<Option<Vec<_>>>())
|
||||
.ok_or_else(|| bad(&c, "`names` must be an array of style names"))?,
|
||||
None => vec![name_param(p, kind, "delete")?.to_string()],
|
||||
};
|
||||
if names.is_empty() {
|
||||
return Err(bad(&c, "missing `name`"));
|
||||
}
|
||||
let defs = kind.defs(&s.doc()?.doc);
|
||||
if let Some(name) = names.iter().find(|n| *n == kind.normal() || !defs.iter().any(|s| s.name == **n)) {
|
||||
return Err(bad(&c, format!("`{name}` can't be deleted")));
|
||||
}
|
||||
s.edit("Delete Style", |d, _| {
|
||||
kind.defs_mut(d).retain(|s| s.name != name);
|
||||
s.edit(if names.len() > 1 { "Delete Styles" } else { "Delete Style" }, |d, _| {
|
||||
kind.defs_mut(d).retain(|s| !names.contains(&s.name));
|
||||
for id in all_text(d) {
|
||||
let Some(t) = text_mut(d, id) else { continue };
|
||||
let fix = |n: &mut Option<String>| {
|
||||
if n.as_deref() == Some(name.as_str()) {
|
||||
if n.as_ref().is_some_and(|n| names.contains(n)) {
|
||||
*n = None;
|
||||
}
|
||||
};
|
||||
@@ -639,5 +670,14 @@ fn delete(s: &mut Session, p: &Value, kind: Kind) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The styles no text uses (see [`uses`]), in the order the panel lists them, without the Normal
|
||||
/// style.
|
||||
fn unused(s: &mut Session, kind: Kind) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let uses = uses(d, kind);
|
||||
let names: Vec<&str> = kind.defs(d).iter().map(|s| s.name.as_str()).filter(|n| *n != kind.normal() && uses_of(&uses, kind, n) == 0).collect();
|
||||
Ok(json!({ "names": names }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -122,6 +122,100 @@ fn new_styles_trim_their_names_as_a_rename_does() {
|
||||
assert_eq!(s.execute("charStyle.duplicate", &json!({"name": " Old "})).unwrap()["name"], "Old copy 2");
|
||||
}
|
||||
|
||||
fn unused(s: &mut Session, p: &str) -> Vec<String> {
|
||||
serde_json::from_value(s.execute(&format!("{p}.unused"), &json!({})).unwrap()["names"].clone()).unwrap()
|
||||
}
|
||||
|
||||
fn listed(s: &mut Session, p: &str) -> Vec<String> {
|
||||
let list = s.execute(&format!("{p}.list"), &json!({})).unwrap();
|
||||
list["styles"].as_array().unwrap().iter().filter_map(|x| x["name"].as_str()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
/// Select All Unused: `charStyle.unused` and `paraStyle.unused` list, in panel order and without an
|
||||
/// undo step, the styles no text uses, never the Normal style. Type in a symbol, an opacity mask, a
|
||||
/// graph design, a pattern tile or a brush's art counts, as type in the layers does.
|
||||
#[test]
|
||||
fn unused_styles_are_those_no_text_uses_wherever_the_text_is() {
|
||||
let (mut s, id) = session_with_text("Used");
|
||||
let names = ["Spare", "Used", "Symbol", "Mask", "Design", "Pattern", "Brush", "Also spare"];
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
for n in names {
|
||||
s.execute(&format!("{p}.new"), &json!({"name": n, "attrs": {}})).unwrap();
|
||||
}
|
||||
s.execute(&format!("{p}.apply"), &json!({"name": "Used", "id": id})).unwrap();
|
||||
}
|
||||
// Type in style `name`, selected.
|
||||
let styled = |s: &mut Session, name: &str| {
|
||||
let t = s.execute("text.create", &json!({"x": 10, "y": 150, "text": name})).unwrap()["id"].as_u64().unwrap();
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
s.execute(&format!("{p}.apply"), &json!({"name": name, "id": t})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [t]})).unwrap();
|
||||
NodeId(t)
|
||||
};
|
||||
let clear = |s: &mut Session, id: NodeId| {
|
||||
s.execute("edit.clear", &json!({"ids": [id.0]})).unwrap();
|
||||
};
|
||||
styled(&mut s, "Symbol");
|
||||
let sym = s.execute("symbol.new", &json!({"name": "Mark"})).unwrap();
|
||||
clear(&mut s, NodeId(sym["id"].as_u64().unwrap()));
|
||||
let masked = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap();
|
||||
let mask = styled(&mut s, "Mask");
|
||||
s.execute("select.set", &json!({"ids": [masked, mask.0]})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
let design = styled(&mut s, "Design");
|
||||
s.execute("graph.design", &json!({"new": "Bar"})).unwrap();
|
||||
clear(&mut s, design);
|
||||
let tile = styled(&mut s, "Pattern");
|
||||
s.execute("object.pattern.make", &json!({"edit": false})).unwrap();
|
||||
if s.doc().unwrap().doc.node(tile).is_some() {
|
||||
clear(&mut s, tile);
|
||||
}
|
||||
let art = styled(&mut s, "Brush");
|
||||
s.execute("brush.new", &json!({"type": "art", "name": "Lettering"})).unwrap();
|
||||
clear(&mut s, art);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
assert_eq!(unused(&mut s, p), ["Spare", "Also spare"], "{p}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
let list = s.execute("charStyle.list", &json!({})).unwrap();
|
||||
let uses = |name: &str| list["styles"].as_array().unwrap().iter().find(|x| x["name"] == name).unwrap()["uses"].as_u64();
|
||||
for name in ["Symbol", "Mask", "Design", "Pattern", "Brush"] {
|
||||
assert_eq!(uses(name), Some(1), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `charStyle.delete` and `paraStyle.delete` take `names` and delete them in one undo step; text
|
||||
/// that used them keeps its look. A name that is not a style, the Normal style, or `names` that is
|
||||
/// not an array of strings deletes none.
|
||||
#[test]
|
||||
fn several_styles_are_deleted_in_one_step() {
|
||||
let (mut s, id) = session_with_text("Title");
|
||||
s.execute("text.setStyle", &json!({"size": 40})).unwrap();
|
||||
for p in ["charStyle", "paraStyle"] {
|
||||
for n in ["A", "B", "C"] {
|
||||
s.execute(&format!("{p}.new"), &json!({"name": n})).unwrap();
|
||||
}
|
||||
s.execute(&format!("{p}.apply"), &json!({"name": "B", "id": id})).unwrap();
|
||||
let before = listed(&mut s, p);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let normal = before[0].clone();
|
||||
for bad in [json!(["A", "Nope"]), json!(["A", normal]), json!([]), json!(["A", 7]), json!("A")] {
|
||||
assert!(s.execute(&format!("{p}.delete"), &json!({"names": bad})).is_err(), "{p} {bad}");
|
||||
}
|
||||
assert_eq!((listed(&mut s, p), s.doc().unwrap().history.undo.len()), (before.clone(), undo), "{p}");
|
||||
s.execute(&format!("{p}.delete"), &json!({"names": ["A", "B"]})).unwrap();
|
||||
assert_eq!(listed(&mut s, p), [normal.as_str(), "C"], "{p}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{p}");
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Styles", "{p}");
|
||||
assert_eq!(listed(&mut s, p), before, "{p}");
|
||||
}
|
||||
s.execute("charStyle.delete", &json!({"names": ["B"]})).unwrap();
|
||||
assert_eq!((runs(&s, id)[0].style.style_name.as_deref(), runs(&s, id)[0].style.size), (None, 40.0));
|
||||
assert_eq!(s.execute("edit.undo", &json!({})).unwrap()["undone"], "Delete Style");
|
||||
}
|
||||
|
||||
/// #1001: attributes their JSON leaves out at the defaults (Auto leading and kerning, no
|
||||
/// mojikumi) are still attributes a style can set, and a style captured with one applies to
|
||||
/// text at the default, is duplicated and comes back out of a library.
|
||||
|
||||
@@ -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})),
|
||||
@@ -510,6 +513,20 @@ fn structured_junk() {
|
||||
("shapeBuilder.regions", json!({"ids": [2, 3], "gap": "x"})),
|
||||
("shapeBuilder.regions", json!({"gap": true})),
|
||||
("shapeBuilder.regions", json!({"gap": 12, "ids": [u64::MAX]})),
|
||||
// Path Precision Tools: the Parametric Shape Tool.
|
||||
("addon.pathtools.shape.create", json!({"center": [1e308, -1e308], "width": 1e308, "count": 1e308})),
|
||||
(
|
||||
"addon.pathtools.shape.create",
|
||||
json!({"center": [0, 0], "kind": "gear", "count": 1000, "a": 0.9, "radius": 16384, "width": 16384, "height": 0.01}),
|
||||
),
|
||||
("addon.pathtools.shape.create", json!({"center": [0, 0], "kind": "star", "count": 1000, "a": 0.01, "radius": 1e-300})),
|
||||
("addon.pathtools.shape.create", json!({"center": ["x", null], "kind": ["star"], "rotation": f64::NAN})),
|
||||
("addon.pathtools.shape.create", json!({"center": [5, 5], "kind": "bubble", "a": -1e308, "b": 1e308, "rotation": 1e308})),
|
||||
("addon.pathtools.shape.set", json!({"id": 2, "count": 1000, "kind": "gear"})),
|
||||
("addon.pathtools.shape.set", json!({"id": u64::MAX, "width": -1})),
|
||||
("addon.pathtools.shape.set", json!({"id": 2, "center": [1, 2, 3], "radius": "big"})),
|
||||
("addon.pathtools.shape.convert", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
|
||||
("addon.pathtools.shape.convert", json!({"ids": "all"})),
|
||||
// Smart Pen add-on.
|
||||
("addon.pen.append", json!({"id": 2, "x": 1e308, "y": 0, "in": [1e308, -1e308]})),
|
||||
("addon.pen.append", json!({"id": 2, "subpath": u64::MAX, "x": 0, "y": 0, "segment": "curved"})),
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
//! its shape within a tolerance (shared by every add-on that takes anchors away).
|
||||
//! - [`cleanup`]: Artwork Cleanup's path repairs (collinear, duplicate and near anchors, tiny
|
||||
//! handles, nearly closed paths) and Rejoin Path Pieces.
|
||||
//! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths and
|
||||
//! finding where paths cross.
|
||||
//! - [`pathtools`]: Path Precision Tools' anchor edits, path readouts, extending open paths,
|
||||
//! finding where paths cross and the Parametric Shape Tool's re-editable shapes.
|
||||
//! - [`segment_reshape`]: bending a segment by dragging a point on it (shared by the tools that
|
||||
//! reshape segments directly).
|
||||
//! - [`pen`]: the Smart Pen's straight and curved segments and its point edits.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Path Precision Tools' geometry: anchor edits for the Anchor Inspector (smooth by the auto-smooth
|
||||
//! rule, equalise handles, add evenly spaced anchors, close), the readouts the Measure & Inspect
|
||||
//! tool shows over a path, open paths lengthened or shortened at an end ([`extend`]) and the places
|
||||
//! where paths cross ([`crossings`]).
|
||||
//! where paths cross ([`crossings`]), and the Parametric Shape Tool's shapes ([`shapes`]).
|
||||
|
||||
mod crossings;
|
||||
mod extend;
|
||||
pub mod shapes;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
|
||||
@@ -0,0 +1,664 @@
|
||||
//! The Parametric Shape Tool's geometry (Path Precision Tools): shapes described by a few numbers
|
||||
//! (kind, centre, size, rotation, a count and up to two values of the kind's own, corner radius)
|
||||
//! that stay editable as those numbers. [`ParametricShape::path`] draws one; [`fingerprint`] and
|
||||
//! [`ParametricShape::recover`] tell whether a path is still the shape it was drawn as, moved,
|
||||
//! turned or scaled since, or was edited into something else; [`recognise`] finds the shape a
|
||||
//! plain rectangle, ellipse or regular polygon is.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use kurbo::{Affine, Point, Vec2};
|
||||
use vectorcraft_geom::corners::cut_corners;
|
||||
use vectorcraft_geom::shapes::KAPPA;
|
||||
use vectorcraft_geom::{Anchor, AnchorKind, PathData, SubPath};
|
||||
|
||||
/// The fewest sides, points or teeth.
|
||||
pub const MIN_COUNT: u32 = 3;
|
||||
/// The most sides, points or teeth.
|
||||
pub const MAX_COUNT: u32 = 1000;
|
||||
/// The smallest width or height (pt).
|
||||
pub const MIN_SIZE: f64 = 0.01;
|
||||
/// The largest width, height or corner radius (pt).
|
||||
pub const MAX_SIZE: f64 = 16384.0;
|
||||
/// The farthest from the origin a centre may be (pt).
|
||||
pub const MAX_CENTER: f64 = 1e7;
|
||||
|
||||
/// The kinds of parametric shape.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ShapeKind {
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Polygon,
|
||||
Star,
|
||||
Ring,
|
||||
Arrow,
|
||||
Cross,
|
||||
Heart,
|
||||
Gear,
|
||||
Bubble,
|
||||
}
|
||||
|
||||
/// One of a kind's own values: its range and default.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ValueRange {
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub default: f64,
|
||||
}
|
||||
|
||||
const fn range(min: f64, max: f64, default: f64) -> Option<ValueRange> {
|
||||
Some(ValueRange { min, max, default })
|
||||
}
|
||||
|
||||
impl ShapeKind {
|
||||
pub const ALL: [ShapeKind; 10] = [
|
||||
ShapeKind::Rectangle,
|
||||
ShapeKind::Ellipse,
|
||||
ShapeKind::Polygon,
|
||||
ShapeKind::Star,
|
||||
ShapeKind::Ring,
|
||||
ShapeKind::Arrow,
|
||||
ShapeKind::Cross,
|
||||
ShapeKind::Heart,
|
||||
ShapeKind::Gear,
|
||||
ShapeKind::Bubble,
|
||||
];
|
||||
|
||||
/// The kind's name in commands and files.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ShapeKind::Rectangle => "rectangle",
|
||||
ShapeKind::Ellipse => "ellipse",
|
||||
ShapeKind::Polygon => "polygon",
|
||||
ShapeKind::Star => "star",
|
||||
ShapeKind::Ring => "ring",
|
||||
ShapeKind::Arrow => "arrow",
|
||||
ShapeKind::Cross => "cross",
|
||||
ShapeKind::Heart => "heart",
|
||||
ShapeKind::Gear => "gear",
|
||||
ShapeKind::Bubble => "bubble",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|k| k.id() == s)
|
||||
}
|
||||
|
||||
/// Does `count` set how many sides, points or teeth the kind has?
|
||||
pub fn counted(self) -> bool {
|
||||
matches!(self, ShapeKind::Polygon | ShapeKind::Star | ShapeKind::Gear)
|
||||
}
|
||||
|
||||
/// The count a shape of this kind starts with.
|
||||
pub fn default_count(self) -> u32 {
|
||||
match self {
|
||||
ShapeKind::Star => 5,
|
||||
ShapeKind::Gear => 12,
|
||||
_ => 6,
|
||||
}
|
||||
}
|
||||
|
||||
/// Can its corners be rounded (`radius`)? Not a shape without corners.
|
||||
pub fn rounds(self) -> bool {
|
||||
!matches!(self, ShapeKind::Ellipse | ShapeKind::Ring)
|
||||
}
|
||||
|
||||
/// The kind's own values `a` and `b` (as fractions of its size), where it has them: a star's
|
||||
/// and a ring's inner radius; an arrow's head length and shaft width; a cross's bar offset
|
||||
/// (−: up) and arm thickness; a heart's notch depth and the roundness of its point; a gear's
|
||||
/// tooth height; a speech bubble's tail height and where its tip is across (−1 left, 1 right).
|
||||
pub fn values(self) -> [Option<ValueRange>; 2] {
|
||||
match self {
|
||||
ShapeKind::Star => [range(0.01, 1.0, 0.5), None],
|
||||
ShapeKind::Ring => [range(0.01, 0.99, 0.5), None],
|
||||
ShapeKind::Arrow => [range(0.05, 0.95, 0.4), range(0.05, 0.95, 0.4)],
|
||||
ShapeKind::Cross => [range(-0.9, 0.9, 0.0), range(0.05, 0.95, 1.0 / 3.0)],
|
||||
ShapeKind::Heart => [range(0.0, 1.0, 0.4), range(0.0, 1.0, 0.0)],
|
||||
ShapeKind::Gear => [range(0.01, 0.9, 0.2), None],
|
||||
ShapeKind::Bubble => [range(0.05, 0.9, 0.25), range(-1.0, 1.0, -0.4)],
|
||||
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => [None, None],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parametric shape: what it is and where; its outline is [`ParametricShape::path`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ParametricShape {
|
||||
pub kind: ShapeKind,
|
||||
pub center: Point,
|
||||
pub width: f64,
|
||||
pub height: f64,
|
||||
/// Degrees clockwise on the page; 0 stands the shape upright (a polygon's first corner up).
|
||||
pub rotation: f64,
|
||||
/// Sides, points or teeth ([`ShapeKind::counted`]).
|
||||
pub count: u32,
|
||||
/// The kind's own values ([`ShapeKind::values`]); a kind without one ignores it.
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
/// The corners' radius (pt), each no larger than fits ([`ShapeKind::rounds`]).
|
||||
pub radius: f64,
|
||||
}
|
||||
|
||||
/// Where a shape is wrong, as `(param, min, max)`: `param` must be from `min` to `max`.
|
||||
pub type OutOfRange = (&'static str, f64, f64);
|
||||
|
||||
impl ParametricShape {
|
||||
/// A `kind` of shape about `center`, `width` × `height`, with the kind's defaults.
|
||||
pub fn new(kind: ShapeKind, center: Point, width: f64, height: f64) -> Self {
|
||||
let [a, b] = kind.values().map(|v| v.map_or(0.0, |v| v.default));
|
||||
Self { kind, center, width, height, rotation: 0.0, count: kind.default_count(), a, b, radius: 0.0 }
|
||||
}
|
||||
|
||||
/// The shape as another kind, keeping its place, size, rotation and corner radius; its count
|
||||
/// and values are the new kind's defaults.
|
||||
pub fn with_kind(self, kind: ShapeKind) -> Self {
|
||||
if kind == self.kind {
|
||||
return self;
|
||||
}
|
||||
Self { radius: self.radius, rotation: self.rotation, ..Self::new(kind, self.center, self.width, self.height) }
|
||||
}
|
||||
|
||||
/// The first number out of its range, if any (every number must also be finite).
|
||||
pub fn check(&self) -> Result<(), OutOfRange> {
|
||||
let within = |v: f64, lo: f64, hi: f64| v.is_finite() && (lo..=hi).contains(&v);
|
||||
let size = [("width", self.width), ("height", self.height)];
|
||||
if let Some((key, _)) = size.into_iter().find(|(_, v)| !within(*v, MIN_SIZE, MAX_SIZE)) {
|
||||
return Err((key, MIN_SIZE, MAX_SIZE));
|
||||
}
|
||||
if !within(self.radius, 0.0, MAX_SIZE) {
|
||||
return Err(("radius", 0.0, MAX_SIZE));
|
||||
}
|
||||
if !(within(self.center.x, -MAX_CENTER, MAX_CENTER) && within(self.center.y, -MAX_CENTER, MAX_CENTER)) {
|
||||
return Err(("center", -MAX_CENTER, MAX_CENTER));
|
||||
}
|
||||
if !self.rotation.is_finite() {
|
||||
return Err(("rotation", -360.0, 360.0));
|
||||
}
|
||||
if !(MIN_COUNT..=MAX_COUNT).contains(&self.count) {
|
||||
return Err(("count", f64::from(MIN_COUNT), f64::from(MAX_COUNT)));
|
||||
}
|
||||
// A value the kind doesn't have is left alone.
|
||||
for (key, v, spec) in [("a", self.a, self.kind.values()[0]), ("b", self.b, self.kind.values()[1])] {
|
||||
if let Some(r) = spec.filter(|r| !within(v, r.min, r.max)) {
|
||||
return Err((key, r.min, r.max));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// From the shape's own frame (its centre at the origin, upright) to the page.
|
||||
pub fn frame(&self) -> Affine {
|
||||
Affine::translate(self.center.to_vec2()) * Affine::rotate(self.rotation.to_radians())
|
||||
}
|
||||
|
||||
/// The shape's outline on the page: closed subpaths, starting at the top (a rectangle's and a
|
||||
/// speech bubble's at the top left corner) and running clockwise; a ring's hole runs the
|
||||
/// other way. `None` when a number is out of range ([`Self::check`]).
|
||||
pub fn path(&self) -> Option<PathData> {
|
||||
self.check().ok()?;
|
||||
let (rx, ry) = (self.width / 2.0, self.height / 2.0);
|
||||
let at = |x: f64, y: f64| Point::new(x * rx, y * ry);
|
||||
let polygon = |pts: Vec<Point>| vec![SubPath::polyline(&pts, true)];
|
||||
let (a, b) = (self.a, self.b);
|
||||
let around = |n: usize, turn: f64, radius_at: &dyn Fn(usize) -> f64| -> Vec<Point> {
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
let (s, c) = (i as f64 * turn).sin_cos();
|
||||
let r = radius_at(i);
|
||||
at(r * s, -r * c)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let n = self.count as usize;
|
||||
let subpaths = match self.kind {
|
||||
ShapeKind::Rectangle => polygon(vec![at(-1.0, -1.0), at(1.0, -1.0), at(1.0, 1.0), at(-1.0, 1.0)]),
|
||||
ShapeKind::Ellipse => vec![ellipse(rx, ry)],
|
||||
ShapeKind::Polygon => polygon(around(n, TAU / n as f64, &|_| 1.0)),
|
||||
ShapeKind::Star => polygon(around(2 * n, PI / n as f64, &|i| if i % 2 == 0 { 1.0 } else { a })),
|
||||
ShapeKind::Ring => {
|
||||
let mut hole = ellipse(rx * a, ry * a);
|
||||
hole.reverse();
|
||||
vec![ellipse(rx, ry), hole]
|
||||
}
|
||||
ShapeKind::Arrow => {
|
||||
// The head from the top down to `yb`, the shaft below it.
|
||||
let (yb, sw) = (-1.0 + 2.0 * a, b);
|
||||
polygon(vec![at(0.0, -1.0), at(1.0, yb), at(sw, yb), at(sw, 1.0), at(-sw, 1.0), at(-sw, yb), at(-1.0, yb)])
|
||||
}
|
||||
ShapeKind::Cross => {
|
||||
// The bar across, `a` of the way from the middle to an end of the upright.
|
||||
let (t, y0) = (b, a * (1.0 - b));
|
||||
let (top, bottom) = (y0 - t, y0 + t);
|
||||
polygon(vec![
|
||||
at(-t, -1.0),
|
||||
at(t, -1.0),
|
||||
at(t, top),
|
||||
at(1.0, top),
|
||||
at(1.0, bottom),
|
||||
at(t, bottom),
|
||||
at(t, 1.0),
|
||||
at(-t, 1.0),
|
||||
at(-t, bottom),
|
||||
at(-1.0, bottom),
|
||||
at(-1.0, top),
|
||||
at(-t, top),
|
||||
])
|
||||
}
|
||||
ShapeKind::Heart => vec![heart(a, b, &at)],
|
||||
ShapeKind::Gear => {
|
||||
// Each tooth's flanks and tip, centred on its angle; straight roots between them.
|
||||
let step = TAU / n as f64;
|
||||
let root = 1.0 - a;
|
||||
let mut pts = Vec::with_capacity(4 * n);
|
||||
for i in 0..n {
|
||||
for (f, r) in [(-0.3, root), (-0.15, 1.0), (0.15, 1.0), (0.3, root)] {
|
||||
let (s, c) = ((i as f64 + f) * step).sin_cos();
|
||||
pts.push(at(r * s, -r * c));
|
||||
}
|
||||
}
|
||||
polygon(pts)
|
||||
}
|
||||
ShapeKind::Bubble => {
|
||||
// The body above `by`, the tail's base on its bottom edge, its tip on the bottom.
|
||||
let by = 1.0 - 2.0 * a;
|
||||
let (mid, half) = (b * 0.5, 0.3);
|
||||
polygon(vec![at(-1.0, -1.0), at(1.0, -1.0), at(1.0, by), at(mid + half, by), at(b, 1.0), at(mid - half, by), at(-1.0, by)])
|
||||
}
|
||||
};
|
||||
let mut path = PathData::new(subpaths);
|
||||
if self.radius > 0.0 && self.kind.rounds() {
|
||||
path = cut_corners(&path, &vec![self.radius; path.anchor_count()], &[]).0;
|
||||
}
|
||||
path.transform(self.frame());
|
||||
Some(path)
|
||||
}
|
||||
|
||||
/// The shape `path` is when it is this shape's outline moved, turned or scaled (along the
|
||||
/// shape's own axes; uniformly when its corners are rounded), not mirrored or skewed, and
|
||||
/// otherwise unedited: the new centre, size, rotation and corner radius. `None` when its
|
||||
/// anchors were edited, or it was skewed or mirrored.
|
||||
pub fn recover(&self, path: &PathData) -> Option<Self> {
|
||||
let drawn = self.path()?;
|
||||
let pts = |p: &PathData| p.anchors().map(|(_, _, a)| a.p).collect::<Vec<_>>();
|
||||
let (from, to) = (pts(&drawn), pts(path));
|
||||
if from.len() != to.len() || drawn.subpaths.len() != path.subpaths.len() {
|
||||
return None;
|
||||
}
|
||||
let m = fit_affine(&from, &to)? * self.frame();
|
||||
let [m0, m1, m2, m3, ..] = m.as_coeffs();
|
||||
let (x, y) = (Vec2::new(m0, m1), Vec2::new(m2, m3));
|
||||
let (sx, sy) = (x.hypot(), y.hypot());
|
||||
// Square and not mirrored: the shape's axes stay at right angles, turned the same way.
|
||||
if !(sx > 1e-12 && sy > 1e-12) || x.dot(y).abs() > 1e-6 * sx * sy || x.cross(y) <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let out = Self {
|
||||
center: m * Point::ZERO,
|
||||
width: self.width * sx,
|
||||
height: self.height * sy,
|
||||
rotation: vectorcraft_geom::normalize_deg(x.y.atan2(x.x).to_degrees()),
|
||||
radius: self.radius * (sx * sy).sqrt(),
|
||||
..*self
|
||||
};
|
||||
out.check().ok()?;
|
||||
same(&out.path()?, path).then_some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// An ellipse of radii `rx`, `ry` about the origin: four smooth anchors from the top, clockwise.
|
||||
fn ellipse(rx: f64, ry: f64) -> SubPath {
|
||||
let (kx, ky) = (rx * KAPPA, ry * KAPPA);
|
||||
let s = |p: Point, out: Vec2| Anchor { p, h_in: p - out, h_out: p + out, kind: AnchorKind::Smooth };
|
||||
SubPath::new(
|
||||
vec![
|
||||
s(Point::new(0.0, -ry), Vec2::new(kx, 0.0)),
|
||||
s(Point::new(rx, 0.0), Vec2::new(0.0, ky)),
|
||||
s(Point::new(0.0, ry), Vec2::new(-kx, 0.0)),
|
||||
s(Point::new(-rx, 0.0), Vec2::new(0.0, -ky)),
|
||||
],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// A heart in the unit box (`at` scales it): two lobes over a point at the bottom, the notch
|
||||
/// between the lobes `depth` of the way down to the middle, the point rounder with `round`.
|
||||
fn heart(depth: f64, round: f64, at: &dyn Fn(f64, f64) -> Point) -> SubPath {
|
||||
let ny = -1.0 + depth;
|
||||
let notch = at(0.0, ny);
|
||||
let mirror = |x: f64, y: f64| [at(x, y), at(-x, y)];
|
||||
let [notch_out, notch_in] = mirror(0.1, ny - 0.3 * (1.0 + ny));
|
||||
let [tip_in, tip_out] = mirror(0.2 + 0.4 * round, 0.7 + 0.3 * round);
|
||||
let a = |p, h_in, h_out, kind| Anchor { p, h_in, h_out, kind };
|
||||
let smooth = AnchorKind::Smooth;
|
||||
SubPath::new(
|
||||
vec![
|
||||
a(notch, notch_in, notch_out, AnchorKind::Corner),
|
||||
a(at(0.5, -1.0), at(0.2, -1.0), at(0.8, -1.0), smooth),
|
||||
a(at(1.0, -0.35), at(1.0, -0.75), at(1.0, 0.1), smooth),
|
||||
a(at(0.0, 1.0), tip_in, tip_out, AnchorKind::Corner),
|
||||
a(at(-1.0, -0.35), at(-1.0, 0.1), at(-1.0, -0.75), smooth),
|
||||
a(at(-0.5, -1.0), at(-0.8, -1.0), at(-0.2, -1.0), smooth),
|
||||
],
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// The affine map taking each of `from` to the point of `to` at its index, worked out from
|
||||
/// three of them far apart; `None` when they all lie on a line.
|
||||
fn fit_affine(from: &[Point], to: &[Point]) -> Option<Affine> {
|
||||
let (&p0, &q0) = (from.first()?, to.first()?);
|
||||
let far = (0..from.len()).max_by(|&i, &j| dist(from, i, p0).total_cmp(&dist(from, j, p0)))?;
|
||||
let p1 = *from.get(far)?;
|
||||
let wide = |i: usize| from.get(i).map_or(0.0, |p| (p1 - p0).cross(*p - p0).abs());
|
||||
let third = (0..from.len()).max_by(|&i, &j| wide(i).total_cmp(&wide(j)))?;
|
||||
let (p2, q1, q2) = (*from.get(third)?, *to.get(far)?, *to.get(third)?);
|
||||
let basis = |o: Point, u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, o.x, o.y]);
|
||||
let g = basis(p0, p1 - p0, p2 - p0);
|
||||
let det = g.determinant();
|
||||
// Too thin a triangle to tell a turn from a skew.
|
||||
if det.is_nan() || det.abs() <= 1e-9 * (p1 - p0).hypot2().max(1e-12) {
|
||||
return None;
|
||||
}
|
||||
Some(basis(q0, q1 - q0, q2 - q0) * g.inverse())
|
||||
}
|
||||
|
||||
fn dist(pts: &[Point], i: usize, from: Point) -> f64 {
|
||||
pts.get(i).map_or(0.0, |p| p.distance(from))
|
||||
}
|
||||
|
||||
/// Are `a` and `b` the same outline, anchor for anchor and handle for handle (to well under a
|
||||
/// thousandth of a point at the size of the art)?
|
||||
fn same(a: &PathData, b: &PathData) -> bool {
|
||||
let scale = a.anchors().map(|(_, _, x)| x.p.x.abs().max(x.p.y.abs())).fold(1.0, f64::max);
|
||||
let tol = 1e-3_f64.max(scale * 1e-9);
|
||||
a.subpaths.len() == b.subpaths.len()
|
||||
&& a.subpaths.iter().zip(&b.subpaths).all(|(s, t)| {
|
||||
s.closed == t.closed
|
||||
&& s.anchors.len() == t.anchors.len()
|
||||
&& s.anchors
|
||||
.iter()
|
||||
.zip(&t.anchors)
|
||||
.all(|(x, y)| x.p.distance(y.p) <= tol && x.h_in.distance(y.h_in) <= tol && x.h_out.distance(y.h_out) <= tol)
|
||||
})
|
||||
}
|
||||
|
||||
/// A fingerprint of `path`'s geometry: its subpaths and every anchor and handle to a thousandth of
|
||||
/// a point. Kept with a shape, it shows whether the path is still the one drawn.
|
||||
pub fn fingerprint(path: &PathData) -> u64 {
|
||||
// FNV-1a: the same in every build, as a fingerprint saved in a file must be.
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
let mut eat = |v: i64| {
|
||||
for byte in v.to_le_bytes() {
|
||||
h = (h ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
};
|
||||
for sp in &path.subpaths {
|
||||
eat(sp.anchors.len() as i64);
|
||||
eat(i64::from(sp.closed));
|
||||
for a in &sp.anchors {
|
||||
for p in [a.p, a.h_in, a.h_out] {
|
||||
// Saturating casts: a huge or non-finite coordinate still hashes.
|
||||
eat((p.x * 1000.0).round() as i64);
|
||||
eat((p.y * 1000.0).round() as i64);
|
||||
}
|
||||
}
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The parametric shape a plain closed path is, if it is one: a rectangle (four corners, no
|
||||
/// handles), an ellipse (four smooth anchors) or a polygon whose sides and corners are all equal,
|
||||
/// stretched along its own axes at most. The shape's outline starts at its own first anchor, so
|
||||
/// the path's anchors may come in a different order.
|
||||
pub fn recognise(path: &PathData) -> Option<ParametricShape> {
|
||||
let [sp] = path.subpaths.as_slice() else { return None };
|
||||
let n = sp.anchors.len();
|
||||
if !sp.closed || !(3..=MAX_COUNT as usize).contains(&n) {
|
||||
return None;
|
||||
}
|
||||
let corners = sp.anchors.iter().all(|a| !a.has_in() && !a.has_out());
|
||||
let kinds: &[ShapeKind] = match (n, corners) {
|
||||
(4, true) => &[ShapeKind::Rectangle, ShapeKind::Polygon],
|
||||
(4, false) => &[ShapeKind::Ellipse],
|
||||
(_, true) => &[ShapeKind::Polygon],
|
||||
_ => return None,
|
||||
};
|
||||
let unit = |kind| ParametricShape { count: n as u32, ..ParametricShape::new(kind, Point::ZERO, 2.0, 2.0) };
|
||||
// The path read from each anchor in turn, either way round.
|
||||
for reversed in [false, true] {
|
||||
let mut sp = sp.clone();
|
||||
if reversed {
|
||||
sp.reverse();
|
||||
}
|
||||
for _ in 0..n {
|
||||
let candidate = PathData::single(sp.clone());
|
||||
if let Some(s) = kinds.iter().find_map(|k| unit(*k).recover(&candidate)) {
|
||||
return Some(s);
|
||||
}
|
||||
sp.anchors.rotate_left(1);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(kind: ShapeKind) -> ParametricShape {
|
||||
ParametricShape::new(kind, Point::new(100.0, 50.0), 80.0, 60.0)
|
||||
}
|
||||
|
||||
fn closed(p: &PathData) -> bool {
|
||||
p.subpaths.iter().all(|s| s.closed)
|
||||
}
|
||||
|
||||
/// Every point of `p` lies in the shape's box (a little slack for curves).
|
||||
fn within_box(p: &PathData, s: &ParametricShape) -> bool {
|
||||
let b = p.bounds().unwrap();
|
||||
let (hw, hh) = (s.width / 2.0 + 1e-6, s.height / 2.0 + 1e-6);
|
||||
b.x0 >= s.center.x - hw && b.x1 <= s.center.x + hw && b.y0 >= s.center.y - hh && b.y1 <= s.center.y + hh
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_kind_draws_a_closed_outline_in_its_box() {
|
||||
for kind in ShapeKind::ALL {
|
||||
let s = shape(kind);
|
||||
assert_eq!(ShapeKind::parse(kind.id()), Some(kind));
|
||||
assert_eq!(s.check(), Ok(()), "{kind:?}");
|
||||
let p = s.path().unwrap();
|
||||
assert!(closed(&p) && within_box(&p, &s), "{kind:?}: {:?}", p.bounds());
|
||||
assert!(p.anchors().all(|(_, _, a)| a.p.x.is_finite() && a.p.y.is_finite()));
|
||||
}
|
||||
assert_eq!(ShapeKind::parse("cloud"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anchor_counts_and_corners_per_kind() {
|
||||
let count = |kind, n: u32| ParametricShape { count: n, ..shape(kind) }.path().unwrap().anchor_count();
|
||||
assert_eq!(count(ShapeKind::Rectangle, 3), 4);
|
||||
assert_eq!(count(ShapeKind::Ellipse, 3), 4);
|
||||
assert_eq!(count(ShapeKind::Polygon, 7), 7);
|
||||
assert_eq!(count(ShapeKind::Polygon, 1000), 1000);
|
||||
assert_eq!(count(ShapeKind::Star, 5), 10);
|
||||
assert_eq!(count(ShapeKind::Gear, 12), 48);
|
||||
assert_eq!(count(ShapeKind::Arrow, 3), 7);
|
||||
assert_eq!(count(ShapeKind::Cross, 3), 12);
|
||||
assert_eq!(count(ShapeKind::Heart, 3), 6);
|
||||
assert_eq!(count(ShapeKind::Bubble, 3), 7);
|
||||
let ring = shape(ShapeKind::Ring).path().unwrap();
|
||||
assert_eq!(ring.subpaths.len(), 2);
|
||||
// The hole runs the other way, so it stays a hole under the non-zero rule.
|
||||
assert!(ring.subpaths[0].signed_area() * ring.subpaths[1].signed_area() < 0.0);
|
||||
let (outer, inner) = (ring.subpaths[0].area().abs(), ring.subpaths[1].area().abs());
|
||||
assert!((inner / outer - 0.25).abs() < 1e-3, "inner ratio 0.5 → a quarter of the area: {}", inner / outer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polygons_and_stars_stand_upright_and_turn_clockwise() {
|
||||
let p = shape(ShapeKind::Polygon).path().unwrap();
|
||||
let top = p.subpaths[0].anchors[0].p;
|
||||
assert!(top.distance(Point::new(100.0, 20.0)) < 1e-9, "first corner straight up: {top:?}");
|
||||
let turned = ParametricShape { rotation: 90.0, ..shape(ShapeKind::Polygon) }.path().unwrap();
|
||||
let right = turned.subpaths[0].anchors[0].p;
|
||||
assert!(right.distance(Point::new(130.0, 50.0)) < 1e-9, "a quarter turn clockwise puts it on the right: {right:?}");
|
||||
let star = shape(ShapeKind::Star).path().unwrap();
|
||||
let inner = star.subpaths[0].anchors[1].p.distance(Point::new(100.0, 50.0));
|
||||
let outer = star.subpaths[0].anchors[0].p.distance(Point::new(100.0, 50.0));
|
||||
assert!((outer - 30.0).abs() < 1e-9 && inner < outer * 0.6, "{outer} {inner}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_change_each_kind() {
|
||||
let differ = |kind: ShapeKind, a: f64, b: f64| {
|
||||
let s = shape(kind);
|
||||
let t = ParametricShape { a, b, ..s };
|
||||
assert_eq!(t.check(), Ok(()), "{kind:?}");
|
||||
assert_ne!(s.path(), t.path(), "{kind:?} a {a} b {b}");
|
||||
};
|
||||
differ(ShapeKind::Star, 0.2, 0.0);
|
||||
differ(ShapeKind::Ring, 0.8, 0.0);
|
||||
differ(ShapeKind::Arrow, 0.6, 0.4);
|
||||
differ(ShapeKind::Arrow, 0.4, 0.2);
|
||||
differ(ShapeKind::Cross, 0.5, 1.0 / 3.0);
|
||||
differ(ShapeKind::Cross, 0.0, 0.6);
|
||||
differ(ShapeKind::Heart, 0.8, 0.0);
|
||||
differ(ShapeKind::Heart, 0.4, 1.0);
|
||||
differ(ShapeKind::Gear, 0.5, 0.0);
|
||||
differ(ShapeKind::Bubble, 0.5, -0.4);
|
||||
differ(ShapeKind::Bubble, 0.25, 0.8);
|
||||
// An arrow's head reaches down `a` of the height.
|
||||
let arrow = ParametricShape { a: 0.5, ..shape(ShapeKind::Arrow) }.path().unwrap();
|
||||
assert!((arrow.subpaths[0].anchors[1].p.y - 50.0).abs() < 1e-9);
|
||||
// A gear's roots sit `a` of the radius in.
|
||||
let gear = shape(ShapeKind::Gear).path().unwrap();
|
||||
let root = gear.subpaths[0].anchors[0].p - Point::new(100.0, 50.0);
|
||||
assert!((root.x / 40.0).hypot(root.y / 30.0) - 0.8 < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corners_round_but_never_past_the_middle_of_a_side() {
|
||||
let sharp = shape(ShapeKind::Rectangle);
|
||||
let round = ParametricShape { radius: 10.0, ..sharp };
|
||||
let p = round.path().unwrap();
|
||||
assert_eq!(p.anchor_count(), 8);
|
||||
assert_eq!(p.bounds(), sharp.path().unwrap().bounds());
|
||||
// Far too big a radius: the corners meet in the middle of the short sides.
|
||||
let huge = ParametricShape { radius: 1000.0, ..sharp }.path().unwrap();
|
||||
let b = huge.bounds().unwrap();
|
||||
assert!((b.width() - 80.0).abs() < 1e-6 && (b.height() - 60.0).abs() < 1e-6);
|
||||
// Kinds without corners ignore it.
|
||||
let e = ParametricShape { radius: 10.0, ..shape(ShapeKind::Ellipse) };
|
||||
assert_eq!(e.path(), shape(ShapeKind::Ellipse).path());
|
||||
for kind in ShapeKind::ALL.into_iter().filter(|k| k.rounds()) {
|
||||
let p = ParametricShape { radius: 3.0, ..shape(kind) }.path().unwrap();
|
||||
assert!(p.anchor_count() > shape(kind).path().unwrap().anchor_count(), "{kind:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_out_of_range_are_refused() {
|
||||
let s = shape(ShapeKind::Star);
|
||||
let bad = [
|
||||
(ParametricShape { width: 0.0, ..s }, "width"),
|
||||
(ParametricShape { height: MAX_SIZE * 2.0, ..s }, "height"),
|
||||
(ParametricShape { width: f64::NAN, ..s }, "width"),
|
||||
(ParametricShape { count: 2, ..s }, "count"),
|
||||
(ParametricShape { count: MAX_COUNT + 1, ..s }, "count"),
|
||||
(ParametricShape { a: 1.5, ..s }, "a"),
|
||||
(ParametricShape { a: f64::NAN, ..s }, "a"),
|
||||
(ParametricShape { radius: -1.0, ..s }, "radius"),
|
||||
(ParametricShape { rotation: f64::INFINITY, ..s }, "rotation"),
|
||||
(ParametricShape { center: Point::new(1e300, 0.0), ..s }, "center"),
|
||||
];
|
||||
for (shape, key) in bad {
|
||||
assert_eq!(shape.check().map_err(|e| e.0), Err(key));
|
||||
assert_eq!(shape.path(), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_kind_keeps_the_place_and_takes_the_new_kinds_defaults() {
|
||||
let s = ParametricShape { rotation: 30.0, radius: 4.0, count: 9, ..shape(ShapeKind::Polygon) };
|
||||
let star = s.with_kind(ShapeKind::Star);
|
||||
assert_eq!((star.center, star.width, star.rotation, star.radius, star.count, star.a), (s.center, 80.0, 30.0, 4.0, 5, 0.5));
|
||||
let arrow = star.with_kind(ShapeKind::Arrow);
|
||||
assert_eq!((arrow.count, arrow.a, arrow.b), (6, 0.4, 0.4));
|
||||
assert_eq!(arrow.check(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_turned_or_scaled_shape_is_recovered() {
|
||||
for kind in ShapeKind::ALL {
|
||||
let s = ParametricShape { rotation: 20.0, ..shape(kind) };
|
||||
let p = s.path().unwrap();
|
||||
assert_eq!(s.recover(&p).map(|r| r.kind), Some(kind), "unchanged {kind:?}");
|
||||
let xf = Affine::translate((40.0, -15.0)) * Affine::rotate_about(0.5, s.center);
|
||||
let r = s.recover(&p.transformed(xf)).unwrap_or_else(|| panic!("{kind:?} moved and turned"));
|
||||
assert!((r.rotation - (20.0 + 0.5f64.to_degrees())).abs() < 1e-6 && (r.width - 80.0).abs() < 1e-6, "{r:?}");
|
||||
assert!(r.center.distance(Point::new(140.0, 35.0)) < 1e-6);
|
||||
// Scaled along its own axes.
|
||||
let along = s.frame() * Affine::scale_non_uniform(1.5, 0.5) * s.frame().inverse();
|
||||
let r = s.recover(&p.transformed(along)).unwrap_or_else(|| panic!("{kind:?} scaled"));
|
||||
assert!((r.width - 120.0).abs() < 1e-6 && (r.height - 30.0).abs() < 1e-6, "{kind:?} {r:?}");
|
||||
}
|
||||
// Rounded corners scale uniformly only.
|
||||
let s = ParametricShape { radius: 5.0, ..shape(ShapeKind::Star) };
|
||||
let p = s.path().unwrap();
|
||||
let r = s.recover(&p.transformed(Affine::scale_about(2.0, s.center))).unwrap();
|
||||
assert!((r.radius - 10.0).abs() < 1e-9 && (r.width - 160.0).abs() < 1e-9);
|
||||
assert_eq!(s.recover(&p.transformed(Affine::scale_non_uniform(2.0, 1.0))), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edited_skewed_or_mirrored_shapes_are_not() {
|
||||
let s = shape(ShapeKind::Star);
|
||||
let p = s.path().unwrap();
|
||||
let mut edited = p.clone();
|
||||
edited.anchor_mut(0, 3).unwrap().translate(Vec2::new(5.0, 0.0));
|
||||
assert_eq!(s.recover(&edited), None);
|
||||
assert_eq!(s.recover(&p.transformed(Affine::skew(0.3, 0.0))), None);
|
||||
assert_eq!(s.recover(&p.transformed(Affine::FLIP_X)), None);
|
||||
let mut more = p.clone();
|
||||
more.subpaths[0].insert_anchor(0, 0.5);
|
||||
assert_eq!(s.recover(&more), None);
|
||||
assert_eq!(s.recover(&PathData::default()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fingerprint_follows_the_geometry() {
|
||||
let p = shape(ShapeKind::Gear).path().unwrap();
|
||||
assert_eq!(fingerprint(&p), fingerprint(&p.clone()));
|
||||
assert_eq!(fingerprint(&p), fingerprint(&p.transformed(Affine::translate((1e-5, 0.0)))), "below a thousandth of a point");
|
||||
assert_ne!(fingerprint(&p), fingerprint(&p.transformed(Affine::translate((0.01, 0.0)))));
|
||||
let mut edited = p.clone();
|
||||
edited.anchor_mut(0, 5).unwrap().h_out.x += 1.0;
|
||||
assert_ne!(fingerprint(&p), fingerprint(&edited));
|
||||
let mut weird = p.clone();
|
||||
weird.anchor_mut(0, 0).unwrap().p.x = f64::NAN;
|
||||
let _ = fingerprint(&weird);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_rectangles_ellipses_and_regular_polygons_are_recognised() {
|
||||
use vectorcraft_geom::shapes;
|
||||
let r = recognise(&shapes::rectangle(kurbo::Rect::new(10.0, 20.0, 110.0, 70.0))).unwrap();
|
||||
assert_eq!(r.kind, ShapeKind::Rectangle);
|
||||
assert!(r.center.distance(Point::new(60.0, 45.0)) < 1e-9);
|
||||
let (w, h) = if r.rotation.abs() < 1e-6 || (r.rotation.abs() - 180.0).abs() < 1e-6 { (r.width, r.height) } else { (r.height, r.width) };
|
||||
assert!((w - 100.0).abs() < 1e-9 && (h - 50.0).abs() < 1e-9, "{r:?}");
|
||||
let e = recognise(&shapes::ellipse(kurbo::Rect::new(0.0, 0.0, 40.0, 20.0))).unwrap();
|
||||
assert_eq!(e.kind, ShapeKind::Ellipse);
|
||||
assert!(e.center.distance(Point::new(20.0, 10.0)) < 1e-9);
|
||||
let poly = recognise(&shapes::polygon(Point::new(50.0, 50.0), 30.0, 7, 10.0)).unwrap();
|
||||
assert_eq!((poly.kind, poly.count), (ShapeKind::Polygon, 7));
|
||||
assert!((poly.width - 60.0).abs() < 1e-6);
|
||||
// Its outline is the polygon's, if from another first anchor.
|
||||
let (a, b) = (poly.path().unwrap().bounds().unwrap(), shapes::polygon(Point::new(50.0, 50.0), 30.0, 7, 10.0).bounds().unwrap());
|
||||
assert!(a.center().distance(b.center()) < 1e-9 && (a.area() - b.area()).abs() < 1e-6, "{a:?} {b:?}");
|
||||
// Not regular, open, or a star: no.
|
||||
assert_eq!(recognise(&PathData::single(SubPath::polyline(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(3.0, 8.0)], true))), None);
|
||||
assert_eq!(recognise(&PathData::single(SubPath::polyline(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(10.0, 10.0)], false))), None);
|
||||
assert_eq!(recognise(&shapes::star(Point::ZERO, 10.0, 4.0, 5, 0.0)), None);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
pub mod anchor_eraser;
|
||||
pub mod extend_path;
|
||||
pub mod measure_inspect;
|
||||
pub mod parametric_shape;
|
||||
pub mod smart_pen;
|
||||
|
||||
use crate::{Tool, ToolInfo};
|
||||
@@ -14,6 +15,7 @@ pub const PATH_TOOLS: &[ToolInfo] = &[
|
||||
ToolInfo { id: anchor_eraser::ID, label: "Anchor Eraser Brush", shortcut: None, icon: "dc-remove-brush" },
|
||||
ToolInfo { id: extend_path::ID, label: "Extend Path Tool", shortcut: None, icon: "dc-arrow-extend" },
|
||||
ToolInfo { id: measure_inspect::ID, label: "Measure & Inspect Tool", shortcut: None, icon: "ruler" },
|
||||
ToolInfo { id: parametric_shape::ID, label: "Parametric Shape Tool", shortcut: None, icon: "hexagon" },
|
||||
];
|
||||
|
||||
/// Smart Pen's tool, a flyout group of its own.
|
||||
@@ -37,6 +39,7 @@ pub fn create(id: &str) -> Option<Box<dyn Tool>> {
|
||||
anchor_eraser::ID => Box::new(anchor_eraser::AnchorEraserTool::default()),
|
||||
extend_path::ID => Box::new(extend_path::ExtendPathTool::default()),
|
||||
measure_inspect::ID => Box::new(measure_inspect::MeasureInspectTool::default()),
|
||||
parametric_shape::ID => Box::new(parametric_shape::ParametricShapeTool::default()),
|
||||
smart_pen::ID => Box::new(smart_pen::SmartPenTool::default()),
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
//! Parametric Shape Tool (Path Precision Tools): shapes that stay editable as numbers. A drag from
|
||||
//! the centre draws one (`addon.pathtools.shape.create`): the drag's length is its size and its
|
||||
//! direction its rotation (Shift: 45° steps); ↑/↓ change the kind's count or first value while
|
||||
//! dragging, ←/→ its second value or corner radius. A click selects a parametric shape and shows
|
||||
//! its handles (centre, size, rotation, corner radius and one of the kind's own), whose drags run
|
||||
//! `addon.pathtools.shape.set`; a double-click on the canvas places one with the last settings.
|
||||
//! Each gesture is one undo step.
|
||||
//!
|
||||
//! A parametric shape is an ordinary path that keeps its numbers in one entry of its object data
|
||||
//! ([`KEY`]) with a fingerprint of the outline they drew. Moved, turned or scaled by any tool it
|
||||
//! stays editable ([`ParametricShape::recover`]); once its anchors are edited it is a plain path.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::hit::hit_test;
|
||||
use vectorcraft_doc::{Node, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools::shapes::fingerprint;
|
||||
pub use vectorcraft_pathops::addons::pathtools::shapes::{MAX_COUNT, MAX_SIZE, MIN_COUNT, MIN_SIZE, ParametricShape, ShapeKind};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
/// The tool's id.
|
||||
pub const ID: &str = "parametricShape";
|
||||
/// The object data entry that holds a parametric shape's numbers (JSON). The `vc-` prefix marks it
|
||||
/// as VectorCraft's own, which SVG export leaves out of the `data-*` attributes it writes.
|
||||
pub const KEY: &str = "vc-pathtools-shape";
|
||||
/// The longest entry read back (a damaged file's longer one is no shape).
|
||||
const MAX_STORED: usize = 4096;
|
||||
/// The undo steps' names.
|
||||
pub const CREATE: &str = "Parametric Shape";
|
||||
pub const EDIT: &str = "Edit Parametric Shape";
|
||||
const BLUE: [u8; 3] = [0x4f, 0x9d, 0xff];
|
||||
/// How far (px) the rotation handle stands above the shape.
|
||||
const STEM_PX: f64 = 24.0;
|
||||
|
||||
/// `s` as commands take it and files keep it.
|
||||
pub fn to_json(s: &ParametricShape) -> Value {
|
||||
json!({
|
||||
"kind": s.kind.id(), "center": [s.center.x, s.center.y], "width": s.width, "height": s.height,
|
||||
"rotation": s.rotation, "count": s.count, "a": s.a, "b": s.b, "radius": s.radius,
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` with the numbers `p` gives (as [`to_json`] writes them; any left out stay as they are). A
|
||||
/// new `kind` comes first, with its own defaults for what `p` leaves out. Every number is checked:
|
||||
/// the error says which is wrong.
|
||||
pub fn apply(mut s: ParametricShape, p: &Value) -> Result<ParametricShape, String> {
|
||||
let given = |key: &str| p.get(key).filter(|v| !v.is_null());
|
||||
let num = |key: &str| -> Result<Option<f64>, String> {
|
||||
given(key).map(|v| v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| format!("`{key}` must be a number"))).transpose()
|
||||
};
|
||||
if let Some(k) = given("kind") {
|
||||
let kind = k.as_str().and_then(ShapeKind::parse);
|
||||
s = s.with_kind(kind.ok_or("`kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble")?);
|
||||
}
|
||||
if let Some(c) = given("center") {
|
||||
let xy = c.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let (x, y) = xy.ok_or("`center` must be [x, y]")?;
|
||||
s.center = Point::new(x, y);
|
||||
}
|
||||
for (key, slot) in [("width", &mut s.width), ("height", &mut s.height), ("a", &mut s.a), ("b", &mut s.b), ("radius", &mut s.radius)] {
|
||||
if let Some(v) = num(key)? {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
if let Some(r) = num("rotation")? {
|
||||
s.rotation = vectorcraft_geom::normalize_deg(r);
|
||||
}
|
||||
if let Some(n) = num("count")? {
|
||||
if n.fract() != 0.0 || !(f64::from(MIN_COUNT)..=f64::from(MAX_COUNT)).contains(&n) {
|
||||
return Err("`count` must be a whole number from 3 to 1000".into());
|
||||
}
|
||||
s.count = n as u32;
|
||||
}
|
||||
s.check().map_err(|(key, lo, hi)| format!("`{key}` must be from {lo} to {hi}"))?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// What a parametric shape keeps in its object data ([`KEY`]): its numbers and the fingerprint of
|
||||
/// `path`, the outline they drew.
|
||||
pub fn stored(s: &ParametricShape, path: &PathData) -> String {
|
||||
let mut v = to_json(s);
|
||||
v["fp"] = json!(format!("{:016x}", fingerprint(path)));
|
||||
v.to_string()
|
||||
}
|
||||
|
||||
/// The parametric shape object `n` is, if it is one: a path (not a guide) with a [`KEY`] entry
|
||||
/// whose fingerprint is its outline's, or which it is moved, turned or scaled. `None` for any
|
||||
/// other object, a damaged entry, or a path whose anchors were edited since.
|
||||
pub fn read(n: &Node) -> Option<ParametricShape> {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return None };
|
||||
let (_, text) = n.extras().attrs.as_deref()?.data.iter().find(|(k, _)| k == KEY)?;
|
||||
if text.len() > MAX_STORED {
|
||||
return None;
|
||||
}
|
||||
let v: Value = serde_json::from_str(text).ok()?;
|
||||
let s = apply(ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), &v).ok()?;
|
||||
let fp = v.get("fp").and_then(Value::as_str).and_then(|f| u64::from_str_radix(f, 16).ok())?;
|
||||
if fp == fingerprint(path) { Some(s) } else { s.recover(path) }
|
||||
}
|
||||
|
||||
/// The values the arrow keys change while drawing: ↑/↓ the first, ←/→ the second.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Field {
|
||||
Count,
|
||||
A,
|
||||
B,
|
||||
Radius,
|
||||
}
|
||||
|
||||
/// The kind's (↑/↓, ←/→) values.
|
||||
fn arrow_fields(kind: ShapeKind) -> (Option<Field>, Option<Field>) {
|
||||
match kind {
|
||||
ShapeKind::Rectangle => (None, Some(Field::Radius)),
|
||||
ShapeKind::Ellipse => (None, None),
|
||||
ShapeKind::Polygon => (Some(Field::Count), Some(Field::Radius)),
|
||||
ShapeKind::Star | ShapeKind::Gear => (Some(Field::Count), Some(Field::A)),
|
||||
ShapeKind::Ring => (Some(Field::A), None),
|
||||
ShapeKind::Arrow | ShapeKind::Cross | ShapeKind::Heart | ShapeKind::Bubble => (Some(Field::A), Some(Field::B)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` with `field` one step up (`up`) or down: a count by one, a value by 5 % of its range, the
|
||||
/// corner radius by `step` pt; each kept within its range.
|
||||
fn stepped(mut s: ParametricShape, field: Field, up: bool, step: f64) -> ParametricShape {
|
||||
let sign = if up { 1.0 } else { -1.0 };
|
||||
let value = |v: f64, slot: usize| match s.kind.values().get(slot).copied().flatten() {
|
||||
Some(r) => (v + sign * 0.05 * (r.max - r.min)).clamp(r.min, r.max),
|
||||
None => v,
|
||||
};
|
||||
match field {
|
||||
Field::Count => s.count = if up { (s.count + 1).min(MAX_COUNT) } else { s.count.saturating_sub(1).max(MIN_COUNT) },
|
||||
Field::A => s.a = value(s.a, 0),
|
||||
Field::B => s.b = value(s.b, 1),
|
||||
Field::Radius => s.radius = (s.radius + sign * step).clamp(0.0, MAX_SIZE),
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The handles of a selected shape.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Grip {
|
||||
/// The centre: moves the shape.
|
||||
Move,
|
||||
/// The box's lower right corner: sizes it about its centre (Shift keeps its proportions).
|
||||
Size,
|
||||
/// Above the top: turns it (Shift: 45° steps).
|
||||
Rotate,
|
||||
/// On the box's top edge, the radius in from its left end: the corner radius.
|
||||
Corner,
|
||||
/// One of the kind's own values, where it shows.
|
||||
Value,
|
||||
}
|
||||
|
||||
/// Where the kind's own value handle is, in the shape's unit box (−1..1 each way, y down).
|
||||
fn value_spot(s: &ParametricShape) -> Option<Point> {
|
||||
let n = f64::from(s.count.max(1));
|
||||
Some(match s.kind {
|
||||
ShapeKind::Star => {
|
||||
let (sn, cs) = (std::f64::consts::PI / n).sin_cos();
|
||||
Point::new(s.a * sn, -s.a * cs)
|
||||
}
|
||||
ShapeKind::Ring => Point::new(0.0, -s.a),
|
||||
ShapeKind::Gear => Point::new(0.0, -(1.0 - s.a)),
|
||||
ShapeKind::Arrow => Point::new(1.0, -1.0 + 2.0 * s.a),
|
||||
ShapeKind::Cross => Point::new(-1.0, s.a * (1.0 - s.b)),
|
||||
ShapeKind::Heart => Point::new(0.0, -1.0 + s.a),
|
||||
ShapeKind::Bubble => Point::new(s.b, 1.0),
|
||||
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// `s` with its own value set from the handle dragged to `u` (unit box), within its range.
|
||||
fn value_from(mut s: ParametricShape, u: Point) -> ParametricShape {
|
||||
let clamp = |v: f64, slot: usize| s.kind.values().get(slot).copied().flatten().map_or(v, |r| v.clamp(r.min, r.max));
|
||||
match s.kind {
|
||||
ShapeKind::Star | ShapeKind::Ring => s.a = clamp(u.to_vec2().hypot(), 0),
|
||||
ShapeKind::Gear => s.a = clamp(1.0 - u.to_vec2().hypot(), 0),
|
||||
ShapeKind::Arrow => s.a = clamp((u.y + 1.0) / 2.0, 0),
|
||||
ShapeKind::Cross => s.a = clamp(u.y / (1.0 - s.b).max(1e-9), 0),
|
||||
ShapeKind::Heart => s.a = clamp(u.y + 1.0, 0),
|
||||
ShapeKind::Bubble => s.b = clamp(u.x, 1),
|
||||
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => {}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The handles of `s` and where they are on the page.
|
||||
fn grips(cx: &ToolContext, s: &ParametricShape) -> Vec<(Grip, Point)> {
|
||||
let (f, rx, ry) = (s.frame(), s.width / 2.0, s.height / 2.0);
|
||||
let mut out = vec![(Grip::Move, s.center), (Grip::Size, f * Point::new(rx, ry)), (Grip::Rotate, f * Point::new(0.0, -ry - cx.tol(STEM_PX)))];
|
||||
if s.kind.rounds() {
|
||||
out.push((Grip::Corner, f * Point::new(-rx + s.radius.min(rx), -ry)));
|
||||
}
|
||||
if let Some(u) = value_spot(s) {
|
||||
out.push((Grip::Value, f * Point::new(u.x * rx, u.y * ry)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The angle (degrees clockwise from straight up) of `p` seen from `c`; with Shift in 45° steps.
|
||||
fn angle(c: Point, p: Point, shift: bool) -> f64 {
|
||||
let d = p - c;
|
||||
let deg = d.x.atan2(-d.y).to_degrees();
|
||||
vectorcraft_geom::normalize_deg(if shift { (deg / 45.0).round() * 45.0 } else { deg })
|
||||
}
|
||||
|
||||
/// A drag under way.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Drag {
|
||||
/// Drawing a shape out from its centre.
|
||||
Create { center: Point },
|
||||
/// Dragging a handle of shape `id`, which was `shape` when the drag began at `from`.
|
||||
Edit { id: NodeId, shape: ParametricShape, grip: Grip, from: Point },
|
||||
}
|
||||
|
||||
pub struct ParametricShapeTool {
|
||||
/// What the next shape is (its kind and numbers, and the size and rotation a double-click
|
||||
/// gives it: the last drawn one's).
|
||||
next: ParametricShape,
|
||||
drag: Option<Drag>,
|
||||
/// Where the pointer last was during the drag, and the keys held.
|
||||
last: (Point, Mods),
|
||||
}
|
||||
|
||||
impl Default for ParametricShapeTool {
|
||||
fn default() -> Self {
|
||||
Self { next: ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0), drag: None, last: (Point::ZERO, Mods::default()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The one selected object when it is an editable parametric shape.
|
||||
fn selected(cx: &ToolContext) -> Option<(NodeId, ParametricShape)> {
|
||||
let [id] = cx.selection.objects.as_slice() else { return None };
|
||||
let s = read(cx.doc.node(*id)?)?;
|
||||
cx.doc.is_editable(*id).then_some((*id, s))
|
||||
}
|
||||
|
||||
/// The editable parametric shape under `p`.
|
||||
fn shape_at(cx: &ToolContext, p: Point) -> Option<NodeId> {
|
||||
let id = hit_test(cx.doc, p, cx.hit_options())?.leaf;
|
||||
(cx.doc.is_editable(id) && read(cx.doc.node(id)?).is_some()).then_some(id)
|
||||
}
|
||||
|
||||
impl ParametricShapeTool {
|
||||
/// The shape a drag to `p` with `mods` makes, and the command that makes it.
|
||||
fn shaped(&self, drag: &Drag, p: Point, mods: Mods) -> (&'static str, ParametricShape, Option<NodeId>) {
|
||||
let fit = |v: f64| v.clamp(MIN_SIZE, MAX_SIZE);
|
||||
match *drag {
|
||||
Drag::Create { center } => {
|
||||
let r = center.distance(p);
|
||||
let aspect = self.next.width / self.next.height;
|
||||
let s = ParametricShape {
|
||||
center,
|
||||
width: fit(2.0 * r * aspect),
|
||||
height: fit(2.0 * r),
|
||||
rotation: angle(center, p, mods.shift),
|
||||
..self.next
|
||||
};
|
||||
("addon.pathtools.shape.create", s, None)
|
||||
}
|
||||
Drag::Edit { id, shape: s, grip, from } => {
|
||||
let local = s.frame().inverse() * p;
|
||||
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
|
||||
let out = match grip {
|
||||
Grip::Move => ParametricShape { center: s.center + (p - from), ..s },
|
||||
Grip::Size if mods.shift => {
|
||||
let k = (local.x.abs() / rx).max(local.y.abs() / ry);
|
||||
ParametricShape { width: fit(s.width * k), height: fit(s.height * k), ..s }
|
||||
}
|
||||
Grip::Size => ParametricShape { width: fit(2.0 * local.x.abs()), height: fit(2.0 * local.y.abs()), ..s },
|
||||
Grip::Rotate => ParametricShape { rotation: angle(s.center, p, mods.shift), ..s },
|
||||
Grip::Corner => ParametricShape { radius: (local.x + rx).clamp(0.0, rx.min(MAX_SIZE)), ..s },
|
||||
Grip::Value => value_from(s, Point::new(local.x / rx, local.y / ry)),
|
||||
};
|
||||
("addon.pathtools.shape.set", out, Some(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The preview of the drag under way at the pointer's last place.
|
||||
fn preview(&self) -> Option<Action> {
|
||||
let drag = self.drag.as_ref()?;
|
||||
let (cmd, s, id) = self.shaped(drag, self.last.0, self.last.1);
|
||||
let mut p = to_json(&s);
|
||||
if let Some(id) = id {
|
||||
p["id"] = json!(id.0);
|
||||
}
|
||||
Some(Action::Preview(cmd.into(), p))
|
||||
}
|
||||
}
|
||||
|
||||
impl Tool for ParametricShapeTool {
|
||||
fn id(&self) -> &'static str {
|
||||
ID
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.drag.is_some()
|
||||
}
|
||||
|
||||
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
|
||||
match ev.kind {
|
||||
PointerKind::Move => vec![],
|
||||
PointerKind::Down => {
|
||||
let tol = cx.point_tol();
|
||||
let grip = selected(cx).and_then(|(id, s)| {
|
||||
let near = grips(cx, &s).into_iter().filter(|(_, at)| at.distance(ev.pos) <= tol);
|
||||
near.min_by(|a, b| a.1.distance(ev.pos).total_cmp(&b.1.distance(ev.pos))).map(|(g, _)| (id, s, g))
|
||||
});
|
||||
if let Some((id, shape, grip)) = grip {
|
||||
self.drag = Some(Drag::Edit { id, shape, grip, from: ev.pos });
|
||||
self.last = (ev.pos, ev.mods);
|
||||
return vec![Action::Begin(EDIT.into())];
|
||||
}
|
||||
if let Some(id) = shape_at(cx, ev.pos) {
|
||||
return if cx.selection.objects == [id] { vec![] } else { vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))] };
|
||||
}
|
||||
let (center, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
|
||||
self.drag = Some(Drag::Create { center });
|
||||
self.last = (center, ev.mods);
|
||||
vec![Action::Begin(CREATE.into())]
|
||||
}
|
||||
PointerKind::Drag | PointerKind::Up => {
|
||||
if self.drag.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
self.last = (ev.pos, ev.mods);
|
||||
let Some(preview) = self.preview() else { return vec![] };
|
||||
if ev.kind == PointerKind::Drag {
|
||||
return vec![preview];
|
||||
}
|
||||
let Some(drag) = self.drag.take() else { return vec![] };
|
||||
match drag {
|
||||
// A click draws nothing (a double-click places a shape).
|
||||
Drag::Create { center } if center.distance(ev.pos) <= cx.tol(2.0) => vec![Action::Cancel],
|
||||
Drag::Create { .. } => {
|
||||
if let Action::Preview(_, p) = &preview
|
||||
&& let Ok(s) = apply(self.next, p)
|
||||
{
|
||||
self.next = ParametricShape { center: Point::ZERO, ..s };
|
||||
}
|
||||
vec![preview, Action::Commit]
|
||||
}
|
||||
Drag::Edit { from, .. } if from == ev.pos => vec![Action::Cancel],
|
||||
Drag::Edit { .. } => vec![preview, Action::Commit],
|
||||
}
|
||||
}
|
||||
PointerKind::DoubleClick => {
|
||||
if self.drag.is_some() || shape_at(cx, ev.pos).is_some() {
|
||||
return vec![];
|
||||
}
|
||||
let s = ParametricShape { center: ev.pos, ..self.next };
|
||||
vec![Action::Exec("addon.pathtools.shape.create".into(), to_json(&s))]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
|
||||
if key == ToolKey::Escape && self.drag.take().is_some() {
|
||||
return vec![Action::Cancel];
|
||||
}
|
||||
if !matches!(self.drag, Some(Drag::Create { .. })) {
|
||||
return vec![];
|
||||
}
|
||||
let (primary, secondary) = arrow_fields(self.next.kind);
|
||||
let (field, up) = match key {
|
||||
ToolKey::Up => (primary, true),
|
||||
ToolKey::Down => (primary, false),
|
||||
ToolKey::Right => (secondary, true),
|
||||
ToolKey::Left => (secondary, false),
|
||||
_ => return vec![],
|
||||
};
|
||||
let Some(field) = field else { return vec![] };
|
||||
self.next = stepped(self.next, field, up, cx.keyboard_increment.max(0.01));
|
||||
self.preview().into_iter().collect()
|
||||
}
|
||||
|
||||
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
|
||||
match self.drag {
|
||||
Some(Drag::Create { .. }) => matches!(key, ToolKey::Up | ToolKey::Down | ToolKey::Left | ToolKey::Right | ToolKey::Escape),
|
||||
Some(Drag::Edit { .. }) => key == ToolKey::Escape,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
|
||||
if let Some(Drag::Create { center }) = &self.drag {
|
||||
let (_, s, _) = self.shaped(&Drag::Create { center: *center }, self.last.0, self.last.1);
|
||||
let text = format!("{}\nAngle: {:.1}°", cx.size_label(s.width, s.height), s.rotation);
|
||||
return vec![Overlay::Measure { p: self.last.0 + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text }];
|
||||
}
|
||||
let Some((_, s)) = selected(cx) else { return vec![] };
|
||||
// The shape's box, then its handles.
|
||||
let (rx, ry) = (s.width / 2.0, s.height / 2.0);
|
||||
let mut bp = BezPath::new();
|
||||
let f: Affine = s.frame();
|
||||
let corners = [(-rx, -ry), (rx, -ry), (rx, ry), (-rx, ry)].map(|(x, y)| f * Point::new(x, y));
|
||||
for (i, p) in corners.iter().enumerate() {
|
||||
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
|
||||
}
|
||||
bp.close_path();
|
||||
let mut out = vec![Overlay::Path { path: bp, color: BLUE, width: 1.0, dashed: true }];
|
||||
for (grip, p) in grips(cx, &s) {
|
||||
if grip == Grip::Rotate {
|
||||
out.push(Overlay::Line { a: f * Point::new(0.0, -ry), b: p, color: BLUE, dashed: false });
|
||||
}
|
||||
out.push(match grip {
|
||||
Grip::Move | Grip::Size => Overlay::Anchor { p, color: BLUE, filled: grip == Grip::Move, size: 7.0 },
|
||||
Grip::Rotate => Overlay::Handle { p, color: BLUE },
|
||||
Grip::Corner | Grip::Value => Overlay::CurvePoint { p, color: BLUE, filled: grip == Grip::Value, size: 7.0 },
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
|
||||
Cursor::ParametricShape
|
||||
}
|
||||
|
||||
/// The next shape's numbers (its size and rotation are a double-click's).
|
||||
fn options(&self) -> Value {
|
||||
let mut v = to_json(&self.next);
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("center");
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn set_option(&mut self, key: &str, v: &Value) {
|
||||
if key != "center"
|
||||
&& let Ok(s) = apply(self.next, &json!({ key: v }))
|
||||
{
|
||||
self.next = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::testutil::*;
|
||||
use vectorcraft_doc::{Appearance, Document, Selection};
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::new(kind, x, y)
|
||||
}
|
||||
|
||||
/// A document with a parametric star about (250, 250), 100 × 100: the document and its id.
|
||||
fn doc_with_star() -> (Document, NodeId) {
|
||||
let mut d = Document::new(500.0, 500.0);
|
||||
let l = d.layers[0].id;
|
||||
let id = d.alloc_id();
|
||||
let s = ParametricShape::new(ShapeKind::Star, Point::new(250.0, 250.0), 100.0, 100.0);
|
||||
let path = s.path().unwrap();
|
||||
let mut n = Node::path(id, path.clone(), Appearance::default_art());
|
||||
n.edit_attrs(|a| a.data.push((KEY.into(), stored(&s, &path))));
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
}
|
||||
|
||||
fn preview_of(acts: &[Action]) -> (String, Value) {
|
||||
match acts.first() {
|
||||
Some(Action::Preview(c, p)) => (c.clone(), p.clone()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_from_the_centre_draws_sized_and_turned_and_arrows_change_its_numbers() {
|
||||
let (d, _) = doc_with_rect();
|
||||
let (sel, p) = (Selection::default(), paint());
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let mut t = ParametricShapeTool::default();
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)), vec![Action::Begin(CREATE.into())]);
|
||||
// 40 pt to the right: 80 across, turned a quarter clockwise.
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::Drag, 340.0, 300.0));
|
||||
let (cmd, p) = preview_of(&acts);
|
||||
assert_eq!(cmd, "addon.pathtools.shape.create");
|
||||
assert_eq!(
|
||||
(p["kind"].clone(), p["center"].clone(), p["width"].clone(), p["rotation"].clone()),
|
||||
(json!("polygon"), json!([300.0, 300.0]), json!(80.0), json!(90.0))
|
||||
);
|
||||
assert!(t.claims_key(&cx, ToolKey::Up) && t.busy());
|
||||
// ↑ adds a side, ← takes a point off the corner radius (none left: stays 0), → adds one.
|
||||
let (_, p) = preview_of(&t.key(&cx, ToolKey::Up, Mods::default()));
|
||||
assert_eq!(p["count"], json!(7));
|
||||
let (_, p) = preview_of(&t.key(&cx, ToolKey::Right, Mods::default()));
|
||||
assert_eq!(p["radius"], json!(1.0));
|
||||
// Shift turns in 45° steps.
|
||||
let shifted = ev(PointerKind::Drag, 340.0, 290.0).with_mods(Mods { shift: true, ..Mods::default() });
|
||||
let (_, p) = preview_of(&t.pointer(&cx, &shifted));
|
||||
assert_eq!(p["rotation"], json!(90.0));
|
||||
let up = t.pointer(&cx, &ev(PointerKind::Up, 300.0, 250.0));
|
||||
assert!(
|
||||
matches!(up.as_slice(), [Action::Preview(_, p), Action::Commit] if p["rotation"] == json!(0.0) && p["height"] == json!(100.0)),
|
||||
"{up:?}"
|
||||
);
|
||||
assert!(!t.busy() && !t.claims_key(&cx, ToolKey::Up), "arrows nudge again");
|
||||
// The next shape remembers the numbers; a double-click places it.
|
||||
assert_eq!((t.options()["count"].clone(), t.options()["radius"].clone()), (json!(7), json!(1.0)));
|
||||
let acts = t.pointer(&cx, &ev(PointerKind::DoubleClick, 50.0, 400.0));
|
||||
let [Action::Exec(cmd, p)] = acts.as_slice() else { panic!("{acts:?}") };
|
||||
assert_eq!(
|
||||
(cmd.as_str(), p["center"].clone(), p["count"].clone(), p["height"].clone()),
|
||||
("addon.pathtools.shape.create", json!([50.0, 400.0]), json!(7), json!(100.0))
|
||||
);
|
||||
// A click without a drag draws nothing; Escape drops a drag.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 50.0, 50.0)), vec![Action::Cancel]);
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 50.0, 50.0));
|
||||
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::Cancel]);
|
||||
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::ParametricShape);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_kinds_arrow_keys_step_within_range() {
|
||||
let s = ParametricShape::new(ShapeKind::Star, Point::ZERO, 10.0, 10.0);
|
||||
assert_eq!(stepped(s, Field::Count, false, 1.0).count, 4);
|
||||
assert_eq!(stepped(ParametricShape { count: 3, ..s }, Field::Count, false, 1.0).count, 3);
|
||||
assert_eq!(stepped(ParametricShape { count: MAX_COUNT, ..s }, Field::Count, true, 1.0).count, MAX_COUNT);
|
||||
assert!((stepped(s, Field::A, true, 1.0).a - 0.5495).abs() < 1e-9);
|
||||
assert_eq!(stepped(ParametricShape { a: 1.0, ..s }, Field::A, true, 1.0).a, 1.0);
|
||||
for kind in ShapeKind::ALL {
|
||||
let (up, side) = arrow_fields(kind);
|
||||
let s = ParametricShape::new(kind, Point::ZERO, 10.0, 10.0);
|
||||
for f in [up, side].into_iter().flatten() {
|
||||
assert_eq!(stepped(s, f, true, 1.0).check(), Ok(()), "{kind:?} {f:?}");
|
||||
assert_eq!(stepped(s, f, false, 1.0).check(), Ok(()), "{kind:?} {f:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_selects_a_shape_and_its_handles_drag_into_set() {
|
||||
let (d, id) = doc_with_star();
|
||||
let p = paint();
|
||||
let none = Selection::default();
|
||||
let mut t = ParametricShapeTool::default();
|
||||
let acts = t.pointer(&cx(&d, &none, &p), &ev(PointerKind::Down, 250.0, 250.0));
|
||||
assert_eq!(acts, vec![Action::Exec("select.set".into(), json!({ "ids": [id.0] }))]);
|
||||
assert!(t.overlays(&cx(&d, &none, &p)).is_empty());
|
||||
let mut sel = Selection::default();
|
||||
sel.set([id]);
|
||||
let cx = cx(&d, &sel, &p);
|
||||
let ov = t.overlays(&cx);
|
||||
assert!(matches!(ov.first(), Some(Overlay::Path { dashed: true, .. })) && ov.len() >= 6, "{ov:?}");
|
||||
// The size handle, at the box's lower right corner: 150 × 120 now.
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0)), vec![Action::Begin(EDIT.into())]);
|
||||
let (cmd, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 325.0, 310.0)));
|
||||
assert_eq!(
|
||||
(cmd.as_str(), p["id"].clone(), p["width"].clone(), p["height"].clone()),
|
||||
("addon.pathtools.shape.set", json!(id.0), json!(150.0), json!(120.0))
|
||||
);
|
||||
assert!(matches!(t.pointer(&cx, &ev(PointerKind::Up, 325.0, 310.0)).as_slice(), [Action::Preview(..), Action::Commit]));
|
||||
// The centre moves it.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 251.0, 249.0));
|
||||
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 261.0, 229.0)));
|
||||
assert_eq!(p["center"], json!([260.0, 230.0]));
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
// The rotation handle above the top.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 250.0, 200.0 - STEM_PX));
|
||||
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 350.0, 250.0)));
|
||||
assert_eq!(p["rotation"], json!(90.0));
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
// The star's inner radius, on its first inner corner.
|
||||
let inner = Point::new(250.0, 250.0) + Vec2::new((36f64).to_radians().sin(), -(36f64).to_radians().cos()) * 25.0;
|
||||
t.pointer(&cx, &ev(PointerKind::Down, inner.x, inner.y));
|
||||
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 210.0)));
|
||||
assert!((p["a"].as_f64().unwrap() - 0.8).abs() < 1e-9, "{p}");
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
// The corner radius on the top edge.
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
|
||||
let (_, p) = preview_of(&t.pointer(&cx, &ev(PointerKind::Drag, 212.0, 200.0)));
|
||||
assert_eq!(p["radius"], json!(12.0));
|
||||
// Pressing and letting go of a handle without moving changes nothing.
|
||||
t.key(&cx, ToolKey::Escape, Mods::default());
|
||||
t.pointer(&cx, &ev(PointerKind::Down, 300.0, 300.0));
|
||||
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Cancel]);
|
||||
// A double-click on a shape places none.
|
||||
assert!(t.pointer(&cx, &ev(PointerKind::DoubleClick, 250.0, 250.0)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_numbers_are_read_back_only_while_the_path_is_the_shape() {
|
||||
let (mut d, id) = doc_with_star();
|
||||
let s = read(d.node(id).unwrap()).unwrap();
|
||||
assert_eq!((s.kind, s.center, s.count), (ShapeKind::Star, Point::new(250.0, 250.0), 5));
|
||||
// Moved: still the shape, about its new centre.
|
||||
d.node_mut(id).unwrap().path_data_mut().unwrap().transform(Affine::translate((10.0, 0.0)));
|
||||
assert_eq!(read(d.node(id).unwrap()).map(|s| s.center), Some(Point::new(260.0, 250.0)));
|
||||
// An anchor moved: a plain path.
|
||||
d.node_mut(id).unwrap().path_data_mut().unwrap().anchor_mut(0, 2).unwrap().p.x += 3.0;
|
||||
assert_eq!(read(d.node(id).unwrap()), None);
|
||||
// Damaged entries are no shape.
|
||||
for bad in ["", "{", "[]", r#"{"kind":"blob"}"#, r#"{"kind":"star","count":5000,"fp":"0"}"#, r#"{"kind":"star"}"#] {
|
||||
let mut n = d.node(id).unwrap().clone();
|
||||
n.edit_attrs(|a| a.data = vec![(KEY.into(), bad.into())]);
|
||||
assert_eq!(read(&n), None, "{bad}");
|
||||
}
|
||||
let plain = Node::path(NodeId(99), PathData::default(), Appearance::default_art());
|
||||
assert_eq!(read(&plain), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_set_the_next_shape_and_refuse_bad_values() {
|
||||
let mut t = ParametricShapeTool::default();
|
||||
t.set_option("kind", &json!("gear"));
|
||||
t.set_option("count", &json!(20));
|
||||
t.set_option("count", &json!(5000));
|
||||
t.set_option("a", &json!(0.3));
|
||||
t.set_option("width", &json!(-4));
|
||||
t.set_option("kind", &json!("cloud"));
|
||||
let o = t.options();
|
||||
assert_eq!((o["kind"].clone(), o["count"].clone(), o["a"].clone(), o["width"].clone()), (json!("gear"), json!(20), json!(0.3), json!(100.0)));
|
||||
assert!(o.get("center").is_none());
|
||||
assert!(apply(t.next, &json!({"center": [1, "x"]})).is_err());
|
||||
assert_eq!(apply(t.next, &json!({"rotation": 450})).map(|s| s.rotation), Ok(90.0));
|
||||
}
|
||||
}
|
||||
@@ -177,6 +177,9 @@ pub enum Cursor {
|
||||
ExtendPath,
|
||||
/// The Measure & Inspect tool (Path Precision Tools add-on).
|
||||
MeasureInspect,
|
||||
/// The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a shape out from its
|
||||
/// centre; a selected shape's handles edit it.
|
||||
ParametricShape,
|
||||
/// The Smart Pen Tool (Smart Pen add-on) away from paths, where a click starts a new path (over
|
||||
/// a path or while drawing it shows the Pen's and Direct Selection's cursors).
|
||||
SmartPen,
|
||||
@@ -274,6 +277,7 @@ impl Cursor {
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect
|
||||
| Cursor::ParametricShape
|
||||
| Cursor::SmartPen => Cursor::Crosshair,
|
||||
c => c,
|
||||
}
|
||||
@@ -311,7 +315,8 @@ impl Cursor {
|
||||
| Cursor::Scissors
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect => Some(Cursor::Crosshair),
|
||||
| Cursor::MeasureInspect
|
||||
| Cursor::ParametricShape => Some(Cursor::Crosshair),
|
||||
Cursor::TypeVertical => Some(Cursor::Text),
|
||||
Cursor::MeshAdd | Cursor::PuppetPin => Some(Cursor::PenAdd),
|
||||
Cursor::SmartPen => Some(Cursor::Pen),
|
||||
|
||||
@@ -696,6 +696,12 @@ group = "Drawing and editing tools"
|
||||
shown_when = ["The Measure & Inspect tool (Path Precision Tools add-on): drag to measure distance and angle; hover a path to read its lengths, area, curvature and angles."]
|
||||
hotspot = "Exact, because precision matters here (an open centre suits it)."
|
||||
|
||||
[[cursor]]
|
||||
id = "ParametricShape"
|
||||
group = "Drawing and editing tools"
|
||||
shown_when = ["The Parametric Shape Tool (Path Precision Tools add-on): a drag draws a re-editable shape out from its centre; a click selects one and shows its handles."]
|
||||
hotspot = "Exact: the centre of the shape being drawn."
|
||||
|
||||
# ── Symbolism tools ───────────────────────────────────────────────────────────────────────────────
|
||||
# The brush size is drawn by the app as a circle around the cursor.
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ const ROWS: &[Row] = &[
|
||||
(&[crate::addons::anchor_eraser::ID], None, &["size", "tolerance", "pressure"]),
|
||||
(&[crate::addons::extend_path::ID], None, &["mode"]),
|
||||
(&[crate::addons::smart_pen::ID], None, &["next"]),
|
||||
// The kind first: a new kind resets the values after it.
|
||||
(&[crate::addons::parametric_shape::ID], None, &["kind", "count", "a", "b", "radius", "width", "height", "rotation"]),
|
||||
// Flare Tool Options (`extra::FLARE_OPTIONS` and the Rays and Rings checkboxes).
|
||||
(
|
||||
&["flare"],
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Path Precision Tools in the interface: its submenu (Object › Plug-ins › Path Precision Tools),
|
||||
//! which selects its tools, opens the Anchor Inspector and runs Split / Add Points at Crossings;
|
||||
//! the Anchor Inspector panel (the selected anchor's position and handles as numbers, and the
|
||||
//! add-on's anchor edits); and its tools' options dialog (a double-click on the tool). Everything
|
||||
//! acts through `addon.pathtools.*` and `tool.setOption`.
|
||||
//! which selects its tools, opens its panels and runs Split / Add Points at Crossings and Convert
|
||||
//! to Parametric Shape; the Anchor Inspector panel (the selected anchor's position and handles as
|
||||
//! numbers, and the add-on's anchor edits); the Parametric Shape panel ([`shape`]); and its tools'
|
||||
//! options dialog (a double-click on the tool). Everything acts through `addon.pathtools.*` and
|
||||
//! `tool.setOption`.
|
||||
|
||||
pub mod shape;
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect};
|
||||
use vectorcraft_tools::addons::{anchor_eraser, extend_path, measure_inspect, parametric_shape};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::dialogs::DialogSpec;
|
||||
@@ -33,11 +36,14 @@ pub fn menu() -> Vec<Item> {
|
||||
tool("Anchor Eraser Brush", anchor_eraser::ID),
|
||||
tool("Extend Path Tool", extend_path::ID),
|
||||
tool("Measure & Inspect Tool", measure_inspect::ID),
|
||||
tool("Parametric Shape Tool", parametric_shape::ID),
|
||||
Item::Sep,
|
||||
Item::Cmd("Anchor Inspector", "window.panel", json!({ "panel": ID })),
|
||||
Item::Cmd("Parametric Shape", "window.panel", json!({ "panel": shape::ID })),
|
||||
Item::Sep,
|
||||
Item::Cmd("Add Points at Crossings", "addon.pathtools.crossings", json!({})),
|
||||
Item::Cmd("Split at Crossings", "addon.pathtools.crossings", json!({ "cut": true })),
|
||||
Item::Cmd("Convert to Parametric Shape", "addon.pathtools.shape.convert", json!({})),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! The Parametric Shape panel (Path Precision Tools): the selected parametric shape's numbers as
|
||||
//! fields (kind, count, size, rotation, the kind's own values, corner radius), edited with
|
||||
//! `addon.pathtools.shape.set`; with no shape selected, the same fields set the Parametric Shape
|
||||
//! Tool's next shape (`tool.setOption`). Convert turns selected plain shapes into parametric ones.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_tools::addons::parametric_shape::{self as tool, MAX_COUNT, MAX_SIZE, MIN_COUNT, ParametricShape, ShapeKind, apply, read};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::panels::empty_state;
|
||||
use crate::widgets::{self, dim_label};
|
||||
|
||||
/// The panel's id (`window.panel {panel: "parametricShape"}`).
|
||||
pub const ID: &str = "parametricShape";
|
||||
const LABEL_W: f32 = 96.0;
|
||||
const FIELD_W: f32 = 72.0;
|
||||
|
||||
/// The kind's name in the interface.
|
||||
fn kind_label(kind: ShapeKind) -> &'static str {
|
||||
match kind {
|
||||
ShapeKind::Rectangle => "Rectangle",
|
||||
ShapeKind::Ellipse => "Ellipse",
|
||||
ShapeKind::Polygon => "Polygon",
|
||||
ShapeKind::Star => "Star",
|
||||
ShapeKind::Ring => "Ring",
|
||||
ShapeKind::Arrow => "Arrow",
|
||||
ShapeKind::Cross => "Cross",
|
||||
ShapeKind::Heart => "Heart",
|
||||
ShapeKind::Gear => "Gear",
|
||||
ShapeKind::Bubble => "Speech Bubble",
|
||||
}
|
||||
}
|
||||
|
||||
/// The labels of the kind's own values `a` and `b`.
|
||||
fn value_labels(kind: ShapeKind) -> [&'static str; 2] {
|
||||
match kind {
|
||||
ShapeKind::Star | ShapeKind::Ring => ["Inner Radius:", ""],
|
||||
ShapeKind::Arrow => ["Head:", "Shaft:"],
|
||||
ShapeKind::Cross => ["Offset:", "Thickness:"],
|
||||
ShapeKind::Heart => ["Depth:", "Point:"],
|
||||
ShapeKind::Gear => ["Tooth Height:", ""],
|
||||
ShapeKind::Bubble => ["Tail:", "Tip:"],
|
||||
ShapeKind::Rectangle | ShapeKind::Ellipse | ShapeKind::Polygon => ["", ""],
|
||||
}
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) {
|
||||
if let Err(e) = app.run(id, p) {
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let Some(st) = app.session.active() else {
|
||||
empty_state(ui, "hexagon", tl!("No document"), tl!("Open a document to draw parametric shapes."));
|
||||
return;
|
||||
};
|
||||
// The selected shape, else the tool's next one.
|
||||
let selected = match st.selection.objects.as_slice() {
|
||||
[id] => st.doc.node(*id).and_then(read).map(|s| (*id, s)),
|
||||
_ => None,
|
||||
};
|
||||
let plain_selected = !st.selection.is_empty() && selected.is_none();
|
||||
let fallback = ParametricShape::new(ShapeKind::Polygon, Point::ZERO, 100.0, 100.0);
|
||||
let s = selected.map(|(_, s)| s).unwrap_or_else(|| apply(fallback, &app.session.tool_options_of(tool::ID)).unwrap_or(fallback));
|
||||
let unit = app.session.general_unit();
|
||||
if selected.is_none() {
|
||||
dim_label(ui, tl!("Select a parametric shape to edit it. With none selected, these set the next shape."));
|
||||
}
|
||||
let mut change: Option<Value> = None;
|
||||
let labels = ShapeKind::ALL.map(kind_label);
|
||||
widgets::label_row(ui, "Shape:", LABEL_W, |ui| {
|
||||
if let Some(k) = widgets::dropdown(ui, "parametric-shape-kind", kind_label(s.kind), &labels, 150.0).and_then(|i| ShapeKind::ALL.get(i)) {
|
||||
change = Some(json!({ "kind": k.id() }));
|
||||
}
|
||||
});
|
||||
if s.kind.counted() {
|
||||
widgets::label_row(ui, "Count:", LABEL_W, |ui| {
|
||||
let range = f64::from(MIN_COUNT)..=f64::from(MAX_COUNT);
|
||||
if let Some(v) = widgets::range_field(ui, "parametric-shape-count", f64::from(s.count), range, "", 0, FIELD_W) {
|
||||
change = Some(json!({ "count": v }));
|
||||
}
|
||||
});
|
||||
}
|
||||
// Width and height side by side, as the Transform panel has them.
|
||||
egui::Grid::new("parametric-shape-size").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
for (label, key, v) in [("W:", "width", s.width), ("H:", "height", s.height)] {
|
||||
dim_label(ui, label);
|
||||
if let Some(v) = widgets::size_field_named(ui, ("parametric-shape", key), Some(tl!(label)), Some(v), unit, FIELD_W) {
|
||||
change = Some(json!({ key: v }));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
widgets::label_row(ui, "Rotation:", LABEL_W, |ui| {
|
||||
if let Some(v) = widgets::mixed_field(ui, "parametric-shape-rotation", Some(s.rotation), "°", 1, FIELD_W) {
|
||||
change = Some(json!({ "rotation": v }));
|
||||
}
|
||||
});
|
||||
// The kind's own values, as percentages of their fraction.
|
||||
for ((key, v), (range, label)) in [("a", s.a), ("b", s.b)].into_iter().zip(s.kind.values().into_iter().zip(value_labels(s.kind))) {
|
||||
let Some(r) = range else { continue };
|
||||
widgets::label_row(ui, label, LABEL_W, |ui| {
|
||||
if let Some(x) = widgets::range_field(ui, ("parametric-shape", key), v * 100.0, r.min * 100.0..=r.max * 100.0, "%", 0, FIELD_W) {
|
||||
change = Some(json!({ key: x / 100.0 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
if s.kind.rounds() {
|
||||
widgets::label_row(ui, "Corner Radius:", LABEL_W, |ui| {
|
||||
if let Some(v) = widgets::size_field(ui, "parametric-shape-radius", Some(s.radius), unit, FIELD_W) {
|
||||
change = Some(json!({ "radius": v.clamp(0.0, MAX_SIZE) }));
|
||||
}
|
||||
});
|
||||
}
|
||||
widgets::divider(ui);
|
||||
let width = ui.available_width();
|
||||
let convert = ui.add_enabled_ui(plain_selected, |ui| widgets::flat_button(ui, tl!("Convert to Parametric Shape"), width).clicked()).inner;
|
||||
match (change, selected) {
|
||||
(Some(mut p), Some((id, _))) => {
|
||||
p["id"] = json!(id.0);
|
||||
run(app, "addon.pathtools.shape.set", p);
|
||||
}
|
||||
(Some(values), None) => run(app, "tool.setOption", json!({ "tool": tool::ID, "values": values })),
|
||||
(None, _) => {}
|
||||
}
|
||||
if convert {
|
||||
run(app, "addon.pathtools.shape.convert", json!({}));
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,12 @@ fn the_plugins_submenu_selects_the_tools_and_opens_the_anchor_inspector() {
|
||||
let mut app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let under = |label: &str| entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == label);
|
||||
for (label, tool) in
|
||||
[("Anchor Eraser Brush", "anchorEraserBrush"), ("Extend Path Tool", "extendPath"), ("Measure & Inspect Tool", "measureInspect")]
|
||||
{
|
||||
for (label, tool) in [
|
||||
("Anchor Eraser Brush", "anchorEraserBrush"),
|
||||
("Extend Path Tool", "extendPath"),
|
||||
("Measure & Inspect Tool", "measureInspect"),
|
||||
("Parametric Shape Tool", "parametricShape"),
|
||||
] {
|
||||
let e = under(label).expect(label);
|
||||
assert_eq!((e.command.as_deref(), &e.params), (Some("tool.select"), &json!({ "tool": tool })));
|
||||
menus::invoke(&mut app, "tool.select", e.params.clone());
|
||||
@@ -84,3 +87,29 @@ fn double_clicking_the_brush_opens_its_options_and_ok_sets_them() {
|
||||
assert_eq!(app.session.tool_options_of("extendPath")["mode"], json!("arc"));
|
||||
assert!(app.run("tool.options", json!({"tool": "measureInspect"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_parametric_shape_panel_opens_from_the_submenu_and_shows_the_shape() {
|
||||
let mut app = app();
|
||||
let entries = menus::menu_entries(&app);
|
||||
let under = |label: &str| {
|
||||
let e = entries.iter().find(|e| e.path == ["Object", "Plug-ins", "Path Precision Tools"] && e.label == label).expect(label);
|
||||
(e.command.clone(), e.params.clone())
|
||||
};
|
||||
assert_eq!(under("Convert to Parametric Shape").0.as_deref(), Some("addon.pathtools.shape.convert"));
|
||||
let (command, params) = under("Parametric Shape");
|
||||
assert_eq!(command.as_deref(), Some("window.panel"));
|
||||
menus::invoke(&mut app, "window.panel", params);
|
||||
assert!(crate::panel_docking::showing(&app.ui, super::shape::ID), "the panel opens");
|
||||
// Nothing selected: the fields set the tool's next shape.
|
||||
let text = painted_text(&mut app, super::shape::show);
|
||||
assert!(text.contains("With none selected") && text.contains("Polygon") && text.contains("Count:") && text.contains("Corner Radius:"), "{text}");
|
||||
app.run("tool.setOption", json!({"tool": "parametricShape", "values": {"kind": "gear"}})).unwrap();
|
||||
let text = painted_text(&mut app, super::shape::show);
|
||||
assert!(text.contains("Gear") && text.contains("Tooth Height:"), "{text}");
|
||||
// A shape selected: its own numbers.
|
||||
app.run("addon.pathtools.shape.create", json!({"kind": "bubble", "center": [150, 150]})).unwrap();
|
||||
let text = painted_text(&mut app, super::shape::show);
|
||||
assert!(text.contains("Speech Bubble") && text.contains("Tail:") && text.contains("Tip:") && !text.contains("With none selected"), "{text}");
|
||||
assert!(!text.contains("Count:"), "a speech bubble has no count: {text}");
|
||||
}
|
||||
|
||||
@@ -151,6 +151,7 @@ pub(super) fn cursor_icon(c: Cursor) -> egui::CursorIcon {
|
||||
| Cursor::AnchorEraser
|
||||
| Cursor::ExtendPath
|
||||
| Cursor::MeasureInspect
|
||||
| Cursor::ParametricShape
|
||||
| Cursor::SmartPen => cursor_icon(c.stand_in().unwrap_or(Cursor::Crosshair)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ fn magnifier(p: &mut Ink, o: Pos2, out: bool) {
|
||||
|
||||
/// Every cursor with a glyph: its own, drawn here, or the one it borrows.
|
||||
#[cfg(test)]
|
||||
const GLYPHS: [Cursor; 85] = [
|
||||
const GLYPHS: [Cursor; 86] = [
|
||||
Cursor::Hand,
|
||||
Cursor::HandGrab,
|
||||
Cursor::ZoomIn,
|
||||
@@ -525,6 +525,7 @@ const GLYPHS: [Cursor; 85] = [
|
||||
Cursor::AnchorEraser,
|
||||
Cursor::ExtendPath,
|
||||
Cursor::MeasureInspect,
|
||||
Cursor::ParametricShape,
|
||||
Cursor::SmartPen,
|
||||
];
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -379,7 +379,7 @@ fn drop_targets_follow_the_buttons_edges() {
|
||||
fn addon_tools_are_in_the_drawer_not_the_toolbars() {
|
||||
let mut app = app();
|
||||
let addon: Vec<&str> = vectorcraft_tools::addons::GROUPS.iter().flat_map(|g| g.iter().map(|t| t.id)).collect();
|
||||
assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect", "smartPen"]);
|
||||
assert_eq!(addon, ["anchorEraserBrush", "extendPath", "measureInspect", "parametricShape", "smartPen"]);
|
||||
for advanced in [false, true] {
|
||||
if advanced {
|
||||
app.run("window.toolbarAdvanced", json!({})).unwrap();
|
||||
@@ -389,7 +389,7 @@ fn addon_tools_are_in_the_drawer_not_the_toolbars() {
|
||||
let listed = |id: &str| {
|
||||
sections().iter().find(|(_, tools)| tools.iter().any(|t| t.id == id)).map(|(h, tools)| (*h, tools.iter().map(|t| t.id).collect::<Vec<_>>()))
|
||||
};
|
||||
assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..3].to_vec())));
|
||||
assert_eq!(listed("extendPath"), Some(("Path Precision Tools", addon[..4].to_vec())));
|
||||
assert_eq!(listed("smartPen"), Some(("Smart Pen", vec!["smartPen"])));
|
||||
for tool in ["extendPath", "smartPen"] {
|
||||
app.run("toolbar.addTool", json!({"tool": tool})).unwrap();
|
||||
|
||||
@@ -1380,6 +1380,7 @@ font-technology Variable Променливи
|
||||
Delete Guides Изтриване на водачи
|
||||
Delete Slice Изтриване на отрязък
|
||||
Delete Style Изтриване на стил
|
||||
Delete Styles Изтриване на стилове
|
||||
Delete Variables Изтриване на променливи
|
||||
Delete View Изтриване на изглед
|
||||
Done Editing Pattern Край на редактирането на мостра
|
||||
@@ -5687,6 +5688,32 @@ anchor Points: Точки:
|
||||
@msg select open paths to close изберете отворени пътища за затваряне
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) пътищата са твърде много или твърде заплетени, за да се намерят пресичанията им (най-много 10000 сегмента)
|
||||
@msg the paths have too many anchors to edit at once пътищата имат твърде много опорни точки, за да се редактират наведнъж
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Инструмент „Параметрична форма“
|
||||
Parametric Shape Параметрична форма
|
||||
Edit Parametric Shape Редактиране на параметрична форма
|
||||
Convert to Parametric Shape Преобразуване в параметрична форма
|
||||
Ring Пръстен
|
||||
Heart Сърце
|
||||
Gear Зъбно колело
|
||||
Speech Bubble Балонче за реч
|
||||
Count: Брой:
|
||||
Thickness: Дебелина:
|
||||
Inner Radius: Вътрешен радиус:
|
||||
Head: Глава:
|
||||
Shaft: Стъбло:
|
||||
Point: Връх:
|
||||
Tooth Height: Височина на зъба:
|
||||
Tail: Опашка:
|
||||
Tip: Връх:
|
||||
Open a document to draw parametric shapes. Отворете документ, за да рисувате параметрични форми.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Изберете параметрична форма, за да я редактирате. Без избрана форма тези стойности важат за следващата.
|
||||
@msg the object is not an editable parametric shape обектът не е редактируема параметрична форма
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert изберете обикновени правоъгълници, елипси или правилни многоъгълници за преобразуване
|
||||
@msg the shape's numbers are out of range стойностите на формата са извън допустимия обхват
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` трябва да е едно от: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` трябва да е цяло число от 3 до 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` трябва да е от {lo} до {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Интелигентно перо
|
||||
Smart Pen Tool Инструмент „Интелигентно перо“
|
||||
@@ -5718,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.
|
@@ -1042,6 +1042,7 @@
|
||||
Delete Spine Point Suprimeix el punt de l’espina
|
||||
Delete Stop Suprimeix la parada
|
||||
Delete Style Suprimeix l’estil
|
||||
Delete Styles Suprimeix els estils
|
||||
Delete Swatch Suprimeix la mostra
|
||||
Delete Symbol Suprimeix el símbol
|
||||
Delete Tab Suprimeix la tabulació
|
||||
@@ -5788,6 +5789,32 @@ anchor Points: Punts:
|
||||
@msg select open paths to close seleccioneu traçats oberts per tancar-los
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) massa traçats, o massa enredats, per trobar-ne els encreuaments (10000 segments com a màxim)
|
||||
@msg the paths have too many anchors to edit at once els traçats tenen massa punts d’ancoratge per editar-los alhora
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Eina de forma paramètrica
|
||||
Parametric Shape Forma paramètrica
|
||||
Edit Parametric Shape Edita la forma paramètrica
|
||||
Convert to Parametric Shape Converteix en forma paramètrica
|
||||
Ring Anell
|
||||
Heart Cor
|
||||
Gear Engranatge
|
||||
Speech Bubble Bafarada
|
||||
Count: Nombre:
|
||||
Thickness: Gruix:
|
||||
Inner Radius: Radi interior:
|
||||
Head: Cap:
|
||||
Shaft: Tija:
|
||||
Point: Punta:
|
||||
Tooth Height: Alçada de la dent:
|
||||
Tail: Cua:
|
||||
Tip: Punta:
|
||||
Open a document to draw parametric shapes. Obriu un document per dibuixar formes paramètriques.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Seleccioneu una forma paramètrica per editar-la. Sense cap selecció, aquests valors s’apliquen a la forma següent.
|
||||
@msg the object is not an editable parametric shape l’objecte no és una forma paramètrica editable
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert seleccioneu rectangles, el·lipses o polígons regulars simples per convertir-los
|
||||
@msg the shape's numbers are out of range els valors de la forma són fora de l’interval
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` ha de ser un de: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` ha de ser un nombre enter de 3 a 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` ha de ser de {lo} a {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Ploma intel·ligent
|
||||
Smart Pen Tool Eina de ploma intel·ligent
|
||||
@@ -5819,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.
|
@@ -1593,6 +1593,7 @@ lighting Light Světlo
|
||||
Delete Guides Odstranit vodítka
|
||||
Delete Slice Odstranit řez
|
||||
Delete Style Odstranit styl
|
||||
Delete Styles Odstranit styly
|
||||
Delete Variables Odstranit proměnné
|
||||
Delete View Odstranit zobrazení
|
||||
Done Editing Pattern Dokončit úpravy vzorku
|
||||
@@ -5797,6 +5798,32 @@ anchor Points: Body:
|
||||
@msg select open paths to close vyberte otevřené cesty k uzavření
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) příliš mnoho nebo příliš spletených cest pro nalezení jejich křížení (nejvýše 10000 segmentů)
|
||||
@msg the paths have too many anchors to edit at once cesty mají příliš mnoho kotevních bodů na úpravu najednou
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Nástroj Parametrický tvar
|
||||
Parametric Shape Parametrický tvar
|
||||
Edit Parametric Shape Upravit parametrický tvar
|
||||
Convert to Parametric Shape Převést na parametrický tvar
|
||||
Ring Prstenec
|
||||
Heart Srdce
|
||||
Gear Ozubené kolo
|
||||
Speech Bubble Bublina
|
||||
Count: Počet:
|
||||
Thickness: Tloušťka:
|
||||
Inner Radius: Vnitřní poloměr:
|
||||
Head: Hrot:
|
||||
Shaft: Dřík:
|
||||
Point: Špička:
|
||||
Tooth Height: Výška zubu:
|
||||
Tail: Ocásek:
|
||||
Tip: Špička:
|
||||
Open a document to draw parametric shapes. Otevřete dokument a kreslete parametrické tvary.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Vyberte parametrický tvar a upravte ho. Bez výběru tyto hodnoty platí pro další tvar.
|
||||
@msg the object is not an editable parametric shape objekt není upravitelný parametrický tvar
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert vyberte k převodu obyčejné obdélníky, elipsy nebo pravidelné mnohoúhelníky
|
||||
@msg the shape's numbers are out of range hodnoty tvaru jsou mimo rozsah
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` musí být jedno z: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` musí být celé číslo od 3 do 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` musí být od {lo} do {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Chytré pero
|
||||
Smart Pen Tool Nástroj Chytré pero
|
||||
@@ -5828,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.
|
@@ -5330,6 +5330,7 @@ swatch Bright Violet Kraftig violet
|
||||
Delete Guides Slet hjælpelinjer
|
||||
Delete Slice Slet udsnit
|
||||
Delete Style Slet typografi
|
||||
Delete Styles Slet typografier
|
||||
Delete Variables Slet variabler
|
||||
Delete View Slet visning
|
||||
Done Editing Pattern Redigering af mønster udført
|
||||
@@ -5702,6 +5703,32 @@ anchor Points: Punkter:
|
||||
@msg select open paths to close vælg åbne stier at lukke
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) for mange eller for sammenfiltrede stier til at finde deres krydsninger (højst 10000 segmenter)
|
||||
@msg the paths have too many anchors to edit at once stierne har for mange ankerpunkter til at blive redigeret på én gang
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrisk form-værktøj
|
||||
Parametric Shape Parametrisk form
|
||||
Edit Parametric Shape Rediger parametrisk form
|
||||
Convert to Parametric Shape Konverter til parametrisk form
|
||||
Ring Ring
|
||||
Heart Hjerte
|
||||
Gear Tandhjul
|
||||
Speech Bubble Taleboble
|
||||
Count: Antal:
|
||||
Thickness: Tykkelse:
|
||||
Inner Radius: Indre radius:
|
||||
Head: Hoved:
|
||||
Shaft: Skaft:
|
||||
Point: Spids:
|
||||
Tooth Height: Tandhøjde:
|
||||
Tail: Hale:
|
||||
Tip: Spids:
|
||||
Open a document to draw parametric shapes. Åbn et dokument for at tegne parametriske former.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Vælg en parametrisk form for at redigere den. Uden valg gælder disse værdier for den næste form.
|
||||
@msg the object is not an editable parametric shape objektet er ikke en redigerbar parametrisk form
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert vælg almindelige rektangler, ellipser eller regulære polygoner at konvertere
|
||||
@msg the shape's numbers are out of range formens værdier er uden for området
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` skal være en af: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` skal være et heltal fra 3 til 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` skal være fra {lo} til {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Smart pen
|
||||
Smart Pen Tool Smart pen-værktøj
|
||||
@@ -5733,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.
|
@@ -1406,6 +1406,7 @@ file-filter Templates Vorlagen
|
||||
Delete Guides Hilfslinien löschen
|
||||
Delete Slice Slice löschen
|
||||
Delete Style Format löschen
|
||||
Delete Styles Formate löschen
|
||||
Delete Variables Variablen löschen
|
||||
Delete View Ansicht löschen
|
||||
Done Editing Pattern Musterbearbeitung beenden
|
||||
@@ -5380,6 +5381,34 @@ anchor Points: Punkte:
|
||||
@msg select open paths to close offene Pfade zum Schließen auswählen
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) zu viele oder zu verschlungene Pfade, um ihre Kreuzungen zu finden (höchstens 10000 Segmente)
|
||||
@msg the paths have too many anchors to edit at once die Pfade haben zu viele Ankerpunkte, um sie auf einmal zu bearbeiten
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrische-Form-Werkzeug
|
||||
Parametric Shape Parametrische Form
|
||||
Edit Parametric Shape Parametrische Form bearbeiten
|
||||
Convert to Parametric Shape In parametrische Form umwandeln
|
||||
Ring Ring
|
||||
Arrow Pfeil
|
||||
Cross Kreuz
|
||||
Heart Herz
|
||||
Gear Zahnrad
|
||||
Speech Bubble Sprechblase
|
||||
Count: Anzahl:
|
||||
Thickness: Stärke:
|
||||
Inner Radius: Innenradius:
|
||||
Head: Kopf:
|
||||
Shaft: Schaft:
|
||||
Point: Spitze:
|
||||
Tooth Height: Zahnhöhe:
|
||||
Tail: Zipfel:
|
||||
Tip: Spitze:
|
||||
Open a document to draw parametric shapes. Öffnen Sie ein Dokument, um parametrische Formen zu zeichnen.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Wählen Sie eine parametrische Form aus, um sie zu bearbeiten. Ohne Auswahl gelten diese Werte für die nächste Form.
|
||||
@msg the object is not an editable parametric shape das Objekt ist keine bearbeitbare parametrische Form
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert einfache Rechtecke, Ellipsen oder regelmäßige Polygone zum Umwandeln auswählen
|
||||
@msg the shape's numbers are out of range die Werte der Form liegen außerhalb des zulässigen Bereichs
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` muss eines der folgenden sein: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` muss eine ganze Zahl von 3 bis 1000 sein
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` muss zwischen {lo} und {hi} liegen
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Intelligenter Zeichenstift
|
||||
Smart Pen Tool Intelligenter-Zeichenstift-Werkzeug
|
||||
@@ -5411,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.
|
@@ -1344,6 +1344,7 @@ font-technology Variable Μεταβλητές
|
||||
Delete Guides Διαγραφή οδηγών
|
||||
Delete Slice Διαγραφή τομής
|
||||
Delete Style Διαγραφή στυλ
|
||||
Delete Styles Διαγραφή στυλ
|
||||
Delete Variables Διαγραφή μεταβλητών
|
||||
Delete View Διαγραφή προβολής
|
||||
Done Editing Pattern Ολοκλήρωση επεξεργασίας μοτίβου
|
||||
@@ -5749,6 +5750,32 @@ anchor Points: Σημεία:
|
||||
@msg select open paths to close επιλέξτε ανοιχτές διαδρομές για κλείσιμο
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) οι διαδρομές είναι πάρα πολλές ή πολύ μπλεγμένες για να βρεθούν οι διασταυρώσεις τους (έως 10000 τμήματα)
|
||||
@msg the paths have too many anchors to edit at once οι διαδρομές έχουν πάρα πολλά σημεία αγκύρωσης για επεξεργασία με μία φορά
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Εργαλείο παραμετρικού σχήματος
|
||||
Parametric Shape Παραμετρικό σχήμα
|
||||
Edit Parametric Shape Επεξεργασία παραμετρικού σχήματος
|
||||
Convert to Parametric Shape Μετατροπή σε παραμετρικό σχήμα
|
||||
Ring Δακτύλιος
|
||||
Heart Καρδιά
|
||||
Gear Γρανάζι
|
||||
Speech Bubble Συννεφάκι ομιλίας
|
||||
Count: Πλήθος:
|
||||
Thickness: Πάχος:
|
||||
Inner Radius: Εσωτερική ακτίνα:
|
||||
Head: Κεφαλή:
|
||||
Shaft: Στέλεχος:
|
||||
Point: Μύτη:
|
||||
Tooth Height: Ύψος δοντιού:
|
||||
Tail: Ουρά:
|
||||
Tip: Άκρη:
|
||||
Open a document to draw parametric shapes. Ανοίξτε ένα έγγραφο για να σχεδιάσετε παραμετρικά σχήματα.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Επιλέξτε ένα παραμετρικό σχήμα για να το επεξεργαστείτε. Χωρίς επιλογή, αυτές οι τιμές ισχύουν για το επόμενο σχήμα.
|
||||
@msg the object is not an editable parametric shape το αντικείμενο δεν είναι επεξεργάσιμο παραμετρικό σχήμα
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert επιλέξτε απλά ορθογώνια, ελλείψεις ή κανονικά πολύγωνα για μετατροπή
|
||||
@msg the shape's numbers are out of range οι τιμές του σχήματος είναι εκτός εύρους
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble Το `kind` πρέπει να είναι ένα από: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 Το `count` πρέπει να είναι ακέραιος από 3 έως 1000
|
||||
@msg `{key}` must be from {lo} to {hi} Το `{key}` πρέπει να είναι από {lo} έως {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Έξυπνη πένα
|
||||
Smart Pen Tool Έξυπνη πένα (Smart Pen Tool)
|
||||
@@ -5780,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.
|
@@ -1418,6 +1418,7 @@ font-technology Variable Variables
|
||||
Delete Guides Eliminar guías
|
||||
Delete Slice Eliminar sector
|
||||
Delete Style Eliminar estilo
|
||||
Delete Styles Eliminar estilos
|
||||
Delete Variables Eliminar variables
|
||||
Delete View Eliminar vista
|
||||
Done Editing Pattern Terminar edición de motivo
|
||||
@@ -5740,6 +5741,32 @@ anchor Points: Puntos:
|
||||
@msg select open paths to close seleccione trazados abiertos que cerrar
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) hay demasiados trazados, o están demasiado enredados, para hallar sus cruces (10000 segmentos como máximo)
|
||||
@msg the paths have too many anchors to edit at once los trazados tienen demasiados puntos de ancla para editarlos a la vez
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Herramienta Forma paramétrica
|
||||
Parametric Shape Forma paramétrica
|
||||
Edit Parametric Shape Editar forma paramétrica
|
||||
Convert to Parametric Shape Convertir en forma paramétrica
|
||||
Ring Anillo
|
||||
Heart Corazón
|
||||
Gear Engranaje
|
||||
Speech Bubble Bocadillo
|
||||
Count: Cantidad:
|
||||
Thickness: Grosor:
|
||||
Inner Radius: Radio interior:
|
||||
Head: Cabeza:
|
||||
Shaft: Cuerpo:
|
||||
Point: Punta:
|
||||
Tooth Height: Altura del diente:
|
||||
Tail: Cola:
|
||||
Tip: Punta:
|
||||
Open a document to draw parametric shapes. Abra un documento para dibujar formas paramétricas.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Seleccione una forma paramétrica para editarla. Sin selección, estos valores se aplican a la siguiente forma.
|
||||
@msg the object is not an editable parametric shape el objeto no es una forma paramétrica editable
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert seleccione rectángulos, elipses o polígonos regulares simples que convertir
|
||||
@msg the shape's numbers are out of range los valores de la forma están fuera de rango
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` debe ser uno de: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` debe ser un número entero entre 3 y 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` debe estar entre {lo} y {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Pluma inteligente
|
||||
Smart Pen Tool Herramienta Pluma inteligente
|
||||
@@ -5771,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.
|
@@ -1567,6 +1567,7 @@ lighting Light Valo
|
||||
Delete Guides Poista apuviivat
|
||||
Delete Slice Poista siivu
|
||||
Delete Style Poista tyyli
|
||||
Delete Styles Poista tyylit
|
||||
Delete Variables Poista muuttujat
|
||||
Delete View Poista näkymä
|
||||
Done Editing Pattern Lopeta kuvion muokkaus
|
||||
@@ -5828,6 +5829,32 @@ anchor Points: Pisteet:
|
||||
@msg select open paths to close valitse suljettavat avoimet polut
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) liian monta tai liian sotkuista polkua niiden risteyskohtien löytämiseen (enintään 10000 osaa)
|
||||
@msg the paths have too many anchors to edit at once poluissa on liian monta ankkuripistettä muokattavaksi kerralla
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrinen muoto -työkalu
|
||||
Parametric Shape Parametrinen muoto
|
||||
Edit Parametric Shape Muokkaa parametrista muotoa
|
||||
Convert to Parametric Shape Muunna parametriseksi muodoksi
|
||||
Ring Rengas
|
||||
Heart Sydän
|
||||
Gear Hammaspyörä
|
||||
Speech Bubble Puhekupla
|
||||
Count: Määrä:
|
||||
Thickness: Paksuus:
|
||||
Inner Radius: Sisäsäde:
|
||||
Head: Kärki:
|
||||
Shaft: Varsi:
|
||||
Point: Kärki:
|
||||
Tooth Height: Hampaan korkeus:
|
||||
Tail: Häntä:
|
||||
Tip: Kärki:
|
||||
Open a document to draw parametric shapes. Avaa asiakirja piirtääksesi parametrisia muotoja.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Valitse parametrinen muoto muokataksesi sitä. Ilman valintaa nämä arvot koskevat seuraavaa muotoa.
|
||||
@msg the object is not an editable parametric shape objekti ei ole muokattava parametrinen muoto
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert valitse muunnettavat tavalliset suorakulmiot, ellipsit tai säännölliset monikulmiot
|
||||
@msg the shape's numbers are out of range muodon arvot ovat sallitun alueen ulkopuolella
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` on oltava jokin seuraavista: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` on oltava kokonaisluku 3–1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` on oltava {lo}–{hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Älykynä
|
||||
Smart Pen Tool Älykynätyökalu
|
||||
@@ -5859,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.
|
@@ -1417,6 +1417,7 @@ font-technology Variable Variables
|
||||
Delete Guides Supprimer les repères
|
||||
Delete Slice Supprimer la tranche
|
||||
Delete Style Supprimer le style
|
||||
Delete Styles Supprimer les styles
|
||||
Delete Variables Supprimer les variables
|
||||
Delete View Supprimer la vue
|
||||
Done Editing Pattern Terminer la modification du motif
|
||||
@@ -5796,6 +5797,32 @@ anchor Points: Points :
|
||||
@msg select open paths to close sélectionnez des tracés ouverts à fermer
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) trop de tracés, ou trop enchevêtrés, pour trouver leurs croisements (10000 segments au plus)
|
||||
@msg the paths have too many anchors to edit at once les tracés ont trop de points d’ancrage pour être modifiés en une fois
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Outil Forme paramétrique
|
||||
Parametric Shape Forme paramétrique
|
||||
Edit Parametric Shape Modifier la forme paramétrique
|
||||
Convert to Parametric Shape Convertir en forme paramétrique
|
||||
Ring Anneau
|
||||
Heart Cœur
|
||||
Gear Engrenage
|
||||
Speech Bubble Bulle de dialogue
|
||||
Count: Nombre :
|
||||
Thickness: Épaisseur :
|
||||
Inner Radius: Rayon intérieur :
|
||||
Head: Tête :
|
||||
Shaft: Corps :
|
||||
Point: Pointe :
|
||||
Tooth Height: Hauteur des dents :
|
||||
Tail: Queue :
|
||||
Tip: Pointe :
|
||||
Open a document to draw parametric shapes. Ouvrez un document pour dessiner des formes paramétriques.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Sélectionnez une forme paramétrique pour la modifier. Sans sélection, ces valeurs s’appliquent à la forme suivante.
|
||||
@msg the object is not an editable parametric shape l’objet n’est pas une forme paramétrique modifiable
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert sélectionnez des rectangles, ellipses ou polygones réguliers simples à convertir
|
||||
@msg the shape's numbers are out of range les valeurs de la forme sont hors limites
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` doit être l’un de : rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` doit être un nombre entier compris entre 3 et 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` doit être compris entre {lo} et {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Plume intelligente
|
||||
Smart Pen Tool Outil Plume intelligente
|
||||
@@ -5827,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.
|
@@ -1806,6 +1806,7 @@ file-filter Templates Sablonok
|
||||
Delete Guides Segédvonalak törlése
|
||||
Delete Slice Szelet törlése
|
||||
Delete Style Stílus törlése
|
||||
Delete Styles Stílusok törlése
|
||||
Delete Variables Változók törlése
|
||||
Delete View Nézet törlése
|
||||
Done Editing Pattern Mintázatszerkesztés befejezése
|
||||
@@ -5784,6 +5785,32 @@ anchor Points: Pontok:
|
||||
@msg select open paths to close jelöljön ki lezárandó nyitott útvonalakat
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) túl sok vagy túl összegabalyodott útvonal a kereszteződések megtalálásához (legfeljebb 10000 szakasz)
|
||||
@msg the paths have too many anchors to edit at once az útvonalaknak túl sok horgonypontjuk van az egyszerre szerkesztéshez
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Paraméteres alakzat eszköz
|
||||
Parametric Shape Paraméteres alakzat
|
||||
Edit Parametric Shape Paraméteres alakzat szerkesztése
|
||||
Convert to Parametric Shape Átalakítás paraméteres alakzattá
|
||||
Ring Gyűrű
|
||||
Heart Szív
|
||||
Gear Fogaskerék
|
||||
Speech Bubble Szövegbuborék
|
||||
Count: Darabszám:
|
||||
Thickness: Vastagság:
|
||||
Inner Radius: Belső sugár:
|
||||
Head: Fej:
|
||||
Shaft: Szár:
|
||||
Point: Csúcs:
|
||||
Tooth Height: Fogmagasság:
|
||||
Tail: Csóva:
|
||||
Tip: Csúcs:
|
||||
Open a document to draw parametric shapes. Nyisson meg egy dokumentumot paraméteres alakzatok rajzolásához.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. A szerkesztéshez jelöljön ki egy paraméteres alakzatot. Kijelölés nélkül ezek az értékek a következő alakzatra vonatkoznak.
|
||||
@msg the object is not an editable parametric shape az objektum nem szerkeszthető paraméteres alakzat
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert jelöljön ki átalakítandó egyszerű téglalapokat, ellipsziseket vagy szabályos sokszögeket
|
||||
@msg the shape's numbers are out of range az alakzat értékei a megengedett tartományon kívül esnek
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind`: ezek egyike kell: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count`: 3 és 1000 közötti egész szám kell
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}`: {lo} és {hi} között kell
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Okostoll
|
||||
Smart Pen Tool Okostoll eszköz
|
||||
@@ -5815,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.
|
@@ -5330,6 +5330,7 @@ swatch Bright Violet Violet cerah
|
||||
Delete Guides Hapus semua panduan
|
||||
Delete Slice Hapus slice
|
||||
Delete Style Hapus gaya
|
||||
Delete Styles Hapus gaya
|
||||
Delete Variables Hapus variabel
|
||||
Delete View Hapus tampilan
|
||||
Done Editing Pattern Selesai mengedit pola
|
||||
@@ -5702,6 +5703,32 @@ anchor Points: Titik:
|
||||
@msg select open paths to close pilih jalur terbuka untuk ditutup
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) jalur terlalu banyak atau terlalu kusut untuk menemukan persilangannya (paling banyak 10000 segmen)
|
||||
@msg the paths have too many anchors to edit at once jalur memiliki terlalu banyak titik jangkar untuk disunting sekaligus
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Alat bentuk parametrik
|
||||
Parametric Shape Bentuk parametrik
|
||||
Edit Parametric Shape Sunting bentuk parametrik
|
||||
Convert to Parametric Shape Ubah menjadi bentuk parametrik
|
||||
Ring Cincin
|
||||
Heart Hati
|
||||
Gear Roda gigi
|
||||
Speech Bubble Balon ucapan
|
||||
Count: Jumlah:
|
||||
Thickness: Ketebalan:
|
||||
Inner Radius: Jari-jari dalam:
|
||||
Head: Kepala:
|
||||
Shaft: Batang:
|
||||
Point: Ujung:
|
||||
Tooth Height: Tinggi gigi:
|
||||
Tail: Ekor:
|
||||
Tip: Ujung:
|
||||
Open a document to draw parametric shapes. Buka dokumen untuk menggambar bentuk parametrik.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Pilih bentuk parametrik untuk menyuntingnya. Tanpa pilihan, nilai ini berlaku untuk bentuk berikutnya.
|
||||
@msg the object is not an editable parametric shape objek bukan bentuk parametrik yang dapat disunting
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert pilih persegi panjang, elips, atau poligon beraturan biasa untuk diubah
|
||||
@msg the shape's numbers are out of range nilai bentuk berada di luar rentang
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` harus salah satu dari: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` harus bilangan bulat dari 3 sampai 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` harus antara {lo} dan {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Pena pintar
|
||||
Smart Pen Tool Alat pena pintar
|
||||
@@ -5733,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.
|
@@ -1430,6 +1430,7 @@ file-filter Templates Modelli
|
||||
Delete Guides Elimina guide
|
||||
Delete Slice Elimina sezione
|
||||
Delete Style Elimina stile
|
||||
Delete Styles Elimina stili
|
||||
Delete Variables Elimina variabili
|
||||
Delete View Elimina vista
|
||||
Done Editing Pattern Termina modifica motivo
|
||||
@@ -5414,6 +5415,34 @@ anchor Points: Punti:
|
||||
@msg select open paths to close seleziona tracciati aperti da chiudere
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) tracciati troppi o troppo intricati per trovarne gli incroci (al massimo 10000 segmenti)
|
||||
@msg the paths have too many anchors to edit at once i tracciati hanno troppi punti di ancoraggio da modificare in una volta
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Strumento Forma parametrica
|
||||
Parametric Shape Forma parametrica
|
||||
Edit Parametric Shape Modifica forma parametrica
|
||||
Convert to Parametric Shape Converti in forma parametrica
|
||||
Ring Anello
|
||||
Arrow Freccia
|
||||
Cross Croce
|
||||
Heart Cuore
|
||||
Gear Ingranaggio
|
||||
Speech Bubble Fumetto
|
||||
Count: Numero:
|
||||
Thickness: Spessore:
|
||||
Inner Radius: Raggio interno:
|
||||
Head: Testa:
|
||||
Shaft: Asta:
|
||||
Point: Punta:
|
||||
Tooth Height: Altezza denti:
|
||||
Tail: Coda:
|
||||
Tip: Punta:
|
||||
Open a document to draw parametric shapes. Apri un documento per disegnare forme parametriche.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Seleziona una forma parametrica per modificarla. Senza selezione, questi valori valgono per la forma successiva.
|
||||
@msg the object is not an editable parametric shape l’oggetto non è una forma parametrica modificabile
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert seleziona rettangoli, ellissi o poligoni regolari semplici da convertire
|
||||
@msg the shape's numbers are out of range i valori della forma sono fuori intervallo
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` deve essere uno tra: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` deve essere un numero intero compreso tra 3 e 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` deve essere compreso tra {lo} e {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Penna intelligente
|
||||
Smart Pen Tool Strumento Penna intelligente
|
||||
@@ -5445,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.
|
@@ -1713,6 +1713,7 @@ font-technology Variable バリアブル
|
||||
Delete Guides ガイドを削除
|
||||
Delete Slice スライスを削除
|
||||
Delete Style スタイルを削除
|
||||
Delete Styles スタイルを削除
|
||||
Delete Variables 変数を削除
|
||||
Delete View 表示を削除
|
||||
Done Editing Pattern パターン編集を完了
|
||||
@@ -5483,6 +5484,34 @@ anchor Points: ポイント:
|
||||
@msg select open paths to close 閉じるオープンパスを選択してください
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) パスが多すぎるか複雑すぎて交差点を見つけられません(最大 10000 セグメント)
|
||||
@msg the paths have too many anchors to edit at once パスのアンカーポイントが多すぎて一度に編集できません
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool パラメトリックシェイプツール
|
||||
Parametric Shape パラメトリックシェイプ
|
||||
Edit Parametric Shape パラメトリックシェイプを編集
|
||||
Convert to Parametric Shape パラメトリックシェイプに変換
|
||||
Ring リング
|
||||
Arrow 矢印
|
||||
Cross 十字
|
||||
Heart ハート
|
||||
Gear 歯車
|
||||
Speech Bubble 吹き出し
|
||||
Count: 数:
|
||||
Thickness: 太さ:
|
||||
Inner Radius: 内側の半径:
|
||||
Head: 矢じり:
|
||||
Shaft: 軸:
|
||||
Point: 先端:
|
||||
Tooth Height: 歯の高さ:
|
||||
Tail: しっぽ:
|
||||
Tip: 先端:
|
||||
Open a document to draw parametric shapes. パラメトリックシェイプを描くにはドキュメントを開いてください。
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. 編集するパラメトリックシェイプを選択してください。選択がないときは、次に描くシェイプの値になります。
|
||||
@msg the object is not an editable parametric shape オブジェクトは編集可能なパラメトリックシェイプではありません
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert 変換する通常の長方形、楕円または正多角形を選択してください
|
||||
@msg the shape's numbers are out of range シェイプの値が範囲外です
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` には次のいずれかを指定してください:rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` には 3 から 1000 の整数を指定してください
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` には {lo} から {hi} を指定してください
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen スマートペン
|
||||
Smart Pen Tool スマートペンツール
|
||||
@@ -5514,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.
|
@@ -5185,6 +5185,7 @@ imageTrace Outline კონტური
|
||||
Delete Light სინათლის წაშლა
|
||||
Delete Slice ნაჭრის წაშლა
|
||||
Delete Style სტილის წაშლა
|
||||
Delete Styles სტილების წაშლა
|
||||
Delete Variables ცვლადების წაშლა
|
||||
Delete View ხედის წაშლა
|
||||
Done Editing Pattern პატერნის რედაქტირების დასრულება
|
||||
@@ -5787,6 +5788,32 @@ anchor Points: წერტილები:
|
||||
@msg select open paths to close მონიშნეთ შესაკრავი ღია ტრაექტორიები
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) ტრაექტორიები ზედმეტად ბევრი ან ჩახლართულია გადაკვეთების საპოვნელად (მაქსიმუმ 10000 სეგმენტი)
|
||||
@msg the paths have too many anchors to edit at once ტრაექტორიებს ზედმეტად ბევრი საყრდენი წერტილი აქვს ერთბაშად დასარედაქტირებლად
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool პარამეტრული ფიგურის ხელსაწყო
|
||||
Parametric Shape პარამეტრული ფიგურა
|
||||
Edit Parametric Shape პარამეტრული ფიგურის რედაქტირება
|
||||
Convert to Parametric Shape პარამეტრულ ფიგურად გარდაქმნა
|
||||
Ring რგოლი
|
||||
Heart გული
|
||||
Gear კბილანა
|
||||
Speech Bubble ტექსტის ბუშტი
|
||||
Count: რაოდენობა:
|
||||
Thickness: სისქე:
|
||||
Inner Radius: შიდა რადიუსი:
|
||||
Head: თავი:
|
||||
Shaft: ღერო:
|
||||
Point: წვერი:
|
||||
Tooth Height: კბილის სიმაღლე:
|
||||
Tail: კუდი:
|
||||
Tip: წვერი:
|
||||
Open a document to draw parametric shapes. გახსენით დოკუმენტი პარამეტრული ფიგურების დასახატად.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. მონიშნეთ პარამეტრული ფიგურა მის დასარედაქტირებლად. მონიშვნის გარეშე ეს მნიშვნელობები შემდეგ ფიგურას ეხება.
|
||||
@msg the object is not an editable parametric shape ობიექტი არ არის რედაქტირებადი პარამეტრული ფიგურა
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert მონიშნეთ გარდასაქმნელი ჩვეულებრივი მართკუთხედები, ელიფსები ან წესიერი მრავალკუთხედები
|
||||
@msg the shape's numbers are out of range ფიგურის მნიშვნელობები დასაშვებ დიაპაზონს სცდება
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` უნდა იყოს ერთ-ერთი: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` უნდა იყოს მთელი რიცხვი 3-დან 1000-მდე
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` უნდა იყოს {lo}-დან {hi}-მდე
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen ჭკვიანი კალამი
|
||||
Smart Pen Tool ჭკვიანი კალმის ხელსაწყო
|
||||
@@ -5818,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.
|
@@ -1701,6 +1701,7 @@ font-technology Variable 가변
|
||||
Delete Guides 안내선 삭제
|
||||
Delete Slice 분할 삭제
|
||||
Delete Style 스타일 삭제
|
||||
Delete Styles 스타일 삭제
|
||||
Delete Variables 변수 삭제
|
||||
Delete View 보기 삭제
|
||||
Done Editing Pattern 패턴 편집 완료
|
||||
@@ -5377,6 +5378,34 @@ anchor Points: 포인트:
|
||||
@msg select open paths to close 닫을 열린 패스를 선택하세요
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 패스가 너무 많거나 너무 얽혀 교차점을 찾을 수 없습니다(최대 10000개 세그먼트)
|
||||
@msg the paths have too many anchors to edit at once 패스의 앵커 포인트가 너무 많아 한 번에 편집할 수 없습니다
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool 파라메트릭 모양 도구
|
||||
Parametric Shape 파라메트릭 모양
|
||||
Edit Parametric Shape 파라메트릭 모양 편집
|
||||
Convert to Parametric Shape 파라메트릭 모양으로 변환
|
||||
Ring 고리
|
||||
Arrow 화살표
|
||||
Cross 십자
|
||||
Heart 하트
|
||||
Gear 톱니바퀴
|
||||
Speech Bubble 말풍선
|
||||
Count: 개수:
|
||||
Thickness: 두께:
|
||||
Inner Radius: 안쪽 반지름:
|
||||
Head: 머리:
|
||||
Shaft: 몸통:
|
||||
Point: 끝:
|
||||
Tooth Height: 톱니 높이:
|
||||
Tail: 꼬리:
|
||||
Tip: 끝:
|
||||
Open a document to draw parametric shapes. 파라메트릭 모양을 그리려면 문서를 여세요.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. 편집할 파라메트릭 모양을 선택하세요. 선택하지 않으면 이 값은 다음 모양에 적용됩니다.
|
||||
@msg the object is not an editable parametric shape 개체가 편집 가능한 파라메트릭 모양이 아닙니다
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert 변환할 일반 사각형, 타원 또는 정다각형을 선택하세요
|
||||
@msg the shape's numbers are out of range 모양의 값이 범위를 벗어났습니다
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind`은(는) 다음 중 하나여야 합니다: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count`는 3에서 1000 사이의 정수여야 합니다
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}`은(는) {lo}에서 {hi} 사이여야 합니다
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen 스마트 펜
|
||||
Smart Pen Tool 스마트 펜 도구
|
||||
@@ -5408,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.
|
@@ -5341,6 +5341,7 @@ swatch Bright Violet Felviolet
|
||||
Delete Guides Hulplijnen verwijderen
|
||||
Delete Slice Slice verwijderen
|
||||
Delete Style Stijl verwijderen
|
||||
Delete Styles Stijlen verwijderen
|
||||
Delete Variables Variabelen verwijderen
|
||||
Delete View Weergave verwijderen
|
||||
Done Editing Pattern Patroon bewerken voltooid
|
||||
@@ -5746,6 +5747,32 @@ anchor Points: Punten:
|
||||
@msg select open paths to close selecteer open paden om te sluiten
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) te veel of te verwarde paden om hun kruisingen te vinden (hoogstens 10000 segmenten)
|
||||
@msg the paths have too many anchors to edit at once de paden hebben te veel ankerpunten om in één keer te bewerken
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrische vorm-gereedschap
|
||||
Parametric Shape Parametrische vorm
|
||||
Edit Parametric Shape Parametrische vorm bewerken
|
||||
Convert to Parametric Shape Omzetten in parametrische vorm
|
||||
Ring Ring
|
||||
Heart Hart
|
||||
Gear Tandwiel
|
||||
Speech Bubble Tekstballon
|
||||
Count: Aantal:
|
||||
Thickness: Dikte:
|
||||
Inner Radius: Binnenstraal:
|
||||
Head: Kop:
|
||||
Shaft: Schacht:
|
||||
Point: Punt:
|
||||
Tooth Height: Tandhoogte:
|
||||
Tail: Staart:
|
||||
Tip: Punt:
|
||||
Open a document to draw parametric shapes. Open een document om parametrische vormen te tekenen.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Selecteer een parametrische vorm om die te bewerken. Zonder selectie gelden deze waarden voor de volgende vorm.
|
||||
@msg the object is not an editable parametric shape het object is geen bewerkbare parametrische vorm
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert selecteer gewone rechthoeken, ellipsen of regelmatige polygonen om om te zetten
|
||||
@msg the shape's numbers are out of range de waarden van de vorm liggen buiten het bereik
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` moet een van deze zijn: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` moet een geheel getal van 3 tot 1000 zijn
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` moet tussen {lo} en {hi} liggen
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Slimme pen
|
||||
Smart Pen Tool Slimme pen-gereedschap
|
||||
@@ -5777,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.
|
@@ -1508,6 +1508,7 @@ lighting Light Światło
|
||||
Delete Guides Usuń linie pomocnicze
|
||||
Delete Slice Usuń plasterek
|
||||
Delete Style Usuń styl
|
||||
Delete Styles Usuń style
|
||||
Delete Variables Usuń zmienne
|
||||
Delete View Usuń widok
|
||||
Done Editing Pattern Zakończ edycję wzorka
|
||||
@@ -5652,6 +5653,32 @@ anchor Points: Punkty:
|
||||
@msg select open paths to close zaznacz otwarte ścieżki do zamknięcia
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) za dużo ścieżek lub są zbyt splątane, by znaleźć ich skrzyżowania (najwyżej 10000 segmentów)
|
||||
@msg the paths have too many anchors to edit at once ścieżki mają za dużo punktów kontrolnych, by edytować je naraz
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Narzędzie Kształt parametryczny
|
||||
Parametric Shape Kształt parametryczny
|
||||
Edit Parametric Shape Edytuj kształt parametryczny
|
||||
Convert to Parametric Shape Konwertuj na kształt parametryczny
|
||||
Ring Pierścień
|
||||
Heart Serce
|
||||
Gear Koło zębate
|
||||
Speech Bubble Dymek
|
||||
Count: Liczba:
|
||||
Thickness: Grubość:
|
||||
Inner Radius: Promień wewnętrzny:
|
||||
Head: Grot:
|
||||
Shaft: Trzon:
|
||||
Point: Czubek:
|
||||
Tooth Height: Wysokość zęba:
|
||||
Tail: Ogonek:
|
||||
Tip: Czubek:
|
||||
Open a document to draw parametric shapes. Otwórz dokument, aby rysować kształty parametryczne.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Zaznacz kształt parametryczny, aby go edytować. Bez zaznaczenia te wartości dotyczą następnego kształtu.
|
||||
@msg the object is not an editable parametric shape obiekt nie jest edytowalnym kształtem parametrycznym
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert zaznacz zwykłe prostokąty, elipsy lub wielokąty foremne do konwersji
|
||||
@msg the shape's numbers are out of range wartości kształtu są poza zakresem
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` musi mieć jedną z wartości: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` musi być liczbą całkowitą od 3 do 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` musi mieć wartość od {lo} do {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Inteligentne pióro
|
||||
Smart Pen Tool Narzędzie Inteligentne pióro
|
||||
@@ -5683,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.
|
@@ -1224,6 +1224,7 @@
|
||||
Delete Guides Excluir Guias
|
||||
Delete Slice Excluir Fatia
|
||||
Delete Style Excluir Estilo
|
||||
Delete Styles Excluir Estilos
|
||||
Delete Variables Excluir Variáveis
|
||||
Delete View Excluir Exibição
|
||||
Done Editing Pattern Concluir Edição de Padrão
|
||||
@@ -5715,6 +5716,32 @@ anchor Points: Pontos:
|
||||
@msg select open paths to close selecione caminhos abertos para fechar
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) caminhos demais, ou emaranhados demais, para encontrar seus cruzamentos (no máximo 10000 segmentos)
|
||||
@msg the paths have too many anchors to edit at once os caminhos têm pontos de ancoragem demais para editar de uma vez
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Ferramenta Forma Paramétrica
|
||||
Parametric Shape Forma Paramétrica
|
||||
Edit Parametric Shape Editar Forma Paramétrica
|
||||
Convert to Parametric Shape Converter em Forma Paramétrica
|
||||
Ring Anel
|
||||
Heart Coração
|
||||
Gear Engrenagem
|
||||
Speech Bubble Balão de Fala
|
||||
Count: Quantidade:
|
||||
Thickness: Espessura:
|
||||
Inner Radius: Raio Interno:
|
||||
Head: Ponta:
|
||||
Shaft: Haste:
|
||||
Point: Bico:
|
||||
Tooth Height: Altura do Dente:
|
||||
Tail: Rabicho:
|
||||
Tip: Ponta:
|
||||
Open a document to draw parametric shapes. Abra um documento para desenhar formas paramétricas.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Selecione uma forma paramétrica para editá-la. Sem seleção, estes valores valem para a próxima forma.
|
||||
@msg the object is not an editable parametric shape o objeto não é uma forma paramétrica editável
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert selecione retângulos, elipses ou polígonos regulares simples para converter
|
||||
@msg the shape's numbers are out of range os valores da forma estão fora do intervalo
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` deve ser um destes: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` deve ser um número inteiro de 3 a 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` deve estar entre {lo} e {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Caneta Inteligente
|
||||
Smart Pen Tool Ferramenta Caneta Inteligente
|
||||
@@ -5746,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.
|
@@ -1428,6 +1428,7 @@ file-filter Templates Шаблоны
|
||||
Delete Guides Удалить направляющие
|
||||
Delete Slice Удалить фрагмент
|
||||
Delete Style Удалить стиль
|
||||
Delete Styles Удалить стили
|
||||
Delete Variables Удалить переменные
|
||||
Delete View Удалить вид
|
||||
Done Editing Pattern Завершить редактирование узора
|
||||
@@ -5775,6 +5776,32 @@ anchor Points: Точки:
|
||||
@msg select open paths to close выделите незамкнутые контуры, чтобы замкнуть их
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контуров слишком много или они слишком запутаны, чтобы найти их пересечения (не более 10000 сегментов)
|
||||
@msg the paths have too many anchors to edit at once у контуров слишком много опорных точек, чтобы править их за раз
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Инструмент «Параметрическая фигура»
|
||||
Parametric Shape Параметрическая фигура
|
||||
Edit Parametric Shape Изменить параметрическую фигуру
|
||||
Convert to Parametric Shape Преобразовать в параметрическую фигуру
|
||||
Ring Кольцо
|
||||
Heart Сердце
|
||||
Gear Шестерёнка
|
||||
Speech Bubble Речевой пузырь
|
||||
Count: Количество:
|
||||
Thickness: Толщина:
|
||||
Inner Radius: Внутренний радиус:
|
||||
Head: Наконечник:
|
||||
Shaft: Древко:
|
||||
Point: Острие:
|
||||
Tooth Height: Высота зуба:
|
||||
Tail: Хвостик:
|
||||
Tip: Кончик:
|
||||
Open a document to draw parametric shapes. Откройте документ, чтобы рисовать параметрические фигуры.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Выделите параметрическую фигуру, чтобы править её. Без выделения эти значения применяются к следующей фигуре.
|
||||
@msg the object is not an editable parametric shape объект не является редактируемой параметрической фигурой
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert выделите обычные прямоугольники, эллипсы или правильные многоугольники для преобразования
|
||||
@msg the shape's numbers are out of range значения фигуры вне допустимого диапазона
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` должно быть одним из: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` должно быть целым числом от 3 до 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` должно быть от {lo} до {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Умное перо
|
||||
Smart Pen Tool Инструмент «Умное перо»
|
||||
@@ -5806,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.
|
@@ -1382,6 +1382,7 @@ font-technology Variable Variabla
|
||||
Delete Guides Radera stödlinjer
|
||||
Delete Slice Radera skiva
|
||||
Delete Style Radera stil
|
||||
Delete Styles Radera stilar
|
||||
Delete Variables Radera variabler
|
||||
Delete View Radera vy
|
||||
Done Editing Pattern Avsluta mönsterredigering
|
||||
@@ -5821,6 +5822,32 @@ anchor Points: Punkter:
|
||||
@msg select open paths to close markera öppna banor att stänga
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) för många eller för trassliga banor för att hitta deras korsningar (högst 10000 segment)
|
||||
@msg the paths have too many anchors to edit at once banorna har för många ankarpunkter för att redigeras på en gång
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrisk form-verktyget
|
||||
Parametric Shape Parametrisk form
|
||||
Edit Parametric Shape Redigera parametrisk form
|
||||
Convert to Parametric Shape Konvertera till parametrisk form
|
||||
Ring Ring
|
||||
Heart Hjärta
|
||||
Gear Kugghjul
|
||||
Speech Bubble Pratbubbla
|
||||
Count: Antal:
|
||||
Thickness: Tjocklek:
|
||||
Inner Radius: Inre radie:
|
||||
Head: Huvud:
|
||||
Shaft: Skaft:
|
||||
Point: Spets:
|
||||
Tooth Height: Tandhöjd:
|
||||
Tail: Svans:
|
||||
Tip: Spets:
|
||||
Open a document to draw parametric shapes. Öppna ett dokument för att rita parametriska former.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Markera en parametrisk form för att redigera den. Utan markering gäller värdena nästa form.
|
||||
@msg the object is not an editable parametric shape objektet är inte en redigerbar parametrisk form
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert markera vanliga rektanglar, ellipser eller regelbundna polygoner att konvertera
|
||||
@msg the shape's numbers are out of range formens värden ligger utanför intervallet
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` måste vara något av: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` måste vara ett heltal från 3 till 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` måste vara från {lo} till {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Smart penna
|
||||
Smart Pen Tool Smart penna-verktyget
|
||||
@@ -5884,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.
|
@@ -5147,6 +5147,7 @@ theme Appearance Görünüm
|
||||
Delete Guides Kılavuzları Sil
|
||||
Delete Slice Dilimi Sil
|
||||
Delete Style Stili Sil
|
||||
Delete Styles Stilleri Sil
|
||||
Delete Variables Değişkenleri Sil
|
||||
Delete View Görünümü Sil
|
||||
Done Editing Pattern Desen Düzenlemeyi Bitir
|
||||
@@ -5755,6 +5756,32 @@ anchor Points: Noktalar:
|
||||
@msg select open paths to close kapatılacak açık yolları seçin
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) kesişimlerini bulmak için yollar çok fazla veya çok karışık (en fazla 10000 bölüm)
|
||||
@msg the paths have too many anchors to edit at once yolların aynı anda düzenlenemeyecek kadar çok bağlantı noktası var
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Parametrik Şekil Aracı
|
||||
Parametric Shape Parametrik Şekil
|
||||
Edit Parametric Shape Parametrik Şekli Düzenle
|
||||
Convert to Parametric Shape Parametrik Şekle Dönüştür
|
||||
Ring Halka
|
||||
Heart Kalp
|
||||
Gear Dişli
|
||||
Speech Bubble Konuşma Balonu
|
||||
Count: Sayı:
|
||||
Thickness: Kalınlık:
|
||||
Inner Radius: İç Yarıçap:
|
||||
Head: Uç:
|
||||
Shaft: Gövde:
|
||||
Point: Sivri Uç:
|
||||
Tooth Height: Diş Yüksekliği:
|
||||
Tail: Kuyruk:
|
||||
Tip: Uç:
|
||||
Open a document to draw parametric shapes. Parametrik şekiller çizmek için bir belge açın.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Düzenlemek için bir parametrik şekil seçin. Seçim yokken bu değerler bir sonraki şekle uygulanır.
|
||||
@msg the object is not an editable parametric shape nesne düzenlenebilir bir parametrik şekil değil
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert dönüştürülecek düz dikdörtgenleri, elipsleri veya düzgün çokgenleri seçin
|
||||
@msg the shape's numbers are out of range şeklin değerleri aralığın dışında
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` şunlardan biri olmalıdır: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` 3 ile 1000 arasında bir tam sayı olmalıdır
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` {lo} ile {hi} arasında olmalıdır
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Akıllı Kalem
|
||||
Smart Pen Tool Akıllı Kalem Aracı
|
||||
@@ -5786,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.
|
@@ -1424,6 +1424,7 @@ file-filter Templates Шаблони
|
||||
Delete Guides Видалити напрямні
|
||||
Delete Slice Видалити фрагмент
|
||||
Delete Style Видалити стиль
|
||||
Delete Styles Видалити стилі
|
||||
Delete Variables Видалити змінні
|
||||
Delete View Видалити вигляд
|
||||
Done Editing Pattern Завершити редагування візерунка
|
||||
@@ -5394,6 +5395,34 @@ anchor Points: Точки:
|
||||
@msg select open paths to close виділіть незамкнені контури, щоб замкнути їх
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) контурів забагато або вони надто заплутані, щоб знайти їхні перетини (не більше 10000 сегментів)
|
||||
@msg the paths have too many anchors to edit at once у контурів забагато опорних точок, щоб редагувати їх за раз
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Інструмент «Параметрична фігура»
|
||||
Parametric Shape Параметрична фігура
|
||||
Edit Parametric Shape Редагувати параметричну фігуру
|
||||
Convert to Parametric Shape Перетворити на параметричну фігуру
|
||||
Ring Кільце
|
||||
Arrow Стрілка
|
||||
Cross Хрест
|
||||
Heart Серце
|
||||
Gear Зубчасте колесо
|
||||
Speech Bubble Мовна бульбашка
|
||||
Count: Кількість:
|
||||
Thickness: Товщина:
|
||||
Inner Radius: Внутрішній радіус:
|
||||
Head: Наконечник:
|
||||
Shaft: Держак:
|
||||
Point: Вістря:
|
||||
Tooth Height: Висота зуба:
|
||||
Tail: Хвостик:
|
||||
Tip: Кінчик:
|
||||
Open a document to draw parametric shapes. Відкрийте документ, щоб малювати параметричні фігури.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Виділіть параметричну фігуру, щоб редагувати її. Без виділення ці значення стосуються наступної фігури.
|
||||
@msg the object is not an editable parametric shape об’єкт не є редагованою параметричною фігурою
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert виділіть звичайні прямокутники, еліпси або правильні багатокутники для перетворення
|
||||
@msg the shape's numbers are out of range значення фігури поза допустимим діапазоном
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` має бути одним із: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` має бути цілим числом від 3 до 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` має бути від {lo} до {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Розумне перо
|
||||
Smart Pen Tool Інструмент «Розумне перо»
|
||||
@@ -5425,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.
|
@@ -1637,6 +1637,7 @@ file-filter Templates Mẫu
|
||||
Delete Guides Xóa các đường căn
|
||||
Delete Slice Xóa lát cắt
|
||||
Delete Style Xóa kiểu
|
||||
Delete Styles Xóa kiểu
|
||||
Delete Variables Xóa biến số
|
||||
Delete View Xóa chế độ xem
|
||||
Done Editing Pattern Hoàn tất chỉnh sửa họa tiết
|
||||
@@ -5396,6 +5397,34 @@ anchor Points: Điểm:
|
||||
@msg select open paths to close chọn các đường dẫn mở để đóng
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) quá nhiều đường dẫn hoặc chúng quá rối để tìm chỗ giao nhau (tối đa 10000 đoạn)
|
||||
@msg the paths have too many anchors to edit at once các đường dẫn có quá nhiều điểm neo để sửa cùng lúc
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool Công cụ Hình tham số
|
||||
Parametric Shape Hình tham số
|
||||
Edit Parametric Shape Sửa hình tham số
|
||||
Convert to Parametric Shape Chuyển thành hình tham số
|
||||
Ring Vành khuyên
|
||||
Arrow Mũi tên
|
||||
Cross Chữ thập
|
||||
Heart Trái tim
|
||||
Gear Bánh răng
|
||||
Speech Bubble Bong bóng thoại
|
||||
Count: Số lượng:
|
||||
Thickness: Độ dày:
|
||||
Inner Radius: Bán kính trong:
|
||||
Head: Đầu:
|
||||
Shaft: Thân:
|
||||
Point: Mũi:
|
||||
Tooth Height: Chiều cao răng:
|
||||
Tail: Đuôi:
|
||||
Tip: Chóp:
|
||||
Open a document to draw parametric shapes. Mở một tài liệu để vẽ hình tham số.
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. Chọn một hình tham số để sửa nó. Khi không chọn gì, các giá trị này dùng cho hình tiếp theo.
|
||||
@msg the object is not an editable parametric shape đối tượng không phải hình tham số có thể sửa
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert chọn các hình chữ nhật, hình elip hoặc đa giác đều thông thường để chuyển đổi
|
||||
@msg the shape's numbers are out of range các giá trị của hình nằm ngoài phạm vi
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` phải là một trong: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` phải là số nguyên từ 3 đến 1000
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` phải từ {lo} đến {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen Bút thông minh
|
||||
Smart Pen Tool Công cụ Bút thông minh
|
||||
@@ -5427,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.
|
@@ -1362,6 +1362,7 @@ file-filter Templates 模板
|
||||
Delete Guides 删除参考线
|
||||
Delete Slice 删除切片
|
||||
Delete Style 删除样式
|
||||
Delete Styles 删除样式
|
||||
Delete Variables 删除变量
|
||||
Delete View 删除视图
|
||||
Done Editing Pattern 完成图案编辑
|
||||
@@ -5637,6 +5638,34 @@ anchor Points: 点:
|
||||
@msg select open paths to close 请选择要闭合的开放路径
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路径太多或太复杂,无法找到交叉处(最多 10000 段)
|
||||
@msg the paths have too many anchors to edit at once 路径的锚点太多,无法一次编辑
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool 参数化形状工具
|
||||
Parametric Shape 参数化形状
|
||||
Edit Parametric Shape 编辑参数化形状
|
||||
Convert to Parametric Shape 转换为参数化形状
|
||||
Ring 圆环
|
||||
Arrow 箭头
|
||||
Cross 十字
|
||||
Heart 心形
|
||||
Gear 齿轮
|
||||
Speech Bubble 对话气泡
|
||||
Count: 数量:
|
||||
Thickness: 粗细:
|
||||
Inner Radius: 内半径:
|
||||
Head: 箭头头部:
|
||||
Shaft: 箭杆:
|
||||
Point: 尖端:
|
||||
Tooth Height: 齿高:
|
||||
Tail: 尾巴:
|
||||
Tip: 尖端:
|
||||
Open a document to draw parametric shapes. 打开文档以绘制参数化形状。
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. 选择一个参数化形状以编辑它。未选择时,这些值用于下一个形状。
|
||||
@msg the object is not an editable parametric shape 该对象不是可编辑的参数化形状
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert 请选择要转换的普通矩形、椭圆或正多边形
|
||||
@msg the shape's numbers are out of range 形状的数值超出范围
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` 必须是以下之一:rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` 必须是 3 到 1000 之间的整数
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` 必须在 {lo} 到 {hi} 之间
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen 智能钢笔
|
||||
Smart Pen Tool 智能钢笔工具
|
||||
@@ -5668,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.
|
@@ -1395,6 +1395,7 @@ file-filter Templates 範本
|
||||
Delete Guides 刪除參考線
|
||||
Delete Slice 刪除切片
|
||||
Delete Style 刪除樣式
|
||||
Delete Styles 刪除樣式
|
||||
Delete Variables 刪除變數
|
||||
Delete View 刪除檢視
|
||||
Done Editing Pattern 完成圖樣編輯
|
||||
@@ -5433,6 +5434,34 @@ anchor Points: 點:
|
||||
@msg select open paths to close 請選取要封閉的開放路徑
|
||||
@msg the paths are too many or too tangled to find where they cross (at most 10000 segments) 路徑太多或太複雜,無法找到交叉處(最多 10000 段)
|
||||
@msg the paths have too many anchors to edit at once 路徑的錨點太多,無法一次編輯
|
||||
# Path Precision Tools add-on: the Parametric Shape Tool, its panel, commands and messages.
|
||||
Parametric Shape Tool 參數化形狀工具
|
||||
Parametric Shape 參數化形狀
|
||||
Edit Parametric Shape 編輯參數化形狀
|
||||
Convert to Parametric Shape 轉換為參數化形狀
|
||||
Ring 圓環
|
||||
Arrow 箭頭
|
||||
Cross 十字
|
||||
Heart 心形
|
||||
Gear 齒輪
|
||||
Speech Bubble 對話氣泡
|
||||
Count: 數量:
|
||||
Thickness: 粗細:
|
||||
Inner Radius: 內半徑:
|
||||
Head: 箭頭頭部:
|
||||
Shaft: 箭桿:
|
||||
Point: 尖端:
|
||||
Tooth Height: 齒高:
|
||||
Tail: 尾巴:
|
||||
Tip: 尖端:
|
||||
Open a document to draw parametric shapes. 開啟文件以繪製參數化形狀。
|
||||
Select a parametric shape to edit it. With none selected, these set the next shape. 選取一個參數化形狀以編輯它。未選取時,這些值用於下一個形狀。
|
||||
@msg the object is not an editable parametric shape 該物件不是可編輯的參數化形狀
|
||||
@msg select plain rectangles, ellipses or regular polygons to convert 請選取要轉換的一般矩形、橢圓或正多邊形
|
||||
@msg the shape's numbers are out of range 形狀的數值超出範圍
|
||||
@msg `kind` must be one of: rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble `kind` 必須是下列其中之一:rectangle, ellipse, polygon, star, ring, arrow, cross, heart, gear, bubble
|
||||
@msg `count` must be a whole number from 3 to 1000 `count` 必須是介於 3 到 1000 的整數
|
||||
@msg `{key}` must be from {lo} to {hi} `{key}` 必須介於 {lo} 到 {hi}
|
||||
# Smart Pen add-on (Object › Plug-ins › Smart Pen): its tool, panel, commands and messages.
|
||||
Smart Pen 智慧筆型
|
||||
Smart Pen Tool 智慧筆型工具
|
||||
@@ -5464,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.
|
@@ -182,6 +182,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
crate::addons::cleanup::ID => crate::addons::cleanup::show(app, ui),
|
||||
crate::addons::color::ID => crate::addons::color::show(app, ui),
|
||||
crate::addons::pathtools::ID => crate::addons::pathtools::show(app, ui),
|
||||
crate::addons::pathtools::shape::ID => crate::addons::pathtools::shape::show(app, ui),
|
||||
crate::addons::pen::ID => crate::addons::pen::show(app, ui),
|
||||
id if library_panel::parse(id).is_some() => library_panel::show(app, ui, id),
|
||||
_ => {
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
//! "+" when the text overrides some of its attributes. New makes a style from the selected text;
|
||||
//! Alt-click on New first asks for its name (`ui.newCharStyle`, `ui.newParaStyle`).
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::{Color32, Sense, Ui, vec2};
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_engine::cmd::textstyles;
|
||||
|
||||
use super::character::{end_typing, text_editing, text_style};
|
||||
use super::memo::Memo;
|
||||
use super::{alt_held, pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
@@ -35,28 +39,93 @@ impl Kind {
|
||||
fn key(self) -> &'static str {
|
||||
if self == Kind::Char { "char-style-sel" } else { "para-style-sel" }
|
||||
}
|
||||
/// Panel state holding the styles highlighted together, with the style [`Kind::key`] held
|
||||
/// when they were highlighted.
|
||||
fn many_key(self) -> &'static str {
|
||||
if self == Kind::Char { "char-style-many" } else { "para-style-many" }
|
||||
}
|
||||
/// Panel state holding the style a Shift-click extends the highlight from.
|
||||
fn anchor_key(self) -> &'static str {
|
||||
if self == Kind::Char { "char-style-anchor" } else { "para-style-anchor" }
|
||||
}
|
||||
/// The style row the panel scrolls into view the next time it draws.
|
||||
fn scroll_key(self) -> &'static str {
|
||||
if self == Kind::Char { "char-style-scroll" } else { "para-style-scroll" }
|
||||
}
|
||||
/// Panel state holding the `uid` of the document the highlight was made in.
|
||||
fn document_key(self) -> &'static str {
|
||||
if self == Kind::Char { "char-style-document" } else { "para-style-document" }
|
||||
}
|
||||
}
|
||||
|
||||
/// Highlights a style the panel just made and scrolls its row into view the next time it draws.
|
||||
fn highlight_made(ctx: &egui::Context, kind: Kind, name: Option<String>) {
|
||||
set_pstate(ctx, kind.scroll_key(), name.clone());
|
||||
set_pstate(ctx, kind.key(), name);
|
||||
/// The styles highlighted in the panel, in the order they were picked, among the styles `listed`:
|
||||
/// the styles a Shift-click, a Cmd/Ctrl-click or Select All Unused highlighted together while
|
||||
/// [`Kind::key`] still holds the style it held then, else the one style in [`Kind::key`].
|
||||
fn highlighted(app: &VectorcraftApp, ctx: &egui::Context, kind: Kind, listed: &[&str]) -> Vec<String> {
|
||||
let document = app.session.active().map(|st| st.uid);
|
||||
if pstate::<Option<u64>>(ctx, kind.document_key()) != document {
|
||||
set_pstate(ctx, kind.document_key(), document);
|
||||
set_highlighted(ctx, kind, vec![]);
|
||||
set_pstate(ctx, kind.anchor_key(), String::new());
|
||||
set_pstate::<Option<String>>(ctx, kind.scroll_key(), None);
|
||||
}
|
||||
let one: Option<String> = pstate(ctx, kind.key());
|
||||
let (with, many): (Option<String>, Vec<String>) = pstate(ctx, kind.many_key());
|
||||
let mut names = if with == one && !many.is_empty() { many } else { one.into_iter().collect() };
|
||||
names.retain(|n| listed.contains(&n.as_str()));
|
||||
names
|
||||
}
|
||||
|
||||
/// Highlights `names` in the panel: [`Kind::key`] holds the first of them.
|
||||
fn set_highlighted(ctx: &egui::Context, kind: Kind, names: Vec<String>) {
|
||||
let one = names.first().cloned();
|
||||
set_pstate(ctx, kind.key(), one.clone());
|
||||
set_pstate(ctx, kind.many_key(), (one, names));
|
||||
}
|
||||
|
||||
/// Highlights the styles the panel just made and scrolls the first one's row into view the next
|
||||
/// time it draws.
|
||||
fn highlight_made(ctx: &egui::Context, kind: Kind, names: Vec<String>) {
|
||||
set_pstate(ctx, kind.scroll_key(), names.first().cloned());
|
||||
set_highlighted(ctx, kind, names);
|
||||
}
|
||||
|
||||
/// Duplicates the styles `names` (`charStyle.duplicate`, `paraStyle.duplicate`) in one undo step
|
||||
/// and highlights the copies.
|
||||
fn duplicate(app: &mut VectorcraftApp, ctx: &egui::Context, kind: Kind, names: &[String]) {
|
||||
let p = kind.prefix();
|
||||
let made = if let [name] = names {
|
||||
app.run(&format!("{p}.duplicate"), json!({ "name": name })).map(|r| vec![r])
|
||||
} else {
|
||||
let commands: Vec<Value> = names.iter().map(|n| json!({ "command": format!("{p}.duplicate"), "params": { "name": n } })).collect();
|
||||
let label = if kind == Kind::Char { "New Character Style" } else { "New Paragraph Style" };
|
||||
app.run("command.batch", json!({ "label": label, "commands": commands })).map(|r| r["results"].as_array().cloned().unwrap_or_default())
|
||||
};
|
||||
if let Ok(made) = made {
|
||||
highlight_made(ctx, kind, made.iter().filter_map(|r| r["name"].as_str().map(str::to_string)).collect());
|
||||
}
|
||||
}
|
||||
|
||||
/// A row dragged in the list: the style's kind and name. Dropped on Create New Style it is
|
||||
/// duplicated (`charStyle.duplicate`, `paraStyle.duplicate`); dropped on Delete it is deleted
|
||||
/// (`charStyle.delete`, `paraStyle.delete`) unless it is Normal. Each panel's buttons accept only rows of
|
||||
/// their own kind.
|
||||
/// (`charStyle.delete`, `paraStyle.delete`) unless it is Normal. A highlighted row carries every
|
||||
/// highlighted style with it. Each panel's buttons accept only rows of their own kind.
|
||||
#[derive(Clone)]
|
||||
struct RowDrag {
|
||||
kind: Kind,
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// The styles a row dragged from the list carries: every highlighted style (`sel`) in the order
|
||||
/// the panel lists them when the row is one of them, else the row's style alone.
|
||||
fn dragged(sel: &[String], listed: &[&str], name: &str) -> Vec<String> {
|
||||
if sel.iter().any(|s| s == name) {
|
||||
listed.iter().filter(|n| sel.iter().any(|s| s == *n)).map(|n| n.to_string()).collect()
|
||||
} else {
|
||||
vec![name.to_string()]
|
||||
}
|
||||
}
|
||||
|
||||
/// The style row of `kind` released on `button` this frame, if any; `button` is outlined while one
|
||||
/// is held over it. `take`: whether a row with that name may be dropped there.
|
||||
fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str) -> bool) -> Option<String> {
|
||||
@@ -65,9 +134,18 @@ fn row_dropped(ui: &Ui, button: &egui::Response, kind: Kind, take: impl Fn(&str)
|
||||
button.dnd_release_payload::<RowDrag>().map(|_| held.name.clone())
|
||||
}
|
||||
|
||||
/// (name, attrs, uses) for every style, Normal first.
|
||||
/// (name, attrs, uses) for every style, Normal first, listed once per document version.
|
||||
fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map<String, Value>, u64)> {
|
||||
let v = app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default();
|
||||
thread_local! {
|
||||
static MEMO: RefCell<[Memo<Document, Value>; 2]> = RefCell::new([Memo::default(), Memo::default()]);
|
||||
}
|
||||
let Some((doc, revision)) = app.session.active().map(|st| (st.doc.clone(), st.revision)) else { return vec![] };
|
||||
let list = || {
|
||||
#[cfg(test)]
|
||||
LISTED.with(|n| n.set(n.get() + 1));
|
||||
app.session.execute(&format!("{}.list", kind.prefix()), &json!({})).unwrap_or_default()
|
||||
};
|
||||
let v = MEMO.with(|m| m.borrow_mut()[kind as usize].get(&doc, revision, list));
|
||||
v["styles"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
@@ -84,6 +162,12 @@ fn styles(app: &mut VectorcraftApp, kind: Kind) -> Vec<(String, Map<String, Valu
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many times [`styles`] has run `charStyle.list` or `paraStyle.list` on this thread.
|
||||
static LISTED: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// The selection's style name (Normal when none) and every one of its attributes, written as a
|
||||
/// style holds them, if text is selected.
|
||||
fn current(app: &VectorcraftApp, kind: Kind) -> Option<(Option<String>, Map<String, Value>)> {
|
||||
@@ -116,14 +200,33 @@ pub(crate) fn open_new(app: &mut VectorcraftApp, kind: Kind) -> Result<Value, St
|
||||
crate::dialogs::new_text_style::open(app, kind == Kind::Para, t)
|
||||
}
|
||||
|
||||
/// A style row's fill, and whether it gets an accent outline: the current style's row is filled
|
||||
/// with `row_selected` and outlined when it is also highlighted, so Cmd/Ctrl-clicking it shows.
|
||||
fn row_look(is_cur: bool, in_highlight: bool, hovered: bool, t: &Tokens) -> (Option<Color32>, bool) {
|
||||
let fill = if is_cur {
|
||||
Some(t.row_selected)
|
||||
} else if in_highlight || hovered {
|
||||
Some(t.hover)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(fill, is_cur && in_highlight)
|
||||
}
|
||||
|
||||
/// The style list and its New and Delete buttons. Shift-click highlights every style from the
|
||||
/// last one clicked without Shift through this one, and Cmd/Ctrl-click adds or removes one; both
|
||||
/// leave the text as it is. Delete deletes every highlighted style but the Normal style.
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let all = styles(app, kind);
|
||||
let cur = current(app, kind);
|
||||
let normal = all.first().map(|s| s.0.clone()).unwrap_or_default();
|
||||
let cur_name = cur.as_ref().map(|(n, _)| n.clone().unwrap_or_else(|| normal.clone()));
|
||||
let mut clicked: Option<(String, bool)> = None;
|
||||
let mut sel: Option<String> = pstate(ui.ctx(), kind.key());
|
||||
let mut clicked: Option<(String, egui::Modifiers)> = None;
|
||||
let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect();
|
||||
// A highlighted style that is no longer listed (renamed in Style Options, deleted by a command
|
||||
// or undone) is no longer highlighted.
|
||||
let mut sel = highlighted(app, ui.ctx(), kind, &listed);
|
||||
let scroll_to: Option<String> = pstate(ui.ctx(), kind.scroll_key());
|
||||
widgets::list_box(ui, |ui| {
|
||||
for (name, attrs, _) in &all {
|
||||
@@ -137,10 +240,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
if scroll_to.as_deref() == Some(name.as_str()) {
|
||||
ui.scroll_to_rect(r, None);
|
||||
}
|
||||
if is_cur || sel.as_deref() == Some(name.as_str()) {
|
||||
ui.painter().rect_filled(r, 0.0, if is_cur { t.row_selected } else { t.hover });
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, t.hover);
|
||||
let (fill, outline) = row_look(is_cur, sel.contains(name), resp.hovered(), &t);
|
||||
if let Some(fill) = fill {
|
||||
ui.painter().rect_filled(r, 0.0, fill);
|
||||
}
|
||||
if outline {
|
||||
ui.painter().rect_stroke(r, 0.0, egui::Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
|
||||
}
|
||||
// The built-in "[Normal … Style]" (listed first) is translated where painted; other names
|
||||
// are user data, brackets or not.
|
||||
@@ -148,7 +253,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let label = if overridden { format!("{shown}+") } else { shown.to_string() };
|
||||
ui.painter().text(r.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.5), t.text);
|
||||
if resp.clicked() {
|
||||
clicked = Some((name.clone(), ui.input(|i| i.modifiers.alt)));
|
||||
clicked = Some((name.clone(), ui.input(|i| i.modifiers)));
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -156,20 +261,17 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
set_pstate::<Option<String>>(ui.ctx(), kind.scroll_key(), None);
|
||||
}
|
||||
let has_text = cur.is_some();
|
||||
if let Some((name, clear)) = clicked {
|
||||
sel = Some(name.clone());
|
||||
if has_text {
|
||||
let p = target(app, kind, json!({ "name": name, "clearOverrides": clear }));
|
||||
if let Some((name, m)) = clicked {
|
||||
sel = super::swatches::click_selection(ui, kind.anchor_key(), sel, &listed, &name, m);
|
||||
// Shift and Cmd/Ctrl clicks only highlight, as in the Graphic Styles panel.
|
||||
if has_text && !(m.shift || m.command) {
|
||||
let p = target(app, kind, json!({ "name": name, "clearOverrides": m.alt }));
|
||||
app.run(&format!("{}.apply", kind.prefix()), p).ok();
|
||||
}
|
||||
}
|
||||
// A highlighted style that is no longer listed (renamed in Style Options, deleted by a command
|
||||
// or undone) is no longer highlighted.
|
||||
if sel.as_ref().is_some_and(|s| !all.iter().any(|(n, ..)| n == s)) {
|
||||
sel = None;
|
||||
}
|
||||
set_pstate(ui.ctx(), kind.key(), sel.clone());
|
||||
let deletable = sel.as_ref().is_some_and(|s| *s != normal);
|
||||
set_highlighted(ui.ctx(), kind, sel.clone());
|
||||
let doomed: Vec<&String> = sel.iter().filter(|s| **s != normal).collect();
|
||||
let deletable = !doomed.is_empty();
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
ui.add_space((ui.available_width() - 2.0 * 28.0).max(0.0));
|
||||
let new = widgets::icon_button_enabled(
|
||||
@@ -181,15 +283,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
24.0,
|
||||
);
|
||||
if let Some(name) = row_dropped(ui, &new, kind, |_| true) {
|
||||
if let Ok(r) = app.run(&format!("{}.duplicate", kind.prefix()), json!({ "name": name })) {
|
||||
highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string));
|
||||
}
|
||||
duplicate(app, ui.ctx(), kind, &dragged(&sel, &listed, &name));
|
||||
} else if new.clicked() {
|
||||
end_typing(app);
|
||||
if alt_held(ui) {
|
||||
app.run(if kind == Kind::Char { "ui.newCharStyle" } else { "ui.newParaStyle" }, json!({})).ok();
|
||||
} else if let Ok(r) = app.run(&format!("{}.new", kind.prefix()), target(app, kind, json!({}))) {
|
||||
highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string));
|
||||
highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string).into_iter().collect());
|
||||
}
|
||||
}
|
||||
let delete = widgets::icon_button_enabled(
|
||||
@@ -200,23 +300,31 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
deletable,
|
||||
24.0,
|
||||
);
|
||||
if let Some(name) = row_dropped(ui, &delete, kind, |n| n != normal) {
|
||||
if app.run(&format!("{}.delete", kind.prefix()), json!({ "name": name })).is_ok() && sel.as_ref() == Some(&name) {
|
||||
set_pstate::<Option<String>>(ui.ctx(), kind.key(), None);
|
||||
let drop_names = |n: &str| dragged(&sel, &listed, n).into_iter().filter(|s| *s != normal).collect::<Vec<_>>();
|
||||
if let Some(name) = row_dropped(ui, &delete, kind, |n| !drop_names(n).is_empty()) {
|
||||
// Deleting the highlighted styles clears the highlight; a row dropped from outside it
|
||||
// leaves it as it is.
|
||||
if app.run(&format!("{}.delete", kind.prefix()), json!({ "names": drop_names(&name) })).is_ok() && sel.contains(&name) {
|
||||
set_highlighted(ui.ctx(), kind, vec![]);
|
||||
}
|
||||
} else if delete.clicked()
|
||||
&& let Some(n) = &sel
|
||||
&& app.run(&format!("{}.delete", kind.prefix()), json!({ "name": n })).is_ok()
|
||||
{
|
||||
set_pstate::<Option<String>>(ui.ctx(), kind.key(), None);
|
||||
} else if delete.clicked() && app.run(&format!("{}.delete", kind.prefix()), json!({ "names": doomed })).is_ok() {
|
||||
set_highlighted(ui.ctx(), kind, vec![]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The panel menu. Select All Unused highlights the styles no text uses (`charStyle.unused`,
|
||||
/// `paraStyle.unused`), Duplicate copies every highlighted style and highlights the copies, and
|
||||
/// Delete deletes every highlighted style but the Normal style.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
let sel: Option<String> = pstate(ui.ctx(), kind.key());
|
||||
let all = styles(app, kind);
|
||||
let listed: Vec<&str> = all.iter().map(|s| s.0.as_str()).collect();
|
||||
let many = highlighted(app, ui.ctx(), kind, &listed);
|
||||
// The items for one style act on the highlighted style while it is the only one.
|
||||
let sel: Option<String> = if many.len() == 1 { many.first().cloned() } else { None };
|
||||
let normal = if kind == Kind::Char { "[Normal Character Style]" } else { "[Normal Paragraph Style]" };
|
||||
let user = sel.as_ref().is_some_and(|s| s != normal);
|
||||
let doomed: Vec<&String> = many.iter().filter(|s| *s != normal).collect();
|
||||
let has_text = current(app, kind).is_some() || selection_len(app) > 0;
|
||||
let p = kind.prefix();
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("New {kind} Style…"), &[("kind", kind.what_label())]), true, false) {
|
||||
@@ -224,17 +332,19 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui, kind: Kind) {
|
||||
end_typing(app);
|
||||
app.run(&format!("{p}.new"), target(app, kind, json!({}))).ok();
|
||||
}
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), sel.is_some(), false)
|
||||
&& let Some(n) = &sel
|
||||
&& let Ok(r) = app.run(&format!("{p}.duplicate"), json!({ "name": n }))
|
||||
{
|
||||
highlight_made(ui.ctx(), kind, r["name"].as_str().map(str::to_string));
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("Duplicate {kind} Style"), &[("kind", kind.what_label())]), !many.is_empty(), false) {
|
||||
let names: Vec<String> = listed.iter().filter(|n| many.iter().any(|s| s == *n)).map(|n| n.to_string()).collect();
|
||||
duplicate(app, ui.ctx(), kind, &names);
|
||||
}
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("Delete {kind} Style"), &[("kind", kind.what_label())]), user, false)
|
||||
&& let Some(n) = &sel
|
||||
&& app.run(&format!("{p}.delete"), json!({ "name": n })).is_ok()
|
||||
if menu_item(ui, &crate::i18n::fmt(tl!("Delete {kind} Style"), &[("kind", kind.what_label())]), !doomed.is_empty(), false)
|
||||
&& app.run(&format!("{p}.delete"), json!({ "names": doomed })).is_ok()
|
||||
{
|
||||
set_pstate::<Option<String>>(ui.ctx(), kind.key(), None);
|
||||
set_highlighted(ui.ctx(), kind, vec![]);
|
||||
}
|
||||
if menu_item(ui, tl!("Select All Unused"), app.session.active().is_some(), false)
|
||||
&& let Ok(r) = app.run(&format!("{p}.unused"), json!({}))
|
||||
{
|
||||
set_highlighted(ui.ctx(), kind, serde_json::from_value(r["names"].clone()).unwrap_or_default());
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, tl!("Clear Overrides"), sel.is_some() && has_text, false)
|
||||
|
||||
@@ -3,6 +3,16 @@ use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use super::super::tests_appearance::{click, frame_events, frame_raw, text_rect};
|
||||
|
||||
#[test]
|
||||
fn current_style_highlight_has_a_separate_indicator() {
|
||||
let ctx = egui::Context::default();
|
||||
let t = Tokens::get(&ctx);
|
||||
assert_eq!(row_look(true, false, false, &t), (Some(t.row_selected), false));
|
||||
assert_eq!(row_look(true, true, false, &t), (Some(t.row_selected), true));
|
||||
assert_eq!(row_look(false, true, false, &t), (Some(t.hover), false));
|
||||
assert_eq!(row_look(false, false, false, &t), (None, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panels_draw_and_apply_headless() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
@@ -309,7 +319,7 @@ fn a_style_made_from_the_panel_is_scrolled_into_view() {
|
||||
} else {
|
||||
// The New button's path: the style it made, highlighted with `highlight_made`.
|
||||
let name = app.run(&format!("{}.new", kind.prefix()), json!({})).unwrap()["name"].as_str().unwrap_or_default().to_string();
|
||||
highlight_made(&ctx, kind, Some(name.clone()));
|
||||
highlight_made(&ctx, kind, vec![name.clone()]);
|
||||
name
|
||||
};
|
||||
assert_eq!(pstate::<Option<String>>(&ctx, kind.key()).as_deref(), Some(made.as_str()), "{what}: the new style is highlighted");
|
||||
@@ -493,6 +503,243 @@ fn character_style_range_apply_from_panel_control() {
|
||||
assert_eq!(app.session.doc().unwrap().doc, after);
|
||||
}
|
||||
|
||||
/// A document with selected type and both kinds' styles A, B, C and D, the type using B.
|
||||
fn several_app() -> (VectorcraftApp, NodeId) {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = NodeId(app.run("text.create", json!({"x": 20, "y": 50, "text": "Hi", "size": 18})).unwrap()["id"].as_u64().unwrap());
|
||||
app.run("select.set", json!({"ids": [id.0]})).unwrap();
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
for n in ["A", "B", "C", "D"] {
|
||||
app.run(&format!("{}.new", kind.prefix()), json!({"name": n, "attrs": {}})).unwrap();
|
||||
}
|
||||
app.run(&format!("{}.apply", kind.prefix()), json!({"name": "B"})).unwrap();
|
||||
}
|
||||
(app, id)
|
||||
}
|
||||
|
||||
/// Click the text `label` that `draw` paints, with `modifiers` held.
|
||||
fn click_text_held(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
label: &str,
|
||||
modifiers: egui::Modifiers,
|
||||
draw: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) {
|
||||
click_where(ctx, app, |texts| text_rect(texts, label).center(), modifiers, draw);
|
||||
}
|
||||
|
||||
/// Click where `at` finds in the texts `draw` paints, with `modifiers` held, over three frames
|
||||
/// (hover, press, release) after two frames that lay them out.
|
||||
fn click_where(
|
||||
ctx: &egui::Context,
|
||||
app: &mut VectorcraftApp,
|
||||
at: impl FnOnce(&[(String, egui::Rect)]) -> egui::Pos2,
|
||||
modifiers: egui::Modifiers,
|
||||
mut draw: impl FnMut(&mut VectorcraftApp, &mut egui::Ui),
|
||||
) {
|
||||
let held = egui::Event::ModifiersChanged(modifiers);
|
||||
frame_events(ctx, app, vec![held.clone()], &mut draw);
|
||||
let pos = at(&frame_events(ctx, app, vec![held.clone()], &mut draw));
|
||||
let button = |pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers };
|
||||
for e in [egui::Event::PointerMoved(pos), button(true), button(false)] {
|
||||
frame_events(ctx, app, vec![held.clone(), e], &mut draw);
|
||||
}
|
||||
frame_events(ctx, app, vec![egui::Event::ModifiersChanged(egui::Modifiers::NONE)], &mut draw);
|
||||
}
|
||||
|
||||
/// The styles `kind`'s panel highlights.
|
||||
fn highlighted_now(app: &mut VectorcraftApp, ctx: &egui::Context, kind: Kind) -> Vec<String> {
|
||||
let all = styles(app, kind);
|
||||
highlighted(app, ctx, kind, &all.iter().map(|s| s.0.as_str()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_documents_clears_highlighted_styles_with_matching_names() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let (mut app, _) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
|
||||
ui.set_width(280.0);
|
||||
show(app, ui, kind);
|
||||
};
|
||||
click_text_held(&ctx, &mut app, "A", egui::Modifiers::NONE, rows);
|
||||
click_text_held(&ctx, &mut app, "B", egui::Modifiers::COMMAND, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "B"]);
|
||||
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
for name in ["A", "B"] {
|
||||
app.run(&format!("{}.new", kind.prefix()), json!({"name": name, "attrs": {}})).unwrap();
|
||||
}
|
||||
assert!(highlighted_now(&mut app, &ctx, kind).is_empty(), "{} Styles: the new document has no highlighted styles", kind.what());
|
||||
assert_eq!(pstate::<String>(&ctx, kind.anchor_key()), "");
|
||||
assert_eq!(pstate::<Option<String>>(&ctx, kind.key()), None);
|
||||
assert_eq!(pstate::<(Option<String>, Vec<String>)>(&ctx, kind.many_key()).1, Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// In both panels Shift-click highlights every style from the last one clicked without Shift
|
||||
/// through the one clicked, and Cmd/Ctrl-click adds or removes one; neither applies a style to the
|
||||
/// selected type.
|
||||
/// Delete then deletes every highlighted style in one undo step, and the type keeps its look.
|
||||
#[test]
|
||||
fn shift_and_cmd_clicks_highlight_several_styles_that_delete_deletes_at_once() {
|
||||
use egui::Modifiers;
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let (mut app, id) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
|
||||
ui.set_width(280.0);
|
||||
show(app, ui, kind);
|
||||
};
|
||||
click_text_held(&ctx, &mut app, "A", Modifiers::NONE, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A"]);
|
||||
let doc = app.session.doc().unwrap().doc.clone();
|
||||
click_text_held(&ctx, &mut app, "C", Modifiers::SHIFT, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "B", "C"], "{} Styles: Shift-click", kind.what());
|
||||
click_text_held(&ctx, &mut app, "B", Modifiers::COMMAND, rows);
|
||||
click_text_held(&ctx, &mut app, "D", Modifiers::COMMAND, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{} Styles: Cmd/Ctrl-click", kind.what());
|
||||
assert_eq!(app.session.doc().unwrap().doc, doc, "Shift and Cmd/Ctrl clicks apply nothing");
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
// The footer's Delete button is centered 12 points left of the panel's right edge.
|
||||
let button = std::cell::Cell::new(egui::Pos2::ZERO);
|
||||
let footer = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
|
||||
ui.set_width(280.0);
|
||||
let right = ui.max_rect().right();
|
||||
show(app, ui, kind);
|
||||
button.set(egui::pos2(right - 12.0, ui.min_rect().bottom() - 12.0));
|
||||
};
|
||||
click_where(&ctx, &mut app, |_| button.get(), Modifiers::NONE, footer);
|
||||
let left: Vec<String> = styles(&mut app, kind).into_iter().skip(1).map(|s| s.0).collect();
|
||||
assert_eq!(left, ["B"], "{} Styles: Delete deletes A, C and D", kind.what());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::<String>::new());
|
||||
assert_eq!(text_runs(&app, id)[0].1, 18.0, "the type keeps its look");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel menu's Select All Unused highlights, in both panels, the styles no text uses, never
|
||||
/// the Normal style; the menu's Delete then deletes them in one undo step.
|
||||
#[test]
|
||||
fn select_all_unused_highlights_the_unused_styles_for_delete() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let (mut app, _) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind);
|
||||
click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{} Styles", kind.what());
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
let label = format!(" Delete {} Style", kind.what());
|
||||
click_text_held(&ctx, &mut app, &label, egui::Modifiers::NONE, items);
|
||||
let left: Vec<String> = styles(&mut app, kind).into_iter().map(|s| s.0).collect();
|
||||
assert_eq!(left[1..], ["B"], "{} Styles: the unused styles are deleted", kind.what());
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::<String>::new());
|
||||
// With every style used, nothing is highlighted.
|
||||
click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::<String>::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// With several styles highlighted, the panel menu's Duplicate copies each of them in one undo
|
||||
/// step, highlights the copies and scrolls the first copy's row into view.
|
||||
#[test]
|
||||
fn panel_menu_duplicate_copies_every_highlighted_style() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let what = kind.what();
|
||||
let (mut app, _) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let items = |app: &mut VectorcraftApp, ui: &mut egui::Ui| menu(app, ui, kind);
|
||||
click_text_held(&ctx, &mut app, " Select All Unused", egui::Modifiers::NONE, items);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C", "D"], "{what} Styles");
|
||||
let before = styles(&mut app, kind);
|
||||
let undo = app.session.doc().unwrap().history.undo.len();
|
||||
click_text_held(&ctx, &mut app, &format!(" Duplicate {what} Style"), egui::Modifiers::NONE, items);
|
||||
let names: Vec<String> = styles(&mut app, kind).into_iter().skip(1).map(|s| s.0).collect();
|
||||
assert_eq!(names, ["A", "B", "C", "D", "A copy", "C copy", "D copy"], "{what} Styles: A, C and D are duplicated");
|
||||
assert_eq!(app.session.doc().unwrap().history.undo.len(), undo + 1, "{what} Styles: one undo step");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A copy", "C copy", "D copy"], "{what} Styles: the copies are highlighted");
|
||||
assert_eq!(pstate::<Option<String>>(&ctx, kind.scroll_key()).as_deref(), Some("A copy"), "{what} Styles: the first copy scrolls into view");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(styles(&mut app, kind), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel and its menu list the styles, and count their uses, once per document version: more
|
||||
/// frames of an unchanged document list nothing again, and an edit lists them again.
|
||||
#[test]
|
||||
fn the_styles_are_listed_once_per_document_version() {
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let what = kind.what();
|
||||
let (mut app, _) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
|
||||
show(app, ui, kind);
|
||||
menu(app, ui, kind);
|
||||
};
|
||||
let listed = || LISTED.with(|n| n.get());
|
||||
frame_events(&ctx, &mut app, vec![], draw);
|
||||
let first = listed();
|
||||
for _ in 0..4 {
|
||||
frame_events(&ctx, &mut app, vec![], draw);
|
||||
}
|
||||
assert_eq!(listed(), first, "{what} Styles: unchanged frames list nothing again");
|
||||
app.run(&format!("{}.new", kind.prefix()), json!({"name": "E", "attrs": {}})).unwrap();
|
||||
frame_events(&ctx, &mut app, vec![], draw);
|
||||
assert_eq!(listed(), first + 1, "{what} Styles: an edit lists the styles again");
|
||||
assert_eq!(styles(&mut app, kind).last().map(|s| s.0.as_str()), Some("E"), "{what} Styles: the new style is listed");
|
||||
}
|
||||
}
|
||||
|
||||
/// A row dragged from the highlighted styles carries all of them: dropped on Create New Style it
|
||||
/// duplicates each in one undo step and highlights the copies, and dropped on Delete it deletes each
|
||||
/// but the Normal style in one undo step and clears the highlight. A row from outside the highlight
|
||||
/// carries only its own style; dropped on Delete it leaves the highlight as it is.
|
||||
#[test]
|
||||
fn dragging_a_highlighted_style_carries_every_highlighted_style() {
|
||||
use egui::Modifiers;
|
||||
for kind in [Kind::Char, Kind::Para] {
|
||||
let what = kind.what();
|
||||
let (mut app, _) = several_app();
|
||||
let ctx = egui::Context::default();
|
||||
let rows = |app: &mut VectorcraftApp, ui: &mut egui::Ui| {
|
||||
ui.set_width(280.0);
|
||||
show(app, ui, kind);
|
||||
};
|
||||
let names = |app: &mut VectorcraftApp| styles(app, kind).into_iter().skip(1).map(|s| s.0).collect::<Vec<_>>();
|
||||
let undo_len = |app: &VectorcraftApp| app.session.doc().unwrap().history.undo.len();
|
||||
click_text_held(&ctx, &mut app, "A", Modifiers::NONE, rows);
|
||||
click_text_held(&ctx, &mut app, "C", Modifiers::COMMAND, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A", "C"], "{what} Styles");
|
||||
|
||||
let undo = undo_len(&app);
|
||||
assert!(drag_row_onto_footer(&ctx, &mut app, kind, "C", 40.0, false), "{what} Styles: New is outlined");
|
||||
assert_eq!(names(&mut app), ["A", "B", "C", "D", "A copy", "C copy"], "{what} Styles: A and C are duplicated");
|
||||
assert_eq!(undo_len(&app), undo + 1, "{what} Styles: one undo step for the copies");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["A copy", "C copy"], "{what} Styles: the copies are highlighted");
|
||||
assert!(drag_row_onto_footer(&ctx, &mut app, kind, "B", 40.0, false), "{what} Styles: New is outlined");
|
||||
assert_eq!(names(&mut app), ["A", "B", "C", "D", "A copy", "C copy", "B copy"], "{what} Styles: only B is duplicated");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), ["B copy"], "{what} Styles: the copy of B is highlighted");
|
||||
|
||||
let normal = super::super::label_or_name(&styles(&mut app, kind)[0].0, true).to_string();
|
||||
click_text_held(&ctx, &mut app, "A copy", Modifiers::NONE, rows);
|
||||
click_text_held(&ctx, &mut app, "C copy", Modifiers::COMMAND, rows);
|
||||
click_text_held(&ctx, &mut app, &normal, Modifiers::COMMAND, rows);
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind).len(), 3, "{what} Styles: A copy, C copy and Normal are highlighted");
|
||||
assert!(drag_row_onto_footer(&ctx, &mut app, kind, "D", 12.0, false), "{what} Styles: Delete is outlined");
|
||||
assert_eq!(names(&mut app), ["A", "B", "C", "A copy", "C copy", "B copy"], "{what} Styles: only D is deleted");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind).len(), 3, "{what} Styles: the highlight stays");
|
||||
let undo = undo_len(&app);
|
||||
assert!(drag_row_onto_footer(&ctx, &mut app, kind, &normal, 12.0, false), "{what} Styles: Delete is outlined for the highlight");
|
||||
assert_eq!(names(&mut app), ["A", "B", "C", "B copy"], "{what} Styles: A copy and C copy are deleted, Normal stays");
|
||||
assert_eq!(undo_len(&app), undo + 1, "{what} Styles: one undo step for the deletes");
|
||||
assert_eq!(highlighted_now(&mut app, &ctx, kind), Vec::<String>::new(), "{what} Styles: the highlight is cleared");
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed real text/shortcut events through the canvas Type routing. No focus reset or typing
|
||||
/// settlement here: the capture dispatch itself must close the live typing interaction.
|
||||
fn capture_input(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec<egui::Event>) {
|
||||
|
||||
@@ -200,6 +200,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
(crate::addons::cleanup::ID, "Artwork Cleanup", "sparkles"),
|
||||
(crate::addons::color::ID, "Color Effects", "sun"),
|
||||
(crate::addons::pathtools::ID, "Anchor Inspector", "dc-anchor-smooth"),
|
||||
(crate::addons::pathtools::shape::ID, "Parametric Shape", "hexagon"),
|
||||
(crate::addons::pen::ID, "Smart Pen", "spline-pointer"),
|
||||
];
|
||||
|
||||
|
||||
+74
-4
@@ -5020,7 +5020,11 @@ spaceBefore, spaceAfter, hyphenate, mojikumi, burasagari, direction, leadingMode
|
||||
touches (a caret, `start == end`, touches its paragraph). Without a range they change every paragraph of the
|
||||
targeted objects, as before. With a range, the character attributes of `text.setStyle` and `text.setFormat` style
|
||||
that range of characters. `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the
|
||||
paragraph at `start`; `paraStyle.list` counts paragraphs as uses. A Return the Type tool types (or `text.editRange`
|
||||
paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in
|
||||
the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and
|
||||
`paraStyle.unused` (Select All Unused in the Character Styles and Paragraph Styles panel menus) list the styles with no
|
||||
uses, in panel order and never the Normal style, and the panel highlights them; `charStyle.delete` and
|
||||
`paraStyle.delete` take `{names}` to delete several styles in one undo step. A Return the Type tool types (or `text.editRange`
|
||||
inserting `\n`) continues the style of the paragraph it splits; deleting a break keeps the first paragraph's style;
|
||||
`text.setText` gives every paragraph the first one's attributes. Threaded text keeps each paragraph's attributes as
|
||||
the story re-flows (a paragraph split between frames has them in both).
|
||||
@@ -5716,9 +5720,10 @@ topmost piece.
|
||||
|
||||
Path Precision Tools is a built-in add-on for exact anchor and path work: its commands are `addon.pathtools.*`, and in
|
||||
the desktop app it lives in Object › Plug-ins › Path Precision Tools, in its Anchor Inspector panel
|
||||
(`window.panel {panel: "anchorInspector"}`) and in three tools that the toolbars don't show until they are added from the
|
||||
All Tools drawer (or chosen with `tool.select`): `anchorEraserBrush`, `extendPath` and `measureInspect`. Each edit is one
|
||||
undo step, and an edit that changes nothing makes no step.
|
||||
(`window.panel {panel: "anchorInspector"}`), in its Parametric Shape panel (`window.panel {panel: "parametricShape"}`)
|
||||
and in four tools that the toolbars don't show until they are added from the All Tools drawer (or chosen with
|
||||
`tool.select`): `anchorEraserBrush`, `extendPath`, `measureInspect` and `parametricShape`. None has a shortcut. Each edit
|
||||
is one undo step, and an edit that changes nothing makes no step.
|
||||
|
||||
Tools:
|
||||
|
||||
@@ -5732,6 +5737,15 @@ Tools:
|
||||
- **Measure & Inspect tool** (`measureInspect`): a drag measures distance, angle and offsets, as the Measure tool does
|
||||
(`tool.options` reports the last one); hovering a path reads it, as `addon.pathtools.inspect` does. Nothing goes into
|
||||
the document.
|
||||
- **Parametric Shape Tool** (`parametricShape`): draws shapes that stay editable as numbers. A drag from the centre runs
|
||||
`addon.pathtools.shape.create`: the drag's length is the shape's size (height 2 × the distance, width in the last
|
||||
shape's proportions), its direction the rotation (Shift: 45° steps). While dragging, ↑/↓ change the kind's count or
|
||||
first value and ←/→ its second value or corner radius. A click selects a parametric shape and shows its handles:
|
||||
centre (move), the box's lower right corner (size; Shift keeps the proportions), a handle above the top (rotation;
|
||||
Shift: 45° steps), a dot on the top edge (corner radius) and one of the kind's own (a star's or ring's inner radius, a
|
||||
gear's tooth height, an arrow's head, a cross's bar, a heart's notch, a bubble's tail tip); each drag runs
|
||||
`addon.pathtools.shape.set`. A double-click on the canvas places a shape with the last settings. Options
|
||||
(`tool.setOption`, or the panel with no shape selected) are the next shape's numbers, as the commands take them.
|
||||
|
||||
Commands:
|
||||
|
||||
@@ -5764,11 +5778,35 @@ Commands:
|
||||
- `addon.pathtools.crossings {cut?, selfCrossings?, ids?}` adds anchors where the selected paths cross one another (and
|
||||
where each crosses itself, with `selfCrossings`), or with `cut` also cuts them there: a path of one subpath becomes a
|
||||
path per piece. Paths that meet at a shared anchor don't cross there; at most 10,000 segments at once.
|
||||
- `addon.pathtools.shape.create {center, kind?, width?, height?, rotation?, count?, a?, b?, radius?}` draws a parametric
|
||||
shape with the current paint, selected. `kind`: `rectangle`, `ellipse`, `polygon` (default), `star`, `ring`, `arrow`,
|
||||
`cross`, `heart`, `gear` or `bubble` (a speech bubble). `width` and `height` are 0.01 to 16,384 pt (100);
|
||||
`rotation` is in degrees clockwise (0: upright, a polygon's first corner up); `count` is a polygon's sides, a star's
|
||||
points or a gear's teeth (3 to 1,000); `radius` rounds the corners (0 to 16,384 pt; no corner passes the middle of a
|
||||
side). `a` and `b` are the kind's own values, fractions of its size: star, inner radius (0.01 to 1, 0.5); ring, the
|
||||
hole (0.01 to 0.99, 0.5); arrow, head length and shaft width (0.05 to 0.95, 0.4 each); cross, the bar's offset (−0.9
|
||||
to 0.9, 0; negative is up) and the arms' thickness (0.05 to 0.95, 1/3); heart, the notch's depth and the point's
|
||||
roundness (0 to 1; 0.4 and 0); gear, tooth height (0.01 to 0.9, 0.2); bubble, the tail's height (0.05 to 0.9, 0.25)
|
||||
and where its tip is across (−1 to 1, −0.4). → `{id, shape}`.
|
||||
- `addon.pathtools.shape.set {id, …}` changes a parametric shape's numbers (the same keys; a new `kind` takes its own
|
||||
defaults for the values not given) and redraws it in place. With no change it makes no step. → `{id, shape}` (its
|
||||
numbers now, so `{id}` alone reads them).
|
||||
- `addon.pathtools.shape.convert {ids?}` makes the selected plain closed paths that are rectangles, ellipses or
|
||||
polygons with equal sides and corners (stretched along their own axes at most) parametric shapes with the same
|
||||
outline. → `{ids}` (those converted).
|
||||
|
||||
A parametric shape is an ordinary path. It keeps its numbers, with a fingerprint of the outline they drew, in one
|
||||
entry of its object data (`vc-pathtools-shape`, as `document.node` lists `attrs.data`), which SVG export leaves out;
|
||||
copies and duplicates keep it. Moved, rotated or scaled with any tool it stays editable: its centre, size and rotation
|
||||
follow (a shape with rounded corners only when scaled evenly). Skewed, mirrored, or with its anchors edited, it is a
|
||||
plain path from then on.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.extend","params":{"id":12,"end":"end","length":40,"mode":"arc"}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.erase","params":{"points":[[100,40],[100,160]],"diameter":30}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.crossings","params":{"cut":true}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.shape.create","params":{"kind":"star","center":[200,150],"width":120,"height":120,"count":7,"a":0.4}}}
|
||||
{"name":"run_command","arguments":{"command":"addon.pathtools.shape.set","params":{"id":14,"kind":"gear","count":16,"radius":2}}}
|
||||
```
|
||||
|
||||
## Smart Pen add-on
|
||||
@@ -5866,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 (Libraries panel: art dragged off the canvas and swatches dragged in are added where they are dropped; scores unchanged; previously: Guides: a moved selection's edges and centre land on ruler guides, not only the point it was grabbed by (#4312); scores and estimates 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 (3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026)
|
||||
|
||||
## Feature submission evidence
|
||||
|
||||
@@ -788,6 +788,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- Divide uses open lines, bent paths and curves as cutting edges (#1307), preserving the frontmost filled face's appearance and holes. Engine tests cover implicit closure of filled open contours and unrelated objects; native automation confirms independent piece movement, one Undo/Redo, canvas/window captures and an exact saved-document read by a separate CLI process. The shared planar map keeps a segment cap (65,536 since #937, was 4,096) and its filled-area fallback; other Pathfinder operations are unchanged.
|
||||
- Type that Transform effects only move and copy (no scale, rotation, reflection or Random) exports as type in each place, and PDF export writes every copy as selectable, searchable text, when its characters are in solid colors without underline or strikethrough, the type object has no fills or strokes of its own, no inline graphics and no perspective, its transform does not scale or shear stroked characters, and the copies do not overlap. When the effect is on the type itself, the type can also have transparency, an opacity mask and raster effects. When the effect is on the group or layer holding the type, the type has no transparency or effects of its own and is not in a clipping path, the group or layer has no fill, stroke or Pathfinder effect of its own, and no group between them has a fill, stroke, Pathfinder or geometry effect. Type still exports as outlines when the effect stops its copies early, or when the copies would number more than 10,000 or take more than 20,000,000 bytes of document data, and so does other type under a geometry effect.
|
||||
- Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)).
|
||||
- Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715).
|
||||
- Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)).
|
||||
- Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)).
|
||||
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode.
|
||||
@@ -948,6 +949,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
Type inside a group, layer, symbol instance, blend or repeat keeps its opacity, blend mode, opacity mask, Opacity & Mask Define Knockout Shape, own effects, perspective and visibility when the effects on the container outline it. Hidden type with a geometry effect of its own is left out of exports.
|
||||
- **Graphic styles:** styles keep opacity and blend mode, capture groups and type, apply on top with Alt, and link to the objects using them; Redefine, Break Link, Graphic Style Options, Select All Unused, Sort by Name, and Select > Same Graphic Style / Appearance Attribute. The panel has list and thumbnail views with rendered previews, merge, reorder and drag and drop, plus a Control bar Style picker; six generated style libraries save and load as `.vcstyles`.
|
||||
In the Graphic Styles panel, Shift-click selects a range of styles and Ctrl/Cmd-click adds or removes one.
|
||||
- **Several text styles at once:** in the Character Styles and Paragraph Styles panels, Shift-click highlights a range of styles and Ctrl/Cmd-click adds or removes one, without applying them; the panel menu's Select All Unused (`charStyle.unused`, `paraStyle.unused`) highlights the styles no type uses (in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes), never the Normal style; Delete, the button or the menu item, deletes every highlighted style but Normal in one undo step (`charStyle.delete {names}`, `paraStyle.delete {names}`). The menu's Duplicate copies every highlighted style in one undo step and highlights the copies. A highlighted row dragged onto Create New Style or Delete carries every highlighted style: dropped on Create New Style it copies them all and highlights the copies, and dropped on Delete it deletes them all but Normal and clears the highlight; a row from outside the highlight carries only its own style. Public reference: [Character and paragraph styles](https://web.archive.org/web/20261015081534/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html).
|
||||
- **Neutral wording:** labels, MCP tool text, docs and packaging use neutral names, and `cargo xtask brands` (part of `cargo xtask ci`) fails on vendor names.
|
||||
- **Colour, type and file workflows:** Recolor Artwork (dialog with harmonies), Edit Colors, Find & Replace, Change Case, Smart Punctuation, Guides, Lock/Hide Above, Transform Each, Rasterize.
|
||||
The Libraries panel holds gradients and patterns: its + button adds the selection's fill or stroke gradient or pattern, a gradient or pattern swatch dragged from the Swatches panel is added, and a click paints the selection through the active Fill/Stroke proxy, a pattern bringing its definition and swatch into the document.
|
||||
@@ -1229,6 +1231,19 @@ Kernels in `vectorcraft_pathops::addons::pathtools`. Tests: `addons::pathtools`
|
||||
undo step, bad params refused, the brush's stroke one step) and `edit_toolbar::tests::addon_tools_are_in_the_drawer_not_the_toolbars`.
|
||||
Commands: `docs/mcp.md` › Path Precision Tools add-on.
|
||||
|
||||
The add-on's Parametric Shape Tool (2026-10-30) draws ten kinds of shape that stay editable as numbers (rectangle,
|
||||
ellipse, polygon and star with 3 to 1,000 sides or points, ring, arrow, cross, heart, gear, speech bubble; corner radius
|
||||
limited to the middle of a side): a drag from the centre sizes and turns one, the arrow keys change its count and values
|
||||
while dragging, handles edit a selected one, a double-click places one with the last settings, and its panel edits the
|
||||
numbers. A shape is a plain path whose numbers and outline fingerprint sit in one object-data entry that SVG export
|
||||
leaves out; moving, rotating or scaling it keeps it editable, editing its anchors makes it a plain path. Convert to
|
||||
Parametric Shape recognises plain rectangles, ellipses and regular polygons. Geometry in
|
||||
`vectorcraft_pathops::addons::pathtools::shapes`. Tests: `shapes` in `vectorcraft-pathops` (each kind, ranges,
|
||||
recovery after transforms, recognition), `addons::parametric_shape` in `vectorcraft-tools` (drag, keys, handles,
|
||||
stored data), `cmd::addons::pathtools::tests` in `vectorcraft-engine` (each command one undo step, bad params refused,
|
||||
anchor edits and transforms, the SVG export filter, the tool's gestures), `addons::pathtools::tests` in
|
||||
`vectorcraft-ui-egui` (submenu and panel) and `command_sweep`.
|
||||
|
||||
## Smart Pen add-on (2026-10-30)
|
||||
|
||||
A VectorCraft addition, not an Illustrator feature, so no area score moves. Object › Plug-ins › Smart Pen selects the
|
||||
@@ -1255,11 +1270,23 @@ 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 | 3D Extrude & Bevel, Inflate and Rotate apply to type, which stays editable (#4715); scores and estimates unchanged |
|
||||
| 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 |
|
||||
| 2026-10-30 | minor | Gradient stop popover: an eyedropper samples the stop's colour from the art (#4681); scores and estimates unchanged |
|
||||
|
||||
+6
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Gradient panel presets; View › Hide Artboards hides their edges and shows the canvas as paper (#4038); 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
|
||||
|
||||
@@ -324,10 +324,12 @@ next unit in the order a right-click on a ruler lists them, from Feet back to Po
|
||||
| Preferences › General › Constrain Angle | done | arrow keys and Shift drags along the turned axes; the Rectangle, Rounded Rectangle, Ellipse and grid tools, point and area type and the shape size dialogs draw along them with the bounding box turned; Shift-drawn polygons and stars keep the turned base orientation; the Line Segment tool's Shift steps count from it (#394, <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rotate-objects-by-specific-angles.html>); arcs, spirals, flares and Pen paths are not turned |
|
||||
| Tabs panel: a stop drags from where the press landed, a stop just added included | done | #1458 |
|
||||
| Outline view keeps template layers in Preview, dimmed | done | #1486; a picture placed to be traced stays visible under the outlines |
|
||||
| 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) | 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 four tools (Anchor Eraser Brush, Extend Path, Measure & Inspect, Parametric Shape), 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 the Parametric Shape panel (kind, count, W/H, rotation, the kind's own values and corner radius of the selected parametric shape, or of the tool's next one), and runs Add Points at / Split at Crossings and Convert to Parametric Shape; the Parametric Shape Tool draws ten re-editable kinds from the centre (↑/↓ and ←/→ change their numbers while dragging) and edits a selected one with handles; 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`, `crates/ui-egui/src/addons/pathtools/shape.rs` and their tests; not an Illustrator feature, so it is not scored |
|
||||
| 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
|
||||
|
||||
@@ -368,6 +370,8 @@ 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 |
|
||||
| 2026-10-30 | minor | View › Hide Artboards hides their edges and shows the canvas as paper (#4038); scores and estimates unchanged |
|
||||
|
||||
Reference in New Issue
Block a user