mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 00:42:33 +00:00
Merge main into feat/character-anti-aliasing-method
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
//! Artwork Cleanup (`addon.cleanup.*`), a built-in add-on: Simplify Anchors (Keep Shape), the
|
||||
//! Document Health Check (check, show and fix the issues it finds), Deep Ungroup and Rejoin Path
|
||||
//! Pieces. Its panel and its submenu, Object › Plug-ins › Artwork Cleanup, are the UI's.
|
||||
//! Pieces, and its text tools ([`text`]). Its panel and its submenu, Object › Plug-ins › Artwork
|
||||
//! Cleanup, are the UI's.
|
||||
|
||||
mod checks;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, NodeKind};
|
||||
@@ -16,9 +18,8 @@ use super::super::edit::selected_roots;
|
||||
use super::super::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, number_or, switch_or, text_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Simplify Anchors' tolerance by default, and the drift (pt) each step of it allows.
|
||||
/// Simplify Anchors' tolerance by default.
|
||||
const TOLERANCE: f64 = 20.0;
|
||||
pub(super) const PT_PER_TOLERANCE: f64 = 0.02;
|
||||
/// Rejoin Path Pieces' gap by default, and the widest (pt).
|
||||
const GAP: f64 = 0.1;
|
||||
const MAX_GAP: f64 = 6.0;
|
||||
@@ -30,7 +31,7 @@ const FIX_ROUNDS: usize = 3;
|
||||
const MAX_LISTED: usize = 1000;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
let mut v = vec![
|
||||
cmd!(
|
||||
"addon.cleanup.simplify",
|
||||
"Simplify Anchors (Keep Shape)…",
|
||||
@@ -85,7 +86,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
rejoin
|
||||
),
|
||||
]
|
||||
];
|
||||
v.extend(text::specs());
|
||||
v
|
||||
}
|
||||
|
||||
/// The paths (not guides) under `roots`, compound path members included.
|
||||
@@ -119,7 +122,7 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let changed: Vec<(NodeId, PathData)> = paths
|
||||
.iter()
|
||||
.filter_map(|(id, path)| {
|
||||
let np = po::simplify_keep_shape(path, tolerance * PT_PER_TOLERANCE, protect);
|
||||
let np = po::simplify_keep_shape(path, tolerance * po::PT_PER_TOLERANCE, protect);
|
||||
(np != **path).then_some((*id, np))
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
//! Artwork Cleanup's text tools: Break Text into Lines / Paragraphs / Words / Glyphs, Combine Point
|
||||
//! Text, Split Text at Cursor, Remove Line / Paragraph Breaks, Set Alignment, Keep Position, Reset
|
||||
//! Text Transform and Replace Missing Fonts. Each is one undo step. Pieces keep their characters'
|
||||
//! styles and stay where the layout drew them: each is moved so the glyph of its first character
|
||||
//! lands where it was.
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{CharStyle, Document, Justify, Node, NodeId, NodeKind, ParaStyle, RubySpan, TextKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_text::{FontDb, FontMatch, TextLayout, edit};
|
||||
|
||||
use crate::cmd::textedit::editing_range;
|
||||
use crate::cmd::typecmd::refresh_bounds;
|
||||
use crate::cmd::{CommandSpec, bad, checked_ids_or_selection, cmd, has_doc, has_selection, switch_or, text_param};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Most text objects one command takes.
|
||||
const MAX_TEXTS: usize = 10_000;
|
||||
/// Most pieces Break Text and Split Text at Cursor make at once.
|
||||
const MAX_PIECES: usize = 20_000;
|
||||
/// Most breaks Remove Line / Paragraph Breaks removes at once.
|
||||
const MAX_BREAKS: usize = 200_000;
|
||||
/// Most pieces Combine Point Text tries to join, counted over every try: past it, what it joined
|
||||
/// so far is the result.
|
||||
const MAX_JOIN_TRIES: usize = 200_000;
|
||||
/// How far (pt) a glyph Combine Point Text sets may land from where it was.
|
||||
const TOLERANCE: f64 = 0.01;
|
||||
/// Combine Point Text: pieces whose baselines are within this many ems of each other share a line
|
||||
/// (the difference becomes baseline shift); a gap wider than `MAX_GAP` ems, or an overlap of more
|
||||
/// than `MAX_OVERLAP`, starts another object.
|
||||
const SAME_LINE: f64 = 0.4;
|
||||
const MAX_GAP: f64 = 10.0;
|
||||
const MAX_OVERLAP: f64 = 1.0;
|
||||
/// Tracking Combine Point Text may give a character (thousandths of an em).
|
||||
const TRACKING: std::ops::RangeInclusive<f64> = -1000.0..=10000.0;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.cleanup.breakText",
|
||||
"Break Text",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{by?: \"lines\"|\"paragraphs\"|\"words\"|\"glyphs\" (lines), ids?: [id…] (default: the selection)} each horizontal point or area type object becomes point type objects, one per line, paragraph, word or glyph, each where its characters were and with their character styles, as one undo step; spaces between pieces go, a paragraph's lines stay lines (a wrapped line ends in a line break), justified lines close up, and a paragraph broken out whole keeps its bullet (not its number; other pieces have no list markers). Type on a path, vertical type, threaded type and area type whose text doesn't fit stay as they are → {ids (the pieces, selected), skipped}",
|
||||
has_selection,
|
||||
break_text
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.combineText",
|
||||
"Combine Point Text",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selection)} merge one-line horizontal point type into as few objects as it can, every glyph staying where it was: pieces turned and sheared alike whose baselines line up (within 0.4 em; the difference becomes baseline shift) join left to right, a space goes between pieces at least half a space apart, and tracking on the character before each piece keeps the spacing; each object keeps its character styles, and the leftmost piece of each run its place and paint, as one undo step → {ids (the objects made, selected), combined (pieces merged into them)}",
|
||||
has_selection,
|
||||
combine_text
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.removeBreaks",
|
||||
"Remove Breaks",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{kind: \"line\"|\"paragraph\", ids?: [id…]} remove the forced line breaks (line) or paragraph returns (paragraph) in the characters selected with a Type tool, else in all the text of the selected type, as one undo step; a break between two words becomes a space → {ids, removed}",
|
||||
has_selection,
|
||||
remove_breaks
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.splitText",
|
||||
"Split Text at Cursor",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{} while a Type tool edits horizontal point or area type: the text before the selected characters, they and the text after become point type objects of their own (at a cursor: before and after it), each where its characters were, as one undo step; spaces at the ends of each piece go, and editing ends with the pieces selected → {ids}",
|
||||
editing_text,
|
||||
split_text
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.alignInPlace",
|
||||
"Set Alignment, Keep Position",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{align: \"left\"|\"center\"|\"right\", ids?: [id…] (default: the selection)} align every paragraph of each horizontal point type object so, moving the object so its longest line stays where it is (one line: the text doesn't move), as one undo step → {ids (the objects changed)}",
|
||||
has_selection,
|
||||
align_in_place
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.resetTextTransform",
|
||||
"Reset Text Transform",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{scale?: bool (false: the horizontal scale stays; true: it goes too, the object's and every character's), ids?: [id…] (default: the selection)} take the rotation, shear and reflection off each point type object, its size and the centre of its text staying, as one undo step → {ids (the objects changed)}",
|
||||
has_selection,
|
||||
reset_transform
|
||||
),
|
||||
cmd!(
|
||||
"addon.cleanup.replaceFonts",
|
||||
"Replace Missing Fonts…",
|
||||
["Object", "Plug-ins", "Artwork Cleanup"],
|
||||
None,
|
||||
"{font?: family (default: the default type font, Source Sans 3; any of its names)} set every character of the document's type whose font family isn't available in `font`, in the style it has closest to the one each had, as one undo step → {runs (style runs changed), font}",
|
||||
has_doc,
|
||||
replace_fonts
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Enabled while a Type tool edits text.
|
||||
fn editing_text(s: &Session) -> std::result::Result<(), String> {
|
||||
match s.tool_options().get("editing") {
|
||||
Some(v) if !v.is_null() => Ok(()),
|
||||
_ => Err("Click in type with a Type tool to split it".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The type objects under `roots` (inside groups and layers, not live art) that can be edited.
|
||||
fn texts_under(d: &Document, roots: &[NodeId]) -> Result<Vec<NodeId>> {
|
||||
fn visit(n: &Node, out: &mut Vec<NodeId>) {
|
||||
match &n.kind {
|
||||
NodeKind::Text(_) => out.push(n.id),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| visit(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
roots.iter().filter_map(|id| d.node(*id)).for_each(|n| visit(n, &mut out));
|
||||
let editable = d.editable();
|
||||
out.retain(|id| editable(*id));
|
||||
out.dedup();
|
||||
if out.len() > MAX_TEXTS {
|
||||
return Err(EngineError::Other("Too many type objects to change at once".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text_of(d: &Document, id: NodeId) -> Option<&TextObject> {
|
||||
match &d.node(id)?.kind {
|
||||
NodeKind::Text(t) => Some(t),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(t: &TextObject) -> TextLayout {
|
||||
vectorcraft_text::layout(FontDb::global(), t)
|
||||
}
|
||||
|
||||
/// Where the layout draws the character at `byte` (the one it starts in): its glyph's pen position.
|
||||
fn glyph_at(lay: &TextLayout, byte: usize) -> Option<Point> {
|
||||
let start = lay.glyphs.iter().filter(|g| g.len > 0 && g.byte <= byte).map(|g| g.byte).max()?;
|
||||
lay.glyphs.iter().find(|g| g.len > 0 && g.byte == start).map(|g| g.origin)
|
||||
}
|
||||
|
||||
/// The pen position of the glyph that starts at `byte`, if one does.
|
||||
fn glyph_starting(lay: &TextLayout, byte: usize) -> Option<Point> {
|
||||
lay.glyphs.iter().find(|g| g.len > 0 && g.byte == byte).map(|g| g.origin)
|
||||
}
|
||||
|
||||
/// The first glyph of line `l`, in text order.
|
||||
fn line_start(lay: &TextLayout, l: usize) -> Option<Point> {
|
||||
let line = lay.lines.get(l)?;
|
||||
let g = lay.glyphs.get(line.glyph_start..line.glyph_end)?.iter().filter(|g| g.len > 0).min_by_key(|g| g.byte)?;
|
||||
Some(g.origin)
|
||||
}
|
||||
|
||||
/// `a..b` of `text` without the white space at its ends; None when nothing else is left.
|
||||
fn trimmed(text: &str, a: usize, b: usize) -> Option<(usize, usize)> {
|
||||
let s = text.get(a..b)?;
|
||||
let start = a + (s.len() - s.trim_start().len());
|
||||
let end = a + s.trim_end().len();
|
||||
(start < end).then_some((start, end))
|
||||
}
|
||||
|
||||
/// Paragraph alignment as point type keeps it: justified lines align to their side.
|
||||
fn point_justify(j: Justify) -> Justify {
|
||||
match j {
|
||||
Justify::JustifyLeft | Justify::JustifyAll => Justify::Left,
|
||||
Justify::JustifyCenter => Justify::Center,
|
||||
Justify::JustifyRight => Justify::Right,
|
||||
j => j,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum By {
|
||||
Lines,
|
||||
Paragraphs,
|
||||
Words,
|
||||
Glyphs,
|
||||
}
|
||||
|
||||
/// A type object being cut into point type pieces: its text as drawn (Shrink Text to Fit's sizes
|
||||
/// taken in) and its layout.
|
||||
struct Source {
|
||||
t: TextObject,
|
||||
lay: TextLayout,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
/// Horizontal point or area type that isn't threaded and whose text all fits; None otherwise.
|
||||
fn of(d: &Document, id: NodeId) -> Option<Self> {
|
||||
let t = text_of(d, id)?;
|
||||
if t.vertical || matches!(t.kind, TextKind::OnPath { .. }) || d.text_threads.iter().any(|th| th.contains(&id)) {
|
||||
return None;
|
||||
}
|
||||
let lay = layout(t);
|
||||
// Text left out of a frame would be lost with it.
|
||||
if lay.overflow {
|
||||
return None;
|
||||
}
|
||||
let t = if (lay.fit_scale - 1.0).abs() > 1e-9 { vectorcraft_text::scaled(t, lay.fit_scale) } else { t.clone() };
|
||||
Some(Self { text: t.plain_text(), t, lay })
|
||||
}
|
||||
|
||||
/// The byte ranges of the pieces `by` cuts the text into.
|
||||
fn ranges(&self, by: By) -> Vec<(usize, usize)> {
|
||||
let text = &self.text;
|
||||
match by {
|
||||
By::Paragraphs => {
|
||||
let mut out = vec![];
|
||||
let mut a = 0;
|
||||
for (i, _) in text.match_indices('\n') {
|
||||
out.push((a, i));
|
||||
a = i + 1;
|
||||
}
|
||||
out.push((a, text.len()));
|
||||
out
|
||||
}
|
||||
By::Lines => self.lay.lines.iter().map(|l| (l.start, l.end)).collect(),
|
||||
By::Words => {
|
||||
let mut out = vec![];
|
||||
for l in &self.lay.lines {
|
||||
let Some(s) = text.get(l.start..l.end) else { continue };
|
||||
let mut word: Option<usize> = None;
|
||||
for (i, c) in s.char_indices() {
|
||||
match (c.is_whitespace(), word) {
|
||||
(true, Some(w)) => {
|
||||
out.push((l.start + w, l.start + i));
|
||||
word = None;
|
||||
}
|
||||
(false, None) => word = Some(i),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(w) = word {
|
||||
out.push((l.start + w, l.end));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
By::Glyphs => {
|
||||
// One piece per cluster (a ligature's characters stay together).
|
||||
let mut clusters = std::collections::BTreeMap::new();
|
||||
for g in self.lay.glyphs.iter().filter(|g| g.len > 0) {
|
||||
let end = clusters.entry(g.byte).or_insert(g.byte + g.len);
|
||||
*end = (*end).max(g.byte + g.len);
|
||||
}
|
||||
clusters.into_iter().collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point type holding bytes `a..b` (white space at its ends left out) where they were drawn.
|
||||
/// Lines the layout wrapped end in a forced line break; `lists` keeps bulleted paragraphs'
|
||||
/// bullets where the piece starts its paragraph.
|
||||
fn piece(&self, (a, b): (usize, usize), lists: bool) -> Option<TextObject> {
|
||||
let (a, b) = trimmed(&self.text, a, b)?;
|
||||
let mut p = TextObject::point(Point::ZERO, "", CharStyle::default());
|
||||
p.runs = edit::slice_runs(&self.t.runs, a, b);
|
||||
p.xf = self.t.xf;
|
||||
p.optical_margin = self.t.optical_margin;
|
||||
p.system_layout = self.t.system_layout;
|
||||
p.fractional_widths = self.t.fractional_widths;
|
||||
p.ruby =
|
||||
self.t.ruby.iter().filter(|r| a <= r.start && r.end <= b).map(|r| RubySpan { start: r.start - a, end: r.end - a, ..r.clone() }).collect();
|
||||
// The paragraphs it holds, with their styles; the first keeps its bullet only from its start.
|
||||
let starts_para = self.text.get(..a).is_some_and(|s| s.rsplit('\n').next().is_some_and(|line| line.trim().is_empty()));
|
||||
let styles = self.t.paragraph_styles();
|
||||
let mut paras: Vec<ParaStyle> = styles.get(self.t.paragraphs_in(a, b))?.to_vec();
|
||||
for (i, para) in paras.iter_mut().enumerate() {
|
||||
para.justify = point_justify(para.justify);
|
||||
if !(lists && (i > 0 || starts_para) && para.list.as_ref().is_some_and(|l| l.number.is_none())) {
|
||||
para.list = None;
|
||||
}
|
||||
}
|
||||
p.set_paragraph_styles(paras);
|
||||
// Lines the layout wrapped end in a forced line break, in place of the spaces they ended in.
|
||||
let mut wraps = vec![];
|
||||
for next in self.lay.lines.iter().skip(1).filter(|l| a < l.start && l.start < b) {
|
||||
let content = self.text.get(..next.start)?.trim_end_matches([' ', '\t']);
|
||||
// A line that ends in a break of its own keeps it.
|
||||
if !content.ends_with(['\n', '\u{2028}']) {
|
||||
wraps.push((content.len().max(a) - a, next.start - a));
|
||||
}
|
||||
}
|
||||
for &(s, e) in wraps.iter().rev() {
|
||||
edit::replace_range(&mut p.runs, s, e, "\u{2028}");
|
||||
p.ruby_after_replace(s, e, '\u{2028}'.len_utf8());
|
||||
}
|
||||
self.place(&mut p, a)?;
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Move `p` (cut from byte `a`) so the glyph of its first character lands where it was drawn.
|
||||
/// A piece of several lines that starts inside a left-aligned line takes a first-line indent,
|
||||
/// so its later lines stay where they were too.
|
||||
fn place(&self, p: &mut TextObject, a: usize) -> Option<()> {
|
||||
let lay = layout(p);
|
||||
let mut shift: Vec2 = glyph_at(&self.lay, a)? - glyph_at(&lay, 0)?;
|
||||
let first = self.lay.lines.iter().rposition(|l| l.start <= a);
|
||||
if let (Some(now), Some(was)) = (line_start(&lay, 1), first.and_then(|l| line_start(&self.lay, l + 1))) {
|
||||
let off = was.x - (now.x + shift.x);
|
||||
let left = match p.para.justify {
|
||||
Justify::Left => true,
|
||||
Justify::Auto => lay.lines.first().is_some_and(|l| !l.rtl),
|
||||
_ => false,
|
||||
};
|
||||
if left && off.abs() > 1e-6 {
|
||||
p.edit_paras(Some(0..1), |para| para.first_line_indent -= off);
|
||||
shift.x += off;
|
||||
}
|
||||
}
|
||||
p.xf = self.t.xf * Affine::translate(shift);
|
||||
Some(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Put each object of `items` (id, the node, its pieces) out of the document, its pieces in its
|
||||
/// place, as one undo step labelled `label`, the pieces selected → their ids.
|
||||
fn replace_with_pieces(s: &mut Session, label: &str, items: Vec<(NodeId, Node, Vec<TextObject>)>) -> Result<Vec<u64>> {
|
||||
s.edit(label, |d, sel| {
|
||||
let (mut made, mut places) = (vec![], vec![]);
|
||||
for (id, mut node, pieces) in items {
|
||||
// A light template: the pieces take its paint and attributes, not its text.
|
||||
node.kind = NodeKind::Text(Box::new(TextObject::point(Point::ZERO, "", CharStyle::default())));
|
||||
let nodes: Vec<Node> = pieces
|
||||
.into_iter()
|
||||
.map(|mut t| {
|
||||
refresh_bounds(&mut t);
|
||||
let mut n = d.reid(&node);
|
||||
n.kind = NodeKind::Text(Box::new(t));
|
||||
n
|
||||
})
|
||||
.collect();
|
||||
made.extend(nodes.iter().map(|n| n.id));
|
||||
places.push((id, nodes));
|
||||
}
|
||||
d.insert_beside(places, true)?;
|
||||
sel.set(made.iter().copied());
|
||||
Ok(made.iter().map(|i| i.0).collect())
|
||||
})
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Vec<u64> {
|
||||
ids.iter().map(|i| i.0).collect()
|
||||
}
|
||||
|
||||
fn break_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.breakText";
|
||||
let by = match text_param(p, "by", C)?.unwrap_or("lines") {
|
||||
"lines" => By::Lines,
|
||||
"paragraphs" => By::Paragraphs,
|
||||
"words" => By::Words,
|
||||
"glyphs" => By::Glyphs,
|
||||
other => return Err(bad(C, format!("`by` must be lines, paragraphs, words or glyphs, not {other:?}"))),
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (mut items, mut skipped, mut count) = (vec![], vec![], 0);
|
||||
for id in texts_under(d, &checked_ids_or_selection(s, p, C)?)? {
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id)) else {
|
||||
skipped.push(id);
|
||||
continue;
|
||||
};
|
||||
let ranges = src.ranges(by);
|
||||
count += ranges.len();
|
||||
if count > MAX_PIECES {
|
||||
return Err(EngineError::Other("Too many pieces to make at once".into()));
|
||||
}
|
||||
let pieces: Vec<TextObject> = ranges.into_iter().filter_map(|r| src.piece(r, by == By::Paragraphs)).collect();
|
||||
if pieces.is_empty() {
|
||||
skipped.push(id);
|
||||
} else {
|
||||
items.push((id, node.clone(), pieces));
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
|
||||
}
|
||||
let ids = replace_with_pieces(s, "Break Text", items)?;
|
||||
Ok(json!({ "ids": ids, "skipped": ids_json(&skipped) }))
|
||||
}
|
||||
|
||||
fn split_text(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let Some((id, a, b)) = editing_range(s)? else {
|
||||
return Err(EngineError::Other("Click in type with a Type tool to split it".into()));
|
||||
};
|
||||
let d = &s.doc()?.doc;
|
||||
let (Some(src), Some(node)) = (Source::of(d, id), d.node(id).filter(|_| d.is_editable(id))) else {
|
||||
return Err(EngineError::Other("Select horizontal point or area type whose text fits".into()));
|
||||
};
|
||||
let text = &src.text;
|
||||
let (a, b) = (edit::floor_char(text, a.min(b)), edit::floor_char(text, a.max(b)));
|
||||
let cuts: Vec<(usize, usize)> = if a == b { vec![(0, a), (a, text.len())] } else { vec![(0, a), (a, b), (b, text.len())] };
|
||||
let pieces: Vec<TextObject> = cuts.into_iter().filter_map(|r| src.piece(r, true)).collect();
|
||||
if pieces.len() < 2 {
|
||||
return Err(EngineError::Other("Nothing to split at the cursor".into()));
|
||||
}
|
||||
let node = node.clone();
|
||||
let ids = replace_with_pieces(s, "Split Text at Cursor", vec![(id, node, pieces)])?;
|
||||
// The text edited is gone: the pieces stay selected.
|
||||
s.set_tool_option("stopEditing", &Value::Bool(true));
|
||||
Ok(json!({ "ids": ids }))
|
||||
}
|
||||
|
||||
/// One piece of point type Combine Point Text takes: its text with its object's scale taken into
|
||||
/// its characters, and where it starts and ends along its line in the frame of its turn and shear.
|
||||
struct Piece {
|
||||
id: NodeId,
|
||||
t: TextObject,
|
||||
/// The object's turn and shear (`xf` without its move).
|
||||
turn: [f64; 4],
|
||||
/// Where its first character's glyph starts on its baseline, in that frame.
|
||||
at: Point,
|
||||
/// Where its last glyph ends along the line, in that frame.
|
||||
end: f64,
|
||||
/// Its first character's size.
|
||||
size: f64,
|
||||
/// Each character's pen position from the first's (byte, x), as it lays out alone.
|
||||
pens: Vec<(usize, f64)>,
|
||||
}
|
||||
|
||||
impl Piece {
|
||||
fn of(d: &Document, id: NodeId) -> Option<(Self, &Node)> {
|
||||
let node = d.node(id)?;
|
||||
let NodeKind::Text(t) = &node.kind else { return None };
|
||||
let text = t.plain_text();
|
||||
if t.vertical || !matches!(t.kind, TextKind::Point) || text.trim().is_empty() || text.contains(['\n', '\u{2028}']) {
|
||||
return None;
|
||||
}
|
||||
let mut t = (**t).clone();
|
||||
t.scale_text_attributes();
|
||||
t.style_scale()?;
|
||||
let lay = layout(&t);
|
||||
let line = lay.lines.first().filter(|l| !l.rtl && lay.lines.len() == 1)?;
|
||||
let first = glyph_at(&lay, 0)?;
|
||||
// The first glyph of each cluster (a mark drawn over it shares its byte).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let pens = lay.glyphs.iter().filter(|g| g.len > 0 && seen.insert(g.byte)).map(|g| (g.byte, g.origin.x - first.x)).collect();
|
||||
let end = lay.glyphs.iter().map(|g| g.origin.x + g.advance).fold(first.x, f64::max) - first.x;
|
||||
let [a, b, c, dd, _, _] = t.xf.as_coeffs();
|
||||
let turn = Affine::new([a, b, c, dd, 0.0, 0.0]);
|
||||
let at = turn.inverse() * (t.xf * Point::new(first.x, line.baseline));
|
||||
let size = t.runs.first()?.style.size;
|
||||
if !(at.x.is_finite() && at.y.is_finite() && size > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Some((Self { id, t, turn: [a, b, c, dd], at, end: at.x + end, size, pens }, node))
|
||||
}
|
||||
|
||||
/// Whether `o` is turned and sheared alike.
|
||||
fn turned_like(&self, o: &Piece) -> bool {
|
||||
self.turn.iter().zip(&o.turn).all(|(x, y)| (x - y).abs() <= 1e-6 * x.abs().max(y.abs()).max(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two objects look alike apart from their text (paint, opacity, blending, effects).
|
||||
fn same_look(a: &Node, b: &Node) -> bool {
|
||||
a.appearance == b.appearance
|
||||
&& a.opacity == b.opacity
|
||||
&& a.blend == b.blend
|
||||
&& a.isolate == b.isolate
|
||||
&& a.knockout == b.knockout
|
||||
&& a.extras == b.extras
|
||||
}
|
||||
|
||||
/// How wide a space of `style` is.
|
||||
fn space_width(style: &CharStyle) -> f64 {
|
||||
let probe = TextObject::point(Point::ZERO, " ", CharStyle { tracking: 0.0, ..style.clone() });
|
||||
layout(&probe).glyphs.first().map_or(style.size * 0.25, |g| g.advance)
|
||||
}
|
||||
|
||||
/// `chain` (pieces left to right on one line) as one point type object whose glyphs land where
|
||||
/// theirs were, or the index of the first piece that can't join the ones before it so.
|
||||
fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
|
||||
let Some(first) = chain.first() else { return Err(0) };
|
||||
let mut runs = vec![];
|
||||
// Each piece's first byte, and the byte range of the character before it (whose tracking sets
|
||||
// the gap).
|
||||
let (mut starts, mut before) = (vec![], vec![]);
|
||||
let mut text = String::new();
|
||||
for (k, p) in chain.iter().enumerate() {
|
||||
if let Some(prev) = k.checked_sub(1).and_then(|i| chain.get(i)) {
|
||||
let starts_blank = p.t.plain_text().starts_with(char::is_whitespace);
|
||||
let last = prev.t.runs.iter().rev().find(|r| !r.text.is_empty()).map(|r| r.style.clone()).unwrap_or_default();
|
||||
if !text.ends_with(char::is_whitespace) && !starts_blank && p.at.x - prev.end >= 0.5 * space_width(&last) {
|
||||
runs.push(vectorcraft_doc::TextRun::new(" ", last));
|
||||
text.push(' ');
|
||||
}
|
||||
let c = text.chars().next_back().map_or(1, char::len_utf8);
|
||||
before.push((text.len() - c, text.len()));
|
||||
}
|
||||
starts.push(text.len());
|
||||
// Baselines a little apart: the difference is baseline shift (up is positive).
|
||||
let shift = first.at.y - p.at.y;
|
||||
for r in &p.t.runs {
|
||||
let mut r = r.clone();
|
||||
r.style.baseline_shift += shift;
|
||||
text.push_str(&r.text);
|
||||
runs.push(r);
|
||||
}
|
||||
}
|
||||
let mut m = first.t.clone();
|
||||
m.set_all_paras(ParaStyle { justify: Justify::Left, ..first.t.para.clone() });
|
||||
let [a, b, c, d] = first.turn;
|
||||
let base = Affine::new([a, b, c, d, 0.0, 0.0]) * Affine::translate(first.at.to_vec2());
|
||||
m.xf = base;
|
||||
m.runs = runs;
|
||||
m.ruby.clear();
|
||||
// Tracking after the character before each piece, until each lands where it was.
|
||||
let targets: Vec<f64> = chain.iter().map(|p| p.at.x - first.at.x).collect();
|
||||
let mut lay = layout(&m);
|
||||
for _ in 0..4 {
|
||||
// A piece whose first character joins the one before it (a ligature) can't keep its place.
|
||||
let xs: Vec<f64> = match starts.iter().enumerate().map(|(k, b)| glyph_starting(&lay, *b).map(|g| g.x).ok_or(k)).collect() {
|
||||
Ok(xs) => xs,
|
||||
Err(k) => return Err(k.max(1)),
|
||||
};
|
||||
let x0 = xs.first().copied().unwrap_or(0.0);
|
||||
let off: Vec<f64> = targets.iter().zip(&xs).map(|(t, x)| t - (x - x0)).collect();
|
||||
if off.iter().all(|o| o.abs() <= TOLERANCE * 0.5) {
|
||||
break;
|
||||
}
|
||||
// Moving piece k moves those after it as far.
|
||||
for (k, w) in off.windows(2).enumerate() {
|
||||
let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue };
|
||||
if delta.abs() > 1e-9 {
|
||||
edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0);
|
||||
if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) {
|
||||
return Err(k + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
lay = layout(&m);
|
||||
}
|
||||
// Every glyph where it was, within the tolerance; else the run ends before the piece that moved.
|
||||
let x0 = glyph_starting(&lay, 0).map_or(0.0, |g| g.x);
|
||||
for (k, p) in chain.iter().enumerate() {
|
||||
let (Some(&start), Some(&target)) = (starts.get(k), targets.get(k)) else { return Err(k.max(1)) };
|
||||
let moved = p.pens.iter().any(|(byte, x)| glyph_starting(&lay, start + byte).is_none_or(|g| (g.x - x0 - target - x).abs() > TOLERANCE));
|
||||
if moved {
|
||||
return Err(k.max(1));
|
||||
}
|
||||
}
|
||||
m.xf = base * Affine::translate((-x0, 0.0));
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
fn combine_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.combineText";
|
||||
let d = &s.doc()?.doc;
|
||||
let mut pieces: Vec<(Piece, &Node)> =
|
||||
texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().filter_map(|id| Piece::of(d, id)).collect();
|
||||
// Lines: pieces turned alike and looking alike, by baseline, then left to right.
|
||||
pieces.sort_by(|x, y| x.0.at.y.total_cmp(&y.0.at.y).then(x.0.at.x.total_cmp(&y.0.at.x)));
|
||||
let mut lines: Vec<Vec<(&Piece, &Node)>> = vec![];
|
||||
for (piece, node) in &pieces {
|
||||
let fits = |l: &Vec<(&Piece, &Node)>| {
|
||||
l.first()
|
||||
.is_some_and(|(f, n)| f.turned_like(piece) && same_look(n, node) && (f.at.y - piece.at.y).abs() <= SAME_LINE * f.size.min(piece.size))
|
||||
};
|
||||
match lines.iter_mut().find(|l| fits(l)) {
|
||||
Some(l) => l.push((piece, node)),
|
||||
None => lines.push(vec![(piece, node)]),
|
||||
}
|
||||
}
|
||||
// Runs along each line: a wide gap or an overlap starts another.
|
||||
let mut chains: Vec<Vec<&Piece>> = vec![];
|
||||
for mut l in lines {
|
||||
l.sort_by(|x, y| x.0.at.x.total_cmp(&y.0.at.x));
|
||||
let mut chain: Vec<&Piece> = vec![];
|
||||
for (piece, _) in l {
|
||||
if let Some(prev) = chain.last() {
|
||||
let gap = piece.at.x - prev.end;
|
||||
if gap > MAX_GAP * prev.size || gap < -MAX_OVERLAP * prev.size {
|
||||
chains.push(std::mem::take(&mut chain));
|
||||
}
|
||||
}
|
||||
chain.push(piece);
|
||||
}
|
||||
chains.push(chain);
|
||||
}
|
||||
// Each run as one object, or as several where a piece can't keep its place joined.
|
||||
let mut made: Vec<(NodeId, TextObject, Vec<NodeId>)> = vec![];
|
||||
let mut tries = 0;
|
||||
'chains: for chain in chains {
|
||||
let mut rest: &[&Piece] = &chain;
|
||||
while rest.len() > 1 {
|
||||
let mut upto = rest.len();
|
||||
while let Some(part) = rest.get(..upto) {
|
||||
tries += part.len();
|
||||
if tries > MAX_JOIN_TRIES {
|
||||
break 'chains;
|
||||
}
|
||||
match merge(part) {
|
||||
Ok(m) => {
|
||||
let gone = part.iter().skip(1).map(|p| p.id).collect();
|
||||
if let Some(first) = part.first() {
|
||||
made.push((first.id, m, gone));
|
||||
}
|
||||
break;
|
||||
}
|
||||
// A run of one stays as it is.
|
||||
Err(k) if k <= 1 => {
|
||||
upto = 1;
|
||||
break;
|
||||
}
|
||||
Err(k) => upto = k,
|
||||
}
|
||||
}
|
||||
rest = rest.get(upto.max(1)..).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
if made.is_empty() {
|
||||
return Err(EngineError::Other("Select two or more one-line point type objects on one baseline to combine".into()));
|
||||
}
|
||||
let combined: usize = made.iter().map(|m| m.2.len()).sum();
|
||||
let ids: Vec<u64> = made.iter().map(|m| m.0.0).collect();
|
||||
s.edit("Combine Point Text", |d, sel| {
|
||||
let mut gone = vec![];
|
||||
for (id, mut t, others) in made {
|
||||
refresh_bounds(&mut t);
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Text(Box::new(t));
|
||||
}
|
||||
gone.extend(others);
|
||||
}
|
||||
d.remove_many(&gone)?;
|
||||
sel.set(ids.iter().map(|i| NodeId(*i)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids, "combined": combined }))
|
||||
}
|
||||
|
||||
/// Remove the breaks `brk` in bytes `a..b` of `t`: one between two words becomes a space → how many
|
||||
/// went.
|
||||
fn strip_breaks(t: &mut TextObject, a: usize, b: usize, brk: char) -> usize {
|
||||
let text = t.plain_text();
|
||||
let (a, b) = (edit::floor_char(&text, a), edit::floor_char(&text, b));
|
||||
let Some(range) = text.get(a..b) else { return 0 };
|
||||
let mut prev = text.get(..a).and_then(|s| s.chars().next_back());
|
||||
let mut cuts = vec![];
|
||||
for (i, c) in range.char_indices() {
|
||||
let at = a + i;
|
||||
if c != brk {
|
||||
prev = Some(c);
|
||||
continue;
|
||||
}
|
||||
// The next character left: past the breaks that go too.
|
||||
let after = at + c.len_utf8();
|
||||
let next = text.get(after..).and_then(|rest| rest.char_indices().find(|(j, n)| *n != brk || after + j >= b).map(|(_, n)| n));
|
||||
let space = prev.is_some_and(|p| !p.is_whitespace()) && next.is_some_and(|n| !n.is_whitespace());
|
||||
if space {
|
||||
prev = Some(' ');
|
||||
}
|
||||
cuts.push((at, space));
|
||||
}
|
||||
for &(at, space) in cuts.iter().rev() {
|
||||
let insert = if space { " " } else { "" };
|
||||
let end = at + brk.len_utf8();
|
||||
t.splice_paras(at, end, insert);
|
||||
edit::replace_range(&mut t.runs, at, end, insert);
|
||||
t.ruby_after_replace(at, end, insert.len());
|
||||
}
|
||||
cuts.len()
|
||||
}
|
||||
|
||||
fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.removeBreaks";
|
||||
let (brk, label, none) = match text_param(p, "kind", C)? {
|
||||
Some("line") => ('\u{2028}', "Remove Line Breaks", "No line breaks to remove"),
|
||||
Some("paragraph") => ('\n', "Remove Paragraph Breaks", "No paragraph breaks to remove"),
|
||||
Some(other) => return Err(bad(C, format!("`kind` must be \"line\" or \"paragraph\", not {other:?}"))),
|
||||
None => return Err(bad(C, "missing `kind`")),
|
||||
};
|
||||
// The characters a Type tool has selected, else all the text of the selected type.
|
||||
let editing = if p.get("ids").is_some() { None } else { editing_range(s)?.filter(|(_, a, b)| a != b) };
|
||||
let d = &s.doc()?.doc;
|
||||
let targets: Vec<(NodeId, usize, usize)> = match editing {
|
||||
Some((id, a, b)) => vec![(id, a.min(b), a.max(b))].into_iter().filter(|(id, ..)| d.is_editable(*id)).collect(),
|
||||
None => texts_under(d, &checked_ids_or_selection(s, p, C)?)?.into_iter().map(|id| (id, 0, usize::MAX)).collect(),
|
||||
};
|
||||
let count: usize = targets
|
||||
.iter()
|
||||
.filter_map(|(id, a, b)| {
|
||||
let text = text_of(d, *id)?.plain_text();
|
||||
let (a, b) = (edit::floor_char(&text, *a), edit::floor_char(&text, *b));
|
||||
Some(text.get(a..b)?.matches(brk).count())
|
||||
})
|
||||
.sum();
|
||||
if count == 0 {
|
||||
return Err(EngineError::Other(none.into()));
|
||||
}
|
||||
if count > MAX_BREAKS {
|
||||
return Err(EngineError::Other("Too many breaks to remove at once".into()));
|
||||
}
|
||||
let (removed, ids) = s.edit(label, |d, _| {
|
||||
let (mut removed, mut ids) = (0, vec![]);
|
||||
for (id, a, b) in targets {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
|
||||
let n = strip_breaks(t, a, b, brk);
|
||||
if n > 0 {
|
||||
refresh_bounds(t);
|
||||
removed += n;
|
||||
ids.push(id.0);
|
||||
}
|
||||
}
|
||||
Ok((removed, ids))
|
||||
})?;
|
||||
Ok(json!({ "ids": ids, "removed": removed }))
|
||||
}
|
||||
|
||||
/// Horizontal point type among the objects `p` names.
|
||||
fn point_texts(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let d = &s.doc()?.doc;
|
||||
let ids: Vec<NodeId> = texts_under(d, &checked_ids_or_selection(s, p, cmd)?)?
|
||||
.into_iter()
|
||||
.filter(|id| text_of(d, *id).is_some_and(|t| matches!(t.kind, TextKind::Point)))
|
||||
.collect();
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("Select point type".into()));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Replace the type objects of `changed` as one undo step labelled `label` → their ids.
|
||||
fn set_texts(s: &mut Session, label: &str, changed: Vec<(NodeId, TextObject)>, reset_angle: bool) -> Result<Value> {
|
||||
let ids: Vec<u64> = changed.iter().map(|c| c.0.0).collect();
|
||||
s.edit(label, |d, _| {
|
||||
for (id, mut t) in changed {
|
||||
refresh_bounds(&mut t);
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.kind = NodeKind::Text(Box::new(t));
|
||||
if reset_angle {
|
||||
n.bbox_angle = 0.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids }))
|
||||
}
|
||||
|
||||
fn align_in_place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.alignInPlace";
|
||||
let justify = match text_param(p, "align", C)? {
|
||||
Some("left") => Justify::Left,
|
||||
Some("center") => Justify::Center,
|
||||
Some("right") => Justify::Right,
|
||||
Some(other) => return Err(bad(C, format!("`align` must be \"left\", \"center\" or \"right\", not {other:?}"))),
|
||||
None => return Err(bad(C, "missing `align`")),
|
||||
};
|
||||
let ids = point_texts(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut changed = vec![];
|
||||
for id in ids {
|
||||
let Some(t) = text_of(d, id).filter(|t| !t.vertical && t.para_styles().any(|para| para.justify != justify)) else { continue };
|
||||
let was = layout(t);
|
||||
// The longest line stays where it is.
|
||||
let Some((i, line)) = was.lines.iter().enumerate().max_by(|a, b| (a.1.x1 - a.1.x0).total_cmp(&(b.1.x1 - b.1.x0))) else { continue };
|
||||
let mut n = t.clone();
|
||||
n.edit_paras(None, |para| para.justify = justify);
|
||||
let Some(now) = layout(&n).lines.get(i).map(|l| l.x0) else { continue };
|
||||
n.xf *= Affine::translate((line.x0 - now, 0.0));
|
||||
changed.push((id, n));
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(EngineError::Other("The point type is aligned that way already".into()));
|
||||
}
|
||||
set_texts(s, "Set Alignment, Keep Position", changed, false)
|
||||
}
|
||||
|
||||
fn reset_transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.resetTextTransform";
|
||||
let scale = switch_or(p, "scale", false, C)?;
|
||||
let ids = point_texts(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let mut changed = vec![];
|
||||
for id in ids {
|
||||
let Some(t) = text_of(d, id) else { continue };
|
||||
let [a, b, c, dd, _, _] = t.xf.as_coeffs();
|
||||
// The object's own axes: its width along its baseline, and its height square to it.
|
||||
let (sx, det) = (a.hypot(b), a * dd - b * c);
|
||||
if !(sx > 1e-12 && sx.is_finite() && det.is_finite() && det.abs() > 1e-12) {
|
||||
continue;
|
||||
}
|
||||
let sy = det.abs() / sx;
|
||||
let unscaled = |t: &TextObject| t.runs.iter().all(|r| r.style.h_scale == 100.0);
|
||||
let upright = b.abs() <= 1e-9 * sx && c.abs() <= 1e-9 * sy && a > 0.0 && dd > 0.0;
|
||||
if upright && (!scale || ((sx - sy).abs() <= 1e-9 * sy && unscaled(t))) {
|
||||
continue;
|
||||
}
|
||||
let mut n = t.clone();
|
||||
let w = if scale {
|
||||
n.runs.iter_mut().for_each(|r| r.style.h_scale = 100.0);
|
||||
sy
|
||||
} else {
|
||||
sx
|
||||
};
|
||||
// The centre of the text stays where it is.
|
||||
let centre = t.xf * layout(t).bounds.center();
|
||||
let turn = Affine::scale_non_uniform(w, sy);
|
||||
let now = turn * layout(&n).bounds.center();
|
||||
n.xf = Affine::translate(centre - now) * turn;
|
||||
changed.push((id, n));
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(EngineError::Other("The point type has no transform to reset".into()));
|
||||
}
|
||||
set_texts(s, "Reset Text Transform", changed, true)
|
||||
}
|
||||
|
||||
fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.cleanup.replaceFonts";
|
||||
let default = CharStyle::default().font_family;
|
||||
let wanted = text_param(p, "font", C)?.filter(|f| !f.trim().is_empty()).unwrap_or(&default);
|
||||
let db = FontDb::global();
|
||||
// Any name of the family is stored as its own name.
|
||||
let family = match db.resolve(wanted, "Regular") {
|
||||
Some((f, m)) if m != FontMatch::Missing => f.family.clone(),
|
||||
_ => return Err(bad(C, "the replacement font is not available")),
|
||||
};
|
||||
let missing = |st: &CharStyle| db.resolve(&st.font_family, &st.font_style).is_none_or(|(_, m)| m == FontMatch::Missing);
|
||||
let d = &s.doc()?.doc;
|
||||
let mut ids = vec![];
|
||||
for l in &d.layers {
|
||||
l.walk(&mut |n| {
|
||||
if let NodeKind::Text(t) = &n.kind
|
||||
&& t.runs.iter().any(|r| missing(&r.style))
|
||||
{
|
||||
ids.push(n.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("No fonts are missing".into()));
|
||||
}
|
||||
let runs = s.edit("Replace Missing Fonts", |d, _| {
|
||||
let mut runs = 0;
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
for r in t.runs.iter_mut().filter(|r| missing(&r.style)) {
|
||||
// The style the font has closest to the one the character had.
|
||||
r.style.font_style = db.face(&family, &r.style.font_style).map_or_else(|| r.style.font_style.clone(), |f| f.style.clone());
|
||||
r.style.font_family = family.clone();
|
||||
r.style.font_version = None;
|
||||
r.style.font_axes.clear();
|
||||
runs += 1;
|
||||
}
|
||||
edit::normalize(&mut t.runs);
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(runs)
|
||||
})?;
|
||||
Ok(json!({ "runs": runs, "font": family }))
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
//! Artwork Cleanup's text tools: pieces stay where their glyphs were drawn and keep their styles,
|
||||
//! each command is one undo step and refuses cleanly when there is nothing to do.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Justify, NodeId, NodeKind, TextKind, TextObject};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn text(s: &mut Session, p: Value) -> NodeId {
|
||||
NodeId(s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn obj(s: &Session, id: NodeId) -> TextObject {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.select(|_, sel| sel.set(ids.iter().copied())).unwrap();
|
||||
}
|
||||
|
||||
fn ids(v: &Value) -> Vec<NodeId> {
|
||||
v["ids"].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
|
||||
}
|
||||
|
||||
/// Each drawn character of `ids` (spaces left out) with where its glyph starts on the page, in
|
||||
/// the order of the objects and their text.
|
||||
fn glyphs(s: &Session, ids: &[NodeId]) -> Vec<(char, Point)> {
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let t = obj(s, *id);
|
||||
let text = t.plain_text();
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
let mut gs: Vec<_> = lay.glyphs.iter().filter(|g| g.len > 0).collect();
|
||||
gs.sort_by_key(|g| g.byte);
|
||||
gs.dedup_by_key(|g| g.byte);
|
||||
for g in gs {
|
||||
let c = text[g.byte..].chars().next().unwrap();
|
||||
if !c.is_whitespace() {
|
||||
out.push((c, t.xf * g.origin));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_same_places(was: &[(char, Point)], now: &[(char, Point)]) {
|
||||
assert_eq!(was.iter().map(|g| g.0).collect::<String>(), now.iter().map(|g| g.0).collect::<String>());
|
||||
for (a, b) in was.iter().zip(now) {
|
||||
assert!((a.1 - b.1).hypot() < 0.02, "{:?} moved to {:?}", a, b);
|
||||
}
|
||||
}
|
||||
|
||||
fn undo_steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn doc_json(s: &Session) -> String {
|
||||
serde_json::to_string(&s.doc().unwrap().doc.layers).unwrap()
|
||||
}
|
||||
|
||||
fn other(r: Result<Value, EngineError>) -> String {
|
||||
match r {
|
||||
Err(EngineError::Other(m)) => m,
|
||||
r => panic!("expected a refusal, got {r:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_into_words_keeps_places_styles_and_is_one_step() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 100, "text": "Hello big World", "size": 24}));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 9, "size": 36, "tracking": 50})).unwrap();
|
||||
let rotated = text(&mut s, json!({"x": 300, "y": 200, "text": "Turned words", "size": 18, "rotation": 30}));
|
||||
let was = glyphs(&s, &[id, rotated]);
|
||||
let before = doc_json(&s);
|
||||
let steps = undo_steps(&s);
|
||||
select(&mut s, &[id, rotated]);
|
||||
let r = s.execute("addon.cleanup.breakText", &json!({"by": "words"})).unwrap();
|
||||
let pieces = ids(&r);
|
||||
assert_eq!(pieces.len(), 5);
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 5, "the pieces are selected");
|
||||
let texts: Vec<String> = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect();
|
||||
assert_eq!(texts, ["Hello", "big", "World", "Turned", "words"]);
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
let big = obj(&s, pieces[1]);
|
||||
assert!(matches!(big.kind, TextKind::Point));
|
||||
assert_eq!((big.runs[0].style.size, big.runs[0].style.tracking), (36.0, 50.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc_json(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_area_type_into_lines_paragraphs_and_glyphs() {
|
||||
for (by, count) in [("lines", None), ("paragraphs", Some(2)), ("glyphs", None)] {
|
||||
let mut s = session();
|
||||
let id = text(
|
||||
&mut s,
|
||||
json!({"x": 40, "y": 40, "text": "A paragraph long enough to wrap in its frame\nSecond one", "size": 14, "area": {"width": 140, "height": 200}}),
|
||||
);
|
||||
let t = obj(&s, id);
|
||||
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t);
|
||||
assert!(lay.lines.len() >= 3, "the first paragraph wraps");
|
||||
let was = glyphs(&s, &[id]);
|
||||
select(&mut s, &[id]);
|
||||
let r = s.execute("addon.cleanup.breakText", &json!({"by": by})).unwrap();
|
||||
let pieces = ids(&r);
|
||||
let want = count.unwrap_or(if by == "lines" { lay.lines.len() } else { was.len() });
|
||||
assert_eq!(pieces.len(), want, "{by}");
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
if by == "paragraphs" {
|
||||
// The wrapped lines end in line breaks.
|
||||
assert!(obj(&s, pieces[0]).plain_text().contains('\u{2028}'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn break_text_refuses_what_it_cannot_break() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
|
||||
assert!(other(s.execute("addon.cleanup.breakText", &json!({}))).contains("point or area type"));
|
||||
let r = s.execute("addon.cleanup.breakText", &json!({"by": "sentences"}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_puts_glyph_pieces_back_together_where_they_were() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 50, "y": 120, "text": "Water Lily", "size": 30}));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "start": 6, "end": 10, "fill": "#ff0000"})).unwrap();
|
||||
let was = glyphs(&s, &[id]);
|
||||
select(&mut s, &[id]);
|
||||
let pieces = ids(&s.execute("addon.cleanup.breakText", &json!({"by": "glyphs"})).unwrap());
|
||||
assert_eq!(pieces.len(), 9);
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
let made = ids(&r);
|
||||
assert_eq!((made.len(), r["combined"].as_u64()), (1, Some(8)));
|
||||
let t = obj(&s, made[0]);
|
||||
assert_eq!(t.plain_text(), "Water Lily", "a space goes back between the words");
|
||||
assert_same_places(&was, &glyphs(&s, &made));
|
||||
let red = t.runs.iter().find(|r| r.text.contains('L')).unwrap();
|
||||
assert_ne!(red.style.fill, t.runs[0].style.fill, "styles stay");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(pieces[1]).is_some(), "undo brings the pieces back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_joins_only_pieces_on_one_line_turned_alike() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
|
||||
let b = text(&mut s, json!({"x": 110, "y": 100, "text": "Right", "size": 20}));
|
||||
let c = text(&mut s, json!({"x": 50, "y": 200, "text": "Below", "size": 20}));
|
||||
let d = text(&mut s, json!({"x": 300, "y": 100, "text": "Turned", "size": 20, "rotation": 45}));
|
||||
let was = glyphs(&s, &[a, b]);
|
||||
select(&mut s, &[a, b, c, d]);
|
||||
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
|
||||
assert_eq!(ids(&r), [a]);
|
||||
assert_eq!(obj(&s, a).plain_text(), "Left Right");
|
||||
assert_same_places(&was, &glyphs(&s, &[a]));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(b).is_none() && doc.node(c).is_some() && doc.node(d).is_some());
|
||||
select(&mut s, &[c, d]);
|
||||
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_line_and_paragraph_breaks() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "one\u{2028}two \u{2028}three\nfour\n\nfive", "size": 12}));
|
||||
select(&mut s, &[id]);
|
||||
let before = doc_json(&s);
|
||||
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"})).unwrap();
|
||||
assert_eq!(r["removed"], 2);
|
||||
assert_eq!(obj(&s, id).plain_text(), "one two three\nfour\n\nfive");
|
||||
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
|
||||
assert_eq!(r["removed"], 3);
|
||||
let t = obj(&s, id);
|
||||
assert_eq!((t.plain_text().as_str(), t.paragraph_count()), ("one two three four five", 1));
|
||||
assert_eq!(other(s.execute("addon.cleanup.removeBreaks", &json!({"kind": "line"}))), "No line breaks to remove");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc_json(&s), before);
|
||||
assert!(matches!(s.execute("addon.cleanup.removeBreaks", &json!({})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
/// The Type tool editing `id` with bytes `anchor..caret` selected.
|
||||
fn edit_text(s: &mut Session, id: NodeId, anchor: usize, caret: usize) {
|
||||
s.select_tool("type", Default::default()).unwrap();
|
||||
s.set_tool_option("textSelection", &json!({"editing": id.0, "anchor": anchor, "caret": caret}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_breaks_in_the_characters_a_type_tool_selected() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "a\nb\nc", "size": 12}));
|
||||
select(&mut s, &[id]);
|
||||
edit_text(&mut s, id, 0, 2);
|
||||
s.execute("addon.cleanup.removeBreaks", &json!({"kind": "paragraph"})).unwrap();
|
||||
assert_eq!(obj(&s, id).plain_text(), "a b\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_at_the_cursor_and_around_a_selection() {
|
||||
for (anchor, caret, want) in [(6, 6, &["Split", "here please"][..]), (6, 10, &["Split", "here", "please"][..])] {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 40, "y": 60, "text": "Split here please", "size": 20, "justify": "center"}));
|
||||
let was = glyphs(&s, &[id]);
|
||||
select(&mut s, &[id]);
|
||||
let r = s.execute("addon.cleanup.splitText", &json!({}));
|
||||
assert!(matches!(&r, Err(EngineError::Disabled(_, why)) if why.contains("Type tool")), "{r:?}");
|
||||
edit_text(&mut s, id, anchor, caret);
|
||||
let steps = undo_steps(&s);
|
||||
let r = s.execute("addon.cleanup.splitText", &json!({})).unwrap();
|
||||
assert_eq!(undo_steps(&s), steps + 1);
|
||||
let pieces = ids(&r);
|
||||
let texts: Vec<String> = pieces.iter().map(|p| obj(&s, *p).plain_text()).collect();
|
||||
assert_eq!(texts, want);
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
assert!(s.tool_options()["editing"].is_null(), "editing ends");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_keeps_the_later_lines_of_a_wrapped_piece() {
|
||||
let mut s = session();
|
||||
let id = text(
|
||||
&mut s,
|
||||
json!({"x": 40, "y": 40, "text": "First words then a long run of text that wraps in the frame", "size": 14, "area": {"width": 160, "height": 200}}),
|
||||
);
|
||||
let was = glyphs(&s, &[id]);
|
||||
select(&mut s, &[id]);
|
||||
edit_text(&mut s, id, 6, 6);
|
||||
let pieces = ids(&s.execute("addon.cleanup.splitText", &json!({})).unwrap());
|
||||
assert_eq!(pieces.len(), 2);
|
||||
assert_same_places(&was, &glyphs(&s, &pieces));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_alignment_keeps_the_text_in_place() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 100, "y": 60, "text": "Stay put", "size": 20}));
|
||||
let multi = text(&mut s, json!({"x": 100, "y": 160, "text": "Short\u{2028}The longest line", "size": 20}));
|
||||
let was = glyphs(&s, &[id]);
|
||||
let longest: Vec<_> = glyphs(&s, &[multi]).into_iter().skip(5).collect();
|
||||
select(&mut s, &[id, multi]);
|
||||
for align in ["center", "right", "left"] {
|
||||
s.execute("addon.cleanup.alignInPlace", &json!({"align": align})).unwrap();
|
||||
assert_same_places(&was, &glyphs(&s, &[id]));
|
||||
assert_same_places(&longest, &glyphs(&s, &[multi]).into_iter().skip(5).collect::<Vec<_>>());
|
||||
}
|
||||
assert_eq!(obj(&s, id).para.justify, Justify::Left);
|
||||
assert!(other(s.execute("addon.cleanup.alignInPlace", &json!({"align": "left"}))).contains("already"));
|
||||
assert!(matches!(s.execute("addon.cleanup.alignInPlace", &json!({"align": "up"})), Err(EngineError::BadParams { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_text_transform_takes_off_rotation_and_with_scale_the_horizontal_scale() {
|
||||
let mut s = session();
|
||||
let id = text(&mut s, json!({"x": 100, "y": 100, "text": "Tilted", "size": 20, "rotation": 30}));
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 150})).unwrap();
|
||||
let centre = |s: &Session| {
|
||||
let t = obj(s, id);
|
||||
t.xf * vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds.center()
|
||||
};
|
||||
let was = centre(&s);
|
||||
let size = obj(&s, id).effective_char_style(&obj(&s, id).runs[0].style).unwrap().size;
|
||||
select(&mut s, &[id]);
|
||||
s.execute("addon.cleanup.resetTextTransform", &json!({})).unwrap();
|
||||
let t = obj(&s, id);
|
||||
let [_, b, c, _, _, _] = t.xf.as_coeffs();
|
||||
assert!(b.abs() < 1e-9 && c.abs() < 1e-9, "{:?}", t.xf);
|
||||
assert!((centre(&s) - was).hypot() < 1e-6);
|
||||
assert!((t.effective_char_style(&t.runs[0].style).unwrap().size - size).abs() < 1e-6, "the size stays");
|
||||
assert!(t.runs[0].style.h_scale > 100.0, "the horizontal scale stays");
|
||||
assert!(other(s.execute("addon.cleanup.resetTextTransform", &json!({}))).contains("no transform"));
|
||||
s.execute("addon.cleanup.resetTextTransform", &json!({"scale": true})).unwrap();
|
||||
assert_eq!(obj(&s, id).runs[0].style.h_scale, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_missing_fonts_with_the_default_or_a_chosen_font() {
|
||||
let mut s = session();
|
||||
let gone = text(&mut s, json!({"x": 40, "y": 60, "text": "Lost", "size": 20, "font": "No Such Font Family", "style": "Bold"}));
|
||||
let kept = text(&mut s, json!({"x": 40, "y": 120, "text": "Fine", "size": 20}));
|
||||
let fine = obj(&s, kept);
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({})).unwrap();
|
||||
assert_eq!((r["runs"].as_u64(), r["font"].as_str()), (Some(1), Some("Source Sans 3")));
|
||||
let t = obj(&s, gone);
|
||||
assert_eq!((t.runs[0].style.font_family.as_str(), t.runs[0].style.font_style.as_str()), ("Source Sans 3", "Bold"));
|
||||
assert_eq!(obj(&s, kept), fine);
|
||||
assert_eq!(other(s.execute("addon.cleanup.replaceFonts", &json!({}))), "No fonts are missing");
|
||||
let r = s.execute("addon.cleanup.replaceFonts", &json!({"font": "No Such Font Family"}));
|
||||
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{r:?}");
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
//! 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, Split / Add Points at Crossings and the Parametric Shape Tool's shapes ([`shape`]).
|
||||
//! anchor edits, Split / Add Points at Crossings, Slide Anchor Along Path, the Corner Tool's corners
|
||||
//! ([`corners`]), the Parametric Shape Tool's shapes ([`shape`]), and the Shared Edge Merge Tool's
|
||||
//! merges with Keep Shared Points Together ([`shared_edge`]).
|
||||
//! Its tools, panels and submenu (Object › Plug-ins › Path Precision Tools) are the tools crate's
|
||||
//! and the UI's.
|
||||
|
||||
mod corners;
|
||||
#[cfg(test)]
|
||||
mod effect_tests;
|
||||
mod shape;
|
||||
mod shared_edge;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -20,7 +26,6 @@ use super::super::pathops::Beside;
|
||||
use super::super::{
|
||||
CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, finite_numbers, has_doc, has_selection, number_or, switch_or, text_param,
|
||||
};
|
||||
use super::cleanup::PT_PER_TOLERANCE;
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// The Anchor Eraser Brush's tolerance by default (the scale Simplify Anchors (Keep Shape) uses).
|
||||
@@ -54,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Extend Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step → {id, length (pt)}",
|
||||
"{id, subpath?: index (0), end: \"start\"|\"end\", length?: pt (negative shortens; never all of it), to?: [x, y] (instead of length: the length that brings the end nearest this point), mode?: \"straight\"|\"bezier\" (stretch the last segment's curve, no anchor added)|\"arc\" (along the circle the end curves on, at most half way round) (\"straight\")} lengthen an open path at an end the way it runs there, or shorten it back along itself, as one undo step; with Keep Shared Points Together on, anchors coincident with an end that moves go with it → {id, length (pt)}",
|
||||
has_doc,
|
||||
extend
|
||||
),
|
||||
@@ -72,7 +77,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Anchor",
|
||||
[],
|
||||
None,
|
||||
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set → {anchors}",
|
||||
"{x?, y?: pt (on the page), inLength?, outLength?: pt (0 retracts), inAngle?, outAngle?: degrees counter-clockwise} move the selected anchors (with their handles) and set their handles, as one undo step: direct-selected anchors, or every anchor of a path selected whole; a smooth anchor's other handle turns with the one set; with Keep Shared Points Together on, anchors coincident with one that moves go with it → {anchors}",
|
||||
has_selection,
|
||||
set_anchor
|
||||
),
|
||||
@@ -139,8 +144,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
crossings
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.slideAnchor",
|
||||
"Slide Anchor Along Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?: index (0), anchor: index, t: -0.98..0.98 (where to: positive, a curve parameter on the segment after the anchor; negative, back from the end of the one before)} move an anchor along its path, the segments beside it refitted so the outline stays, as one undo step; ends of open paths don't slide; with Keep Shared Points Together on, anchors coincident with it go with it → {id, strayed (pt)}",
|
||||
has_doc,
|
||||
slide_anchor
|
||||
),
|
||||
];
|
||||
v.extend(shape::specs());
|
||||
v.extend(corners::specs());
|
||||
v.extend(shared_edge::specs());
|
||||
v
|
||||
}
|
||||
|
||||
@@ -244,7 +260,7 @@ fn erase(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = paths_under(d, &roots);
|
||||
let paths: Vec<(NodeId, &PathData)> = ids.iter().filter_map(|id| Some((*id, d.node(*id)?.path_data()?))).collect();
|
||||
check_size(paths.iter().map(|(_, p)| *p), C)?;
|
||||
let tol = if ignore { f64::INFINITY } else { tolerance * PT_PER_TOLERANCE };
|
||||
let tol = if ignore { f64::INFINITY } else { tolerance * po::PT_PER_TOLERANCE };
|
||||
let mut removed = 0;
|
||||
let mut changed = vec![];
|
||||
for (id, path) in paths {
|
||||
@@ -288,8 +304,8 @@ fn edit_whole(s: &mut Session, label: &str, changed: Vec<(NodeId, PathData)>) ->
|
||||
|
||||
// ---------- Extend Path ----------
|
||||
|
||||
/// The open subpath an `extend` request names: (path id, subpath index, its data).
|
||||
fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
|
||||
/// The subpath a request names: (path id, subpath index, its data).
|
||||
fn named_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, SubPath)> {
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), cmd)?;
|
||||
let si = usize_opt(p, "subpath", cmd)?.unwrap_or(0);
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -298,15 +314,36 @@ fn open_subpath(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, usize, Sub
|
||||
}
|
||||
let path = d.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(cmd, "`id` is not a path"))?;
|
||||
let sp = path.subpaths.get(si).ok_or_else(|| bad(cmd, "no such subpath"))?;
|
||||
if sp.closed || sp.anchors.len() < 2 {
|
||||
return Err(bad(cmd, "the subpath has no ends to extend (it is closed or a single point)"));
|
||||
}
|
||||
Ok((id, si, sp.clone()))
|
||||
}
|
||||
|
||||
/// Put `new` in place of the subpath `old` (from [`named_subpath`]) as one step labelled `label`
|
||||
/// (none when it is the same), with what Keep Shared Points Together moves too; the path's direct
|
||||
/// selection goes.
|
||||
fn set_subpath(s: &mut Session, label: &str, (id, si, old): (NodeId, usize, SubPath), new: SubPath) -> Result<()> {
|
||||
if new == old {
|
||||
return Ok(());
|
||||
}
|
||||
let mut np = s.doc()?.doc.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
if let Some(slot) = np.subpaths.get_mut(si) {
|
||||
*slot = new;
|
||||
}
|
||||
let shared = shared_edge::shared_points(s, [(id, &np)])?;
|
||||
s.edit(label, |d, sel| {
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
set_path(d, id, np);
|
||||
shared_edge::move_shared(d, shared);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.extend";
|
||||
let (id, si, sp) = open_subpath(s, p, C)?;
|
||||
let (id, si, sp) = named_subpath(s, p, C)?;
|
||||
if sp.closed || sp.anchors.len() < 2 {
|
||||
return Err(bad(C, "the subpath has no ends to extend (it is closed or a single point)"));
|
||||
}
|
||||
let at_start = match text_param(p, "end", C)? {
|
||||
Some("start") => true,
|
||||
Some("end") => false,
|
||||
@@ -328,22 +365,26 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`length` must be a number of at most 1000000 pt either way"));
|
||||
}
|
||||
let new = pt::extend_open(&sp, at_start, length, mode).ok_or_else(|| bad(C, "the path is too short to shorten that much"))?;
|
||||
if new != sp {
|
||||
s.edit("Extend Path", |d, sel| {
|
||||
let n = d.node(id).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(id))?;
|
||||
let mut np = n;
|
||||
if let Some(slot) = np.subpaths.get_mut(si) {
|
||||
*slot = new;
|
||||
}
|
||||
sel.anchors.remove(&id);
|
||||
sel.segments.remove(&id);
|
||||
set_path(d, id, np);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
set_subpath(s, "Extend Path", (id, si, sp), new)?;
|
||||
Ok(json!({ "id": id.0, "length": length }))
|
||||
}
|
||||
|
||||
// ---------- Slide Anchor Along Path ----------
|
||||
|
||||
fn slide_anchor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.slideAnchor";
|
||||
let (id, si, sp) = named_subpath(s, p, C)?;
|
||||
let anchor = usize_opt(p, "anchor", C)?.ok_or_else(|| bad(C, "give `anchor`, the index of the anchor to slide"))?;
|
||||
let t = number_or(p, "t", f64::NAN, C)?;
|
||||
if t.is_nan() || t.abs() > pt::MAX_SLIDE {
|
||||
return Err(bad(C, "`t` must be a number from -0.98 to 0.98"));
|
||||
}
|
||||
let (new, strayed) =
|
||||
pt::slide_anchor(&sp, anchor, t).ok_or_else(|| bad(C, "that anchor can't slide: an end of an open path has nowhere to go"))?;
|
||||
set_subpath(s, "Slide Anchor Along Path", (id, si, sp), new)?;
|
||||
Ok(json!({ "id": id.0, "strayed": strayed }))
|
||||
}
|
||||
|
||||
// ---------- Measure & Inspect ----------
|
||||
|
||||
fn polar(h: Option<(f64, f64)>) -> Value {
|
||||
@@ -355,7 +396,7 @@ fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = checked_id(s, p.get("id").unwrap_or(&Value::Null), C)?;
|
||||
let (x, y) = (number_or(p, "x", f64::NAN, C)?, number_or(p, "y", f64::NAN, C)?);
|
||||
let tolerance = number_or(p, "tolerance", 3.0, C)?;
|
||||
if !(x.is_finite() && y.is_finite()) || !(tolerance >= 0.0 && tolerance.is_finite()) {
|
||||
if !(x.is_finite() && y.is_finite() && tolerance >= 0.0 && tolerance.is_finite()) {
|
||||
return Err(bad(C, "give `x` and `y` (and a `tolerance` of 0 or more)"));
|
||||
}
|
||||
let path = s.doc()?.doc.node(id).and_then(|n| n.path_data()).ok_or_else(|| bad(C, "`id` is not a path"))?;
|
||||
@@ -423,10 +464,12 @@ fn edit_anchors(s: &mut Session, label: &str, cmd: &str, f: impl Fn(&mut SubPath
|
||||
}
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
let shared = shared_edge::shared_points(s, changed.iter().map(|(id, np)| (*id, np)))?;
|
||||
s.edit(label, |d, _| {
|
||||
for (id, np) in changed {
|
||||
set_path(d, id, np);
|
||||
}
|
||||
shared_edge::move_shared(d, shared);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! The Corner Tool's commands: the app's Live Corners ([`LiveCorners`]) cut, resized and removed on
|
||||
//! chosen anchors, each edit one undo step.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{LiveCorners, NodeId, NodeKind};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_tools::addons::corner_tool::MAX_RADIUS;
|
||||
|
||||
use super::super::super::object::recut;
|
||||
use super::super::super::{CommandSpec, bad, checked_id, checked_ids_or_selection, cmd, has_doc, number_or};
|
||||
use super::{check_size, paths_under};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.cornerSet",
|
||||
"Set Corners",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors), radius: pt 0..1000000 (drawn no larger than fits), kind?: \"round\"|\"invertedRound\"|\"chamfer\" (each corner's own)} cut live corners at the anchors that can have one, as one undo step → {ids, corners (how many)}",
|
||||
has_doc,
|
||||
corner_set
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.cornerRemove",
|
||||
"Remove Corners",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (the selection), anchors?: [index…] (of each path's outline with no corners cut; else its direct-selected anchors, or all of them), items?: [{id, anchors?}…] (instead of ids: each path with its own anchors)} make those live corners sharp again, as one undo step → {ids, corners (how many)}",
|
||||
has_doc,
|
||||
corner_remove
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The `anchors` of `p`.
|
||||
fn anchors_param(p: &Value, cmd: &str) -> Result<Option<BTreeSet<usize>>> {
|
||||
let Some(v) = p.get("anchors").filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
let list = v.as_array().ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {v}")))?;
|
||||
list.iter()
|
||||
.map(|k| {
|
||||
k.as_u64()
|
||||
.and_then(|k| usize::try_from(k).ok())
|
||||
.ok_or_else(|| bad(cmd, format!("`anchors` must list anchor indices (0, 1, 2…), not {k}")))
|
||||
})
|
||||
.collect::<Result<_>>()
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
/// The corners a request names: each path with the corners (anchor indices of its uncut outline).
|
||||
fn named_corners(s: &Session, p: &Value, cmd: &str) -> Result<Vec<(NodeId, BTreeSet<usize>)>> {
|
||||
let shared = anchors_param(p, cmd)?;
|
||||
// Each path with its own `anchors`, or sharing the request's.
|
||||
let mut own: Vec<Option<BTreeSet<usize>>> = vec![];
|
||||
let ids: Vec<NodeId> = match p.get("items").filter(|v| !v.is_null()) {
|
||||
None => paths_under(&s.doc()?.doc, &checked_ids_or_selection(s, p, cmd)?),
|
||||
Some(Value::Array(items)) => {
|
||||
let mut ids = vec![];
|
||||
for it in items {
|
||||
ids.push(checked_id(s, it.get("id").unwrap_or(&Value::Null), cmd)?);
|
||||
own.push(anchors_param(it, cmd)?);
|
||||
}
|
||||
ids
|
||||
}
|
||||
Some(v) => return Err(bad(cmd, format!("`items` must be a list of {{id, anchors?}}, not {v}"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
check_size(ids.iter().filter_map(|id| d.node(*id)?.path_data()), cmd)?;
|
||||
let editable = d.editable();
|
||||
let mut out = vec![];
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
let Some(lc) = d.node(*id).filter(|_| editable(*id)).and_then(LiveCorners::of) else { continue };
|
||||
let corners = match own.get(i).and_then(Option::as_ref).or(shared.as_ref()) {
|
||||
Some(given) => {
|
||||
let n = lc.base.anchor_count();
|
||||
if let Some(past) = given.iter().find(|k| **k >= n) {
|
||||
return Err(bad(cmd, format!("object {id} has {n} anchors, so no anchor {past}")));
|
||||
}
|
||||
given.iter().copied().filter(|k| lc.corner(*k).is_some()).collect()
|
||||
}
|
||||
None => st.selection.partial(*id).filter(|a| !a.is_empty()).map_or_else(|| lc.all(), |a| lc.corners_of(a)),
|
||||
};
|
||||
if !corners.is_empty() {
|
||||
out.push((*id, corners));
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "choose anchors where two sides meet at an angle: only those take a corner"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Cut `radius` and `kind` on `corners` as one step (none when they have them already).
|
||||
fn edit_corners(s: &mut Session, label: &str, corners: Vec<(NodeId, BTreeSet<usize>)>, radius: f64, kind: Option<CornerKind>) -> Result<Value> {
|
||||
let d = &s.doc()?.doc;
|
||||
let same = corners.iter().all(|(id, picked)| {
|
||||
d.node(*id)
|
||||
.and_then(LiveCorners::of)
|
||||
.is_some_and(|c| picked.iter().all(|k| (c.drawn_radius(*k) - radius).abs() < 1e-9 && kind.is_none_or(|kd| c.kind(*k) == kd)))
|
||||
});
|
||||
let out = json!({
|
||||
"ids": corners.iter().map(|(id, _)| id.0).collect::<Vec<_>>(),
|
||||
"corners": corners.iter().map(|(_, c)| c.len()).sum::<usize>(),
|
||||
});
|
||||
if !same {
|
||||
s.edit(label, |d, sel| {
|
||||
for (id, picked) in corners {
|
||||
let Some(NodeKind::Path { path, live, .. }) = d.node_mut(id).map(|n| &mut n.kind) else { continue };
|
||||
// A cut changes the anchors, which is all a segment selection goes by.
|
||||
sel.segments.remove(&id);
|
||||
recut(path, live, sel.anchors.get_mut(&id), &picked, Some(radius), kind);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn corner_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.cornerSet";
|
||||
let radius = number_or(p, "radius", f64::NAN, C)?;
|
||||
if !(0.0..=MAX_RADIUS).contains(&radius) {
|
||||
return Err(bad(C, "`radius` must be from 0 to 1000000 pt"));
|
||||
}
|
||||
let kind = p
|
||||
.get("kind")
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| {
|
||||
serde_json::from_value::<CornerKind>(v.clone())
|
||||
.map_err(|_| bad(C, format!("`kind` must be \"round\", \"invertedRound\" or \"chamfer\", not {v}")))
|
||||
})
|
||||
.transpose()?;
|
||||
let corners = named_corners(s, p, C)?;
|
||||
edit_corners(s, "Set Corners", corners, radius, kind)
|
||||
}
|
||||
|
||||
fn corner_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let corners = named_corners(s, p, "addon.pathtools.cornerRemove")?;
|
||||
edit_corners(s, "Remove Corners", corners, 0.0, Some(CornerKind::Round))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Path Effects (`addon.path.*`) through the session: live on the canvas, the document's path left
|
||||
//! alone, and Expand Appearance and a save keeping what they draw.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Effect, Node, NodeId};
|
||||
use vectorcraft_geom::{BezPath, Rect};
|
||||
use vectorcraft_render::Renderer;
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_bez, effect_catalog, new_effect, preview, scale_effect};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A black 4 pt stroke along `anchors`, unfilled.
|
||||
fn stroked(anchors: Value, closed: bool) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let id = NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap());
|
||||
s.select(|_, sel| sel.set([id])).unwrap();
|
||||
s.execute("paint.setFill", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn wave() -> (Session, NodeId) {
|
||||
let anchors = json!([
|
||||
{"x": 20, "y": 100},
|
||||
{"x": 60, "y": 60, "in": [40, 60], "out": [80, 60]},
|
||||
{"x": 100, "y": 100},
|
||||
{"x": 140, "y": 140},
|
||||
{"x": 180, "y": 100}
|
||||
]);
|
||||
stroked(anchors, false)
|
||||
}
|
||||
|
||||
/// Every Path Effect, with parameters that change the wave.
|
||||
fn effects() -> Vec<(&'static str, Value)> {
|
||||
vec![
|
||||
("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "dash2": 3, "gap2": 6})),
|
||||
("addon.path.dashes", json!({"dash1": 14, "gap1": 6, "alignCorners": true})),
|
||||
("addon.path.addAnchors", json!({"count": 3})),
|
||||
("addon.path.addAnchors", json!({"spacing": "distance", "distance": 15, "target": "curved"})),
|
||||
("addon.path.extend", json!({"startLength": 10, "endPercent": 10, "endShape": "arc"})),
|
||||
("addon.path.extend", json!({"startLength": -10, "endLength": -20})),
|
||||
("addon.path.close", json!({})),
|
||||
("addon.path.close", json!({"mode": "smooth"})),
|
||||
("addon.path.smooth", json!({"ratio": 0.3})),
|
||||
("addon.path.retract", json!({})),
|
||||
("addon.path.reverse", json!({})),
|
||||
("addon.path.simplify", json!({"tolerance": 200, "protectCorners": false})),
|
||||
("addon.path.split", json!({"anchors": "both"})),
|
||||
]
|
||||
}
|
||||
|
||||
fn node(s: &Session, id: NodeId) -> Node {
|
||||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
fn picture_of(d: &Document) -> Vec<u8> {
|
||||
let r = Renderer::new().render_region(d, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true);
|
||||
(0..200).flat_map(|y| (0..200).map(move |x| (x, y))).flat_map(|(x, y)| r.pixel(x, y)).collect()
|
||||
}
|
||||
|
||||
fn picture(s: &Session) -> Vec<u8> {
|
||||
picture_of(&s.doc().unwrap().doc)
|
||||
}
|
||||
|
||||
/// The outline of `n`: its path, or those of the compound path it became.
|
||||
fn outline(n: &Node) -> BezPath {
|
||||
let mut out = n.path_data().map(|p| p.to_bezpath()).unwrap_or_default();
|
||||
n.children().into_iter().flatten().for_each(|c| out.extend(outline(c)));
|
||||
out
|
||||
}
|
||||
|
||||
/// How many bytes of two pictures differ by more than antialiasing noise.
|
||||
fn differing(a: &[u8], b: &[u8]) -> usize {
|
||||
a.iter().zip(b).filter(|(x, y)| x.abs_diff(**y) > 8).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_effect_draws_live_and_leaves_the_document_path() {
|
||||
let (mut s, id) = stroked(json!([{"x": 10, "y": 50}, {"x": 190, "y": 50}]), false);
|
||||
let solid = picture(&s);
|
||||
let path = node(&s, id).path_data().cloned();
|
||||
s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 20, "gap1": 20}})).unwrap();
|
||||
assert_eq!(node(&s, id).path_data().cloned(), path, "only the effect holds the geometry");
|
||||
let dashed = picture(&s);
|
||||
let at = |pic: &[u8], x: usize| pic[(50 * 200 + x) * 4];
|
||||
assert!(at(&solid, 45) < 40 && at(&dashed, 45) > 200, "a gap: {}", at(&dashed, 45));
|
||||
assert!(at(&dashed, 15) < 40, "a dash");
|
||||
// Hiding it draws the path again.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "visible": false})).unwrap();
|
||||
assert_eq!(picture(&s), solid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_effect_moves_the_bounds_the_object_is_measured_by() {
|
||||
let (mut s, id) = stroked(json!([{"x": 50, "y": 50}, {"x": 150, "y": 50}]), false);
|
||||
s.execute("effect.apply", &json!({"effect": "addon.path.extend", "params": {"startLength": 0, "endLength": 30}})).unwrap();
|
||||
let drawn = preview::bounds_of(&s.doc().unwrap().doc, &[id], true).unwrap();
|
||||
assert!((drawn.x1 - 180.0).abs() < 3.0, "{drawn:?}");
|
||||
let pic = picture(&s);
|
||||
assert!(pic[(50 * 200 + 170) * 4] < 40, "and draws there");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_appearance_writes_the_outline_the_effect_draws() {
|
||||
for (effect, params) in effects() {
|
||||
let (mut s, id) = wave();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap();
|
||||
let n = node(&s, id);
|
||||
let before = n.path_data().cloned().unwrap();
|
||||
let live = apply_geometry_bez(&n.appearance.effects, &before.to_bezpath(), before.bounds().unwrap(), &GeomContext::of(&n));
|
||||
let pic = picture(&s);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let expanded = node(&s, id);
|
||||
assert!(expanded.appearance.effects.is_empty(), "{effect} {params}");
|
||||
let (out, want) = (outline(&expanded), live);
|
||||
assert_eq!(out.elements().len(), want.elements().len(), "{effect} {params}");
|
||||
for (a, b) in out.elements().iter().zip(want.elements()) {
|
||||
assert!(a.is_finite() && format!("{a:.4?}") == format!("{b:.4?}"), "{effect} {params}: {a:?} {b:?}");
|
||||
}
|
||||
assert_eq!(differing(&pic, &picture(&s)), 0, "{effect} {params}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_effects_round_trip_through_a_save_and_scale_their_distances() {
|
||||
for (effect, params) in effects() {
|
||||
let (mut s, id) = wave();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap();
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects, node(&s, id).appearance.effects, "{effect} {params}");
|
||||
assert_eq!(picture_of(&restored), picture(&s), "{effect} {params}");
|
||||
}
|
||||
let mut e = new_effect("addon.path.dashes", &json!({"dash1": 10})).unwrap();
|
||||
scale_effect(&mut e, 2.0);
|
||||
assert_eq!((e.params["dash1"].as_f64(), e.params["gap1"].as_f64()), (Some(20.0), Some(12.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_takes_a_path_effect_off_in_one_step() {
|
||||
let (mut s, id) = wave();
|
||||
s.execute("effect.apply", &json!({"effect": "addon.path.reverse"})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects.len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// A file's effects skip the checks `effect.apply` makes: whatever they hold, the object draws and
|
||||
/// expands.
|
||||
#[test]
|
||||
fn path_effects_with_junk_params_from_a_file_draw_and_expand() {
|
||||
let junk = [
|
||||
json!(null),
|
||||
json!("x"),
|
||||
json!({"dash1": 1e308, "gap1": -1e308, "offset": "x", "alignCorners": 3}),
|
||||
json!({"count": 1e308, "maxCount": 1e308, "distance": -1e308, "spacing": "distance", "target": []}),
|
||||
json!({"startLength": 1e308, "endPercent": -1e308, "endShape": {}, "mode": 1, "ratio": "x", "handles": "shorter", "length": 1e308}),
|
||||
json!({"tolerance": 1e308, "protectCorners": "no", "anchors": null, "startPercent": 1e308, "startShape": "arc"}),
|
||||
];
|
||||
let ids = effect_catalog().into_iter().map(|e| e.id).filter(|id| id.starts_with("addon.path."));
|
||||
let stack: Vec<Effect> =
|
||||
ids.flat_map(|id| junk.iter().map(move |params| Effect { id: id.into(), params: params.clone(), visible: true })).collect();
|
||||
let (mut s, id) = wave();
|
||||
s.edit("Junk", |d, _| {
|
||||
if let Some(n) = d.node_mut(id) {
|
||||
n.appearance.effects = stack;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let saved = vectorcraft_format::save(&s.doc().unwrap().doc, false);
|
||||
picture_of(&vectorcraft_format::load(&saved).unwrap());
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert!(node(&s, id).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_write_the_dashes_as_paths() {
|
||||
let (mut s, _) = wave();
|
||||
s.execute("effect.apply", &json!({"effect": "addon.path.dashes", "params": {"dash1": 10, "gap1": 10}})).unwrap();
|
||||
let v = s.execute("document.export", &json!({"format": "svg"})).unwrap();
|
||||
let svg = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let moves = svg.iter().filter(|c| **c == b'M').count();
|
||||
assert!(moves > 5, "the exported art holds the dashes: {moves}");
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: with that option on, the
|
||||
//! add-on's own commands that move an anchor (Slide Anchor Along Path, Extend Path, Set Anchor) take
|
||||
//! the anchors coincident with it along.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
|
||||
use vectorcraft_geom::{PathData, Point, Vec2};
|
||||
use vectorcraft_pathops::addons::pathtools as pt;
|
||||
use vectorcraft_tools::addons::shared_edge::{DIAMETER, DIAMETER_RANGE, ID};
|
||||
|
||||
use super::super::super::{CommandSpec, always, bad, checked_id, cmd, finite_numbers, has_doc, number_or, switch_or, text_param};
|
||||
use super::{edit_whole, set_path};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"addon.pathtools.mergeEdge",
|
||||
"Merge Shared Edge",
|
||||
[],
|
||||
None,
|
||||
"{a, b: two path ids (they needn't be selected), at: [x, y] (where), diameter?: pt 0.25..200 (20; how near `at` the edge is looked for), tolerance?: pt 0..3 (0.002; how near two anchors count as coincident), follow?: \"average\" (half way between the two)|\"a\"|\"b\" (keep that path's outline), ends?: bool (false: true makes an end even where the paths could merge)} where the paths touch at two coincident anchors, one each side of `at`, replace the stretches between them with one shared edge; stretches much longer one than the other (by over 10%) or leaving an end at over 120° aren't merged. Otherwise make an end there: two near anchors meet, and an anchor only one path has there is matched by a new one on the other. Each path stays closed or open; one undo step → {a, b, made: \"edge\"|\"end\"|\"nothing\" (merged already)}",
|
||||
has_doc,
|
||||
merge_edge
|
||||
),
|
||||
cmd!(
|
||||
"addon.pathtools.movePointsTogether",
|
||||
"Keep Shared Points Together",
|
||||
[],
|
||||
None,
|
||||
"{enabled?: bool (default: the opposite of now)} Keep Shared Points Together: whether anchors coincident (within the Shared Edge Merge Tool's `tolerance`) with one that Slide Anchor Along Path, Extend Path or Set Anchor moves move with it, on any editable path (the `together` option of tool `sharedEdge`, kept with the preferences; off by default) → {enabled}",
|
||||
always,
|
||||
move_points_together
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// A path a merge may edit.
|
||||
fn merge_path<'a>(d: &'a Document, id: NodeId, cmd: &str) -> Result<&'a PathData> {
|
||||
if !d.is_editable(id) {
|
||||
return Err(bad(cmd, "the path is locked or hidden"));
|
||||
}
|
||||
match d.node(id).map(|n| &n.kind) {
|
||||
Some(NodeKind::Path { path, guide: false, .. }) => Ok(path),
|
||||
_ => Err(bad(cmd, "`a` and `b` must be two different paths")),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.mergeEdge";
|
||||
let a = checked_id(s, p.get("a").unwrap_or(&Value::Null), C)?;
|
||||
let b = checked_id(s, p.get("b").unwrap_or(&Value::Null), C)?;
|
||||
if a == b {
|
||||
return Err(bad(C, "`a` and `b` must be two different paths"));
|
||||
}
|
||||
let [x, y] = p.get("at").and_then(finite_numbers::<2>).ok_or_else(|| bad(C, "`at` must be [x, y]"))?;
|
||||
let at = Point::new(x, y);
|
||||
let diameter = number_or(p, "diameter", DIAMETER, C)?;
|
||||
if !(DIAMETER_RANGE.0..=DIAMETER_RANGE.1).contains(&diameter) {
|
||||
return Err(bad(C, "`diameter` must be from 0.25 to 200 pt"));
|
||||
}
|
||||
let tolerance = number_or(p, "tolerance", pt::COINCIDENCE, C)?;
|
||||
if !(0.0..=pt::MAX_COINCIDENCE).contains(&tolerance) {
|
||||
return Err(bad(C, "`tolerance` must be from 0 to 3 pt"));
|
||||
}
|
||||
let follow = match text_param(p, "follow", C)? {
|
||||
None => pt::Follow::Average,
|
||||
Some(f) => pt::Follow::parse(f).ok_or_else(|| bad(C, "`follow` must be \"average\", \"a\" or \"b\""))?,
|
||||
};
|
||||
let ends = switch_or(p, "ends", false, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let (pa, pb) = (merge_path(d, a, C)?, merge_path(d, b, C)?);
|
||||
let radius = diameter / 2.0;
|
||||
let edge = if ends { None } else { pt::stretches_at(pa, pb, at, radius, tolerance) };
|
||||
let plan = edge.or_else(|| pt::ends_at(pa, pb, at, radius)).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
|
||||
let (na, nb) = pt::apply_merge(pa, pb, &plan, follow).ok_or_else(|| bad(C, "the paths have nothing to merge there"))?;
|
||||
let changed: Vec<(NodeId, PathData)> =
|
||||
[(a, pa, na), (b, pb, nb)].into_iter().filter(|(_, old, new)| *old != new).map(|(id, _, new)| (id, new)).collect();
|
||||
let made = match plan {
|
||||
_ if changed.is_empty() => "nothing",
|
||||
pt::MergePlan::Edge { .. } => "edge",
|
||||
pt::MergePlan::Ends { .. } => "end",
|
||||
};
|
||||
edit_whole(s, "Merge Shared Edge", changed)?;
|
||||
Ok(json!({ "a": a.0, "b": b.0, "made": made }))
|
||||
}
|
||||
|
||||
fn move_points_together(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.movePointsTogether";
|
||||
let now = s.tool_options_of(ID)["together"].as_bool().unwrap_or(false);
|
||||
let enabled = switch_or(p, "enabled", !now, C)?;
|
||||
s.set_tool_options(Some(ID), &Map::from_iter([("together".to_string(), json!(enabled))]));
|
||||
Ok(json!({ "enabled": enabled }))
|
||||
}
|
||||
|
||||
/// Anchors to move along with others: each with how far.
|
||||
pub(super) type SharedPoints = Vec<(NodeId, AnchorRef, Vec2)>;
|
||||
|
||||
/// With Keep Shared Points Together on, the anchors of editable paths coincident with an anchor
|
||||
/// that `changed` (paths in their new form) moves, and not moving themselves. Subpaths whose
|
||||
/// anchors `changed` adds or removes move none: their anchors stay put.
|
||||
pub(super) fn shared_points<'a>(s: &Session, changed: impl IntoIterator<Item = (NodeId, &'a PathData)>) -> Result<SharedPoints> {
|
||||
let options = s.tool_options_of(ID);
|
||||
if options["together"].as_bool() != Some(true) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let tol = options["tolerance"].as_f64().unwrap_or(pt::COINCIDENCE);
|
||||
let d = &s.doc()?.doc;
|
||||
let mut moved: BTreeSet<(NodeId, AnchorRef)> = BTreeSet::new();
|
||||
let mut from: Vec<(Point, Vec2)> = vec![];
|
||||
for (id, new) in changed {
|
||||
let Some(old) = d.node(id).and_then(|n| n.path_data()) else { continue };
|
||||
for (si, (o, n)) in old.subpaths.iter().zip(&new.subpaths).enumerate().filter(|(_, (o, n))| o.anchors.len() == n.anchors.len()) {
|
||||
for (ai, (x, y)) in o.anchors.iter().zip(&n.anchors).enumerate().filter(|(_, (x, y))| x.p != y.p) {
|
||||
moved.insert((id, (si, ai)));
|
||||
from.push((x.p, y.p - x.p));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
if from.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
from.sort_by(|a, b| a.0.x.total_cmp(&b.0.x));
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
let NodeKind::Path { path, guide: false, .. } = &n.kind else { return };
|
||||
if !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
for (si, ai, a) in path.anchors() {
|
||||
let start = from.partition_point(|(p, _)| p.x < a.p.x - tol);
|
||||
let mut near = from.iter().skip(start).take_while(|(p, _)| p.x <= a.p.x + tol);
|
||||
if let Some((_, by)) = near.find(|(p, _)| p.distance(a.p) <= tol)
|
||||
&& !moved.contains(&(n.id, (si, ai)))
|
||||
{
|
||||
out.push((n.id, (si, ai), *by));
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Move the anchors [`shared_points`] found; what they are on stops being a live shape.
|
||||
pub(super) fn move_shared(d: &mut Document, shared: SharedPoints) {
|
||||
let mut by_path: BTreeMap<NodeId, Vec<(AnchorRef, Vec2)>> = BTreeMap::new();
|
||||
for (id, at, by) in shared {
|
||||
by_path.entry(id).or_default().push((at, by));
|
||||
}
|
||||
for (id, anchors) in by_path {
|
||||
let Some(mut np) = d.node(id).and_then(|n| n.path_data()).cloned() else { continue };
|
||||
for ((si, ai), by) in anchors {
|
||||
if let Some(a) = np.anchor_mut(si, ai) {
|
||||
a.translate(by);
|
||||
}
|
||||
}
|
||||
set_path(d, id, np);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! The Shared Edge Merge Tool's command and Keep Shared Points Together: one undo step each, bad
|
||||
//! params refused, the tool's click through the session, and only the add-on's commands affected.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_geom::{PathData, Point};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
|
||||
use crate::{Session, ViewInfo};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, pts: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = pts.iter().map(|(x, y)| json!({"x": x, "y": y})).collect();
|
||||
NodeId(s.execute("path.create", &json!({"anchors": anchors, "closed": closed})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn path(s: &Session, id: NodeId) -> PathData {
|
||||
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
|
||||
}
|
||||
|
||||
fn steps(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn points(s: &Session, id: NodeId) -> Vec<Point> {
|
||||
path(s, id).anchors().map(|(_, _, a)| a.p).collect()
|
||||
}
|
||||
|
||||
/// Two closed shapes with a bent edge each between (100, 100) and (200, 100).
|
||||
fn shapes(s: &mut Session) -> (NodeId, NodeId) {
|
||||
let a = line(s, &[(100.0, 100.0), (150.0, 104.0), (200.0, 100.0), (200.0, 50.0), (100.0, 50.0)], true);
|
||||
let b = line(s, &[(200.0, 100.0), (150.0, 96.0), (100.0, 100.0), (100.0, 150.0), (200.0, 150.0)], true);
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_replaces_both_stretches_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let (pa, pb) = (path(&s, a), path(&s, b));
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
|
||||
assert_eq!(r, json!({"a": a.0, "b": b.0, "made": "edge"}));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert!(path(&s, a).subpaths[0].closed && path(&s, b).subpaths[0].closed);
|
||||
assert_eq!(points(&s, a)[..3], [Point::new(100.0, 100.0), Point::new(150.0, 100.0), Point::new(200.0, 100.0)]);
|
||||
assert_eq!(points(&s, b)[..3], [Point::new(200.0, 100.0), Point::new(150.0, 100.0), Point::new(100.0, 100.0)]);
|
||||
// Nothing left to do: no step.
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100]})).unwrap();
|
||||
assert_eq!(r["made"], json!("nothing"));
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((path(&s, a), path(&s, b)), (pa, pb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_follows_the_path_asked_and_makes_ends() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
let pa = path(&s, a);
|
||||
s.execute("addon.pathtools.mergeEdge", &json!({"a": a.0, "b": b.0, "at": [125, 100], "follow": "a"})).unwrap();
|
||||
assert_eq!(path(&s, a), pa);
|
||||
assert_eq!(points(&s, b)[1], Point::new(150.0, 104.0));
|
||||
// Ends asked for: the near anchors of two lines meet, one undo step.
|
||||
let c = line(&mut s, &[(10.0, 200.0), (50.0, 200.0)], false);
|
||||
let d = line(&mut s, &[(54.0, 200.0), (90.0, 240.0)], false);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.mergeEdge", &json!({"a": c.0, "b": d.0, "at": [52, 200], "ends": true})).unwrap();
|
||||
assert_eq!((r["made"].clone(), steps(&s)), (json!("end"), n + 1));
|
||||
assert_eq!((points(&s, c)[1], points(&s, d)[0]), (Point::new(52.0, 200.0), Point::new(52.0, 200.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_edge_refuses_bad_params_and_locked_paths() {
|
||||
let mut s = session();
|
||||
let (a, b) = shapes(&mut s);
|
||||
let (i, j) = (a.0, b.0);
|
||||
let n = steps(&s);
|
||||
for p in [
|
||||
json!({}),
|
||||
json!({"a": i, "b": i, "at": [125, 100]}),
|
||||
json!({"a": i, "b": j}),
|
||||
json!({"a": i, "b": j, "at": [1]}),
|
||||
json!({"a": i, "b": j, "at": [1, "x"]}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "diameter": 0.1}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "diameter": 201}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "tolerance": 3.5}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "tolerance": -1}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "follow": "up"}),
|
||||
json!({"a": i, "b": j, "at": [125, 100], "ends": 1}),
|
||||
json!({"a": i, "b": u64::MAX, "at": [125, 100]}),
|
||||
json!({"a": i, "b": j, "at": [390, 290]}),
|
||||
] {
|
||||
assert!(s.execute("addon.pathtools.mergeEdge", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!(steps(&s), n);
|
||||
s.execute("select.set", &json!({"ids": [i]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
assert!(s.execute("addon.pathtools.mergeEdge", &json!({"a": i, "b": j, "at": [125, 100]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tool_merges_unselected_paths_on_a_click_with_shift_following_the_upper() {
|
||||
let mut s = session();
|
||||
let (lower, upper) = shapes(&mut s);
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("sharedEdge", view).unwrap();
|
||||
let keep = path(&s, upper);
|
||||
let n = steps(&s);
|
||||
let shifted = |kind| {
|
||||
let mut e = PointerEvent::new(kind, 125.0, 100.0);
|
||||
e.mods = Mods { shift: true, ..Mods::default() };
|
||||
e
|
||||
};
|
||||
s.pointer(&shifted(PointerKind::Down), view).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Up), view).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(path(&s, upper), keep);
|
||||
assert_eq!(points(&s, lower)[1], Point::new(150.0, 96.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_shared_points_together_is_off_by_default_and_a_preference_of_the_tool() {
|
||||
let mut s = session();
|
||||
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(false));
|
||||
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap(), json!({"enabled": true}));
|
||||
assert_eq!(s.tool_options_of("sharedEdge")["together"], json!(true), "kept with another tool active");
|
||||
assert_eq!(s.execute("addon.pathtools.movePointsTogether", &json!({})).unwrap(), json!({"enabled": false}));
|
||||
assert!(s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slide_takes_coincident_anchors_along_in_one_step_when_the_option_is_on() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
|
||||
let far = line(&mut s, &[(200.0, 101.0), (250.0, 200.0)], false);
|
||||
let slide = json!({"id": a.0, "anchor": 1, "t": 0.5});
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0), "off: it stays");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
let moved = points(&s, a)[1];
|
||||
assert!(moved.distance(Point::new(250.0, 100.0)) < 1e-6, "{moved:?}");
|
||||
assert!(points(&s, b)[0].distance(moved) < 1e-9);
|
||||
assert_eq!(points(&s, b)[1], Point::new(200.0, 200.0));
|
||||
assert_eq!(points(&s, far)[0], Point::new(200.0, 101.0), "not coincident");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
|
||||
// A wider coincidence reaches it, and a locked path stays.
|
||||
s.set_tool_options(Some("sharedEdge"), &serde_json::Map::from_iter([("tolerance".to_string(), json!(2))]));
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
s.execute("addon.pathtools.slideAnchor", &slide).unwrap();
|
||||
assert!(points(&s, far)[0].distance(Point::new(250.0, 101.0)) < 1e-6);
|
||||
assert_eq!(points(&s, b)[0], Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_anchor_and_extend_take_coincident_anchors_along_but_built_in_moves_do_not() {
|
||||
let mut s = session();
|
||||
let a = line(&mut s, &[(100.0, 100.0), (200.0, 100.0)], false);
|
||||
let b = line(&mut s, &[(200.0, 100.0), (200.0, 200.0)], false);
|
||||
s.execute("addon.pathtools.movePointsTogether", &json!({"enabled": true})).unwrap();
|
||||
// Set Anchor on one selected anchor.
|
||||
s.select(|_, sel| {
|
||||
sel.set([a]);
|
||||
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
|
||||
})
|
||||
.unwrap();
|
||||
let n = steps(&s);
|
||||
s.execute("addon.pathtools.setAnchor", &json!({"x": 220, "y": 90})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
assert_eq!(points(&s, b), [Point::new(220.0, 90.0), Point::new(200.0, 200.0)]);
|
||||
// An edit that moves no anchor moves none.
|
||||
s.execute("addon.pathtools.corner", &json!({})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], Point::new(220.0, 90.0));
|
||||
// Extend Path moves an end only when it adds no anchor.
|
||||
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10, "mode": "bezier"})).unwrap();
|
||||
let end = points(&s, a)[1];
|
||||
assert!(points(&s, b)[0].distance(end) < 1e-9);
|
||||
let end = points(&s, b)[0];
|
||||
s.execute("addon.pathtools.extend", &json!({"id": a.0, "end": "end", "length": 10})).unwrap();
|
||||
assert_eq!(points(&s, a).len(), 3);
|
||||
assert_eq!(points(&s, b)[0], end, "the end it left stays with what shares it");
|
||||
// The app's own moves are untouched by the option.
|
||||
s.select(|_, sel| {
|
||||
sel.set([a]);
|
||||
sel.anchors.insert(a, [(0, 1)].into_iter().collect());
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.move", &json!({"dx": 5, "dy": 5})).unwrap();
|
||||
assert_eq!(points(&s, b)[0], end);
|
||||
}
|
||||
@@ -4,7 +4,8 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::{LiveCorners, LiveShape, NodeId};
|
||||
use vectorcraft_geom::shapes::CornerKind;
|
||||
use vectorcraft_geom::{AnchorKind, PathData, Point};
|
||||
use vectorcraft_tools::{PointerEvent, PointerKind};
|
||||
|
||||
@@ -456,3 +457,220 @@ fn the_tool_draws_one_step_per_drag_and_arrows_change_the_count() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape_of(&s, id).map(|s| s.width), Some(120.0));
|
||||
}
|
||||
|
||||
// ---------- Corner Tool ----------
|
||||
|
||||
/// A square (100, 100)–(300, 300), plain and selected: anchors from the top left, clockwise.
|
||||
fn square(s: &mut Session) -> NodeId {
|
||||
let id = line(s, &[(100.0, 100.0), (300.0, 100.0), (300.0, 300.0), (100.0, 300.0)], true);
|
||||
select(s, &[id]);
|
||||
id
|
||||
}
|
||||
|
||||
fn live(s: &Session, id: NodeId) -> Option<LiveShape> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().live_shape().cloned()
|
||||
}
|
||||
|
||||
fn drawn(s: &Session, id: NodeId, corner: usize) -> f64 {
|
||||
LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap().drawn_radius(corner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_set_cuts_live_corners_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let plain = path(&s, id);
|
||||
let n = steps(&s);
|
||||
// The selected path: all four.
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
|
||||
assert_eq!(r, json!({"ids": [id.0], "corners": 4}));
|
||||
assert_eq!((steps(&s), path(&s, id).anchor_count()), (n + 1, 8));
|
||||
let lc = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).map(|c| (c.drawn_radius(3), c.kind(3))).unwrap();
|
||||
assert_eq!(lc, (20.0, CornerKind::Chamfer));
|
||||
// The same again changes nothing: no step.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 20, "kind": "chamfer"})).unwrap();
|
||||
assert_eq!(steps(&s), n + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((path(&s, id), live(&s, id)), (plain, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_set_goes_by_anchors_the_selection_and_items_and_fits() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let other = line(&mut s, &[(350.0, 10.0), (390.0, 10.0), (390.0, 50.0)], false);
|
||||
// Named anchors, of each path by `items`, at once; an open end takes none.
|
||||
let items = json!([{"id": id.0, "anchors": [1]}, {"id": other.0, "anchors": [0, 1]}]);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.cornerSet", &json!({"items": items, "radius": 5})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), steps(&s)), (json!(2), n + 1));
|
||||
assert_eq!((drawn(&s, id, 1), drawn(&s, id, 0), drawn(&s, other, 1)), (5.0, 0.0, 5.0));
|
||||
// `ids` with `anchors`, keeping the kind each has.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [1], "radius": 9})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 1), 9.0);
|
||||
// Direct-selected anchors: only their corners, which stay selected as they gain anchors.
|
||||
select_anchors(&mut s, id, &[0]);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"radius": 7})).unwrap();
|
||||
assert_eq!((drawn(&s, id, 0), drawn(&s, id, 2)), (7.0, 0.0));
|
||||
let sel = s.doc().unwrap().selection.anchors[&id].clone();
|
||||
let c = LiveCorners::of(s.doc().unwrap().doc.node(id).unwrap()).unwrap();
|
||||
assert_eq!(c.corners_of(&sel), BTreeSet::from([0]));
|
||||
// A radius that doesn't fit is drawn at what does.
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"ids": [id.0], "anchors": [2], "radius": 5000})).unwrap();
|
||||
assert!(drawn(&s, id, 2) <= 200.0 - 7.0 + 1e-9, "{}", drawn(&s, id, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_remove_makes_corners_sharp_and_a_path_plain_again() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let plain = path(&s, id);
|
||||
s.execute("addon.pathtools.cornerSet", &json!({"anchors": [0, 1], "radius": 10, "kind": "invertedRound"})).unwrap();
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.cornerRemove", &json!({"items": [{"id": id.0, "anchors": [0]}]})).unwrap();
|
||||
assert_eq!((r["corners"].clone(), steps(&s), drawn(&s, id, 0), drawn(&s, id, 1)), (json!(1), n + 1, 0.0, 10.0));
|
||||
s.execute("addon.pathtools.cornerRemove", &json!({})).unwrap();
|
||||
assert_eq!((steps(&s), path(&s, id), live(&s, id)), (n + 2, plain, None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 1), 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_commands_refuse_bad_params() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let smooth = circle(&mut s);
|
||||
let i = id.0;
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.cornerSet",
|
||||
&[
|
||||
json!({"ids": [i]}),
|
||||
json!({"ids": [i], "radius": -1}),
|
||||
json!({"ids": [i], "radius": 1e308}),
|
||||
json!({"ids": [i], "radius": "big"}),
|
||||
json!({"ids": [i], "radius": 5, "kind": "spiky"}),
|
||||
json!({"ids": [i], "anchors": "all", "radius": 5}),
|
||||
json!({"ids": [i], "anchors": [-1], "radius": 5}),
|
||||
json!({"ids": [i], "anchors": [9], "radius": 5}),
|
||||
json!({"ids": [u64::MAX], "radius": 5}),
|
||||
json!({"items": 3, "radius": 5}),
|
||||
json!({"items": [{"anchors": [0]}], "radius": 5}),
|
||||
json!({"ids": [smooth.0], "radius": 5}),
|
||||
],
|
||||
);
|
||||
refuses(&mut s, "addon.pathtools.cornerRemove", &[json!({"anchors": [9], "ids": [i]}), json!({"ids": [smooth.0]})]);
|
||||
// Locked: left alone.
|
||||
s.execute("object.lock", &json!({"ids": [i]})).ok();
|
||||
if !s.doc().unwrap().doc.is_editable(id) {
|
||||
refuses(&mut s, "addon.pathtools.cornerSet", &[json!({"items": [{"id": i}], "radius": 5})]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_tool_clicks_drags_and_removes_in_one_step_each() {
|
||||
let mut s = session();
|
||||
let id = square(&mut s);
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("cornerTool", view).unwrap();
|
||||
s.set_tool_option("radius", &json!(15));
|
||||
let n = steps(&s);
|
||||
let press = |s: &mut Session, k, x, y| s.pointer(&PointerEvent::new(k, x, y), view).unwrap();
|
||||
// A click on the top right anchor.
|
||||
press(&mut s, PointerKind::Down, 300.0, 100.0);
|
||||
press(&mut s, PointerKind::Up, 300.0, 100.0);
|
||||
assert_eq!((steps(&s), drawn(&s, id, 1)), (n + 1, 15.0));
|
||||
// A drag from the bottom right anchor, previewed, one step.
|
||||
press(&mut s, PointerKind::Down, 300.0, 300.0);
|
||||
press(&mut s, PointerKind::Drag, 280.0, 280.0);
|
||||
assert_eq!(drawn(&s, id, 2), 20.0, "the preview shows");
|
||||
press(&mut s, PointerKind::Drag, 270.0, 270.0);
|
||||
press(&mut s, PointerKind::Up, 270.0, 270.0);
|
||||
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 2, 30.0));
|
||||
// A drag on the marker resizes; a double-click on it removes.
|
||||
press(&mut s, PointerKind::Down, 270.0, 270.0);
|
||||
press(&mut s, PointerKind::Drag, 260.0, 260.0);
|
||||
press(&mut s, PointerKind::Up, 260.0, 260.0);
|
||||
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 3, 40.0));
|
||||
press(&mut s, PointerKind::DoubleClick, 260.0, 260.0);
|
||||
assert_eq!((steps(&s), drawn(&s, id, 2)), (n + 4, 0.0));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(drawn(&s, id, 2), 40.0);
|
||||
// A click on a marker selects the corner and loads its values.
|
||||
press(&mut s, PointerKind::Down, 285.0, 115.0);
|
||||
press(&mut s, PointerKind::Up, 285.0, 115.0);
|
||||
assert_eq!(steps(&s), n + 3, "selecting is no edit");
|
||||
assert_eq!(s.doc().unwrap().selection.anchors.get(&id).map(|a| a.len()), Some(2));
|
||||
// The bottom right one, resized by its marker, loads its radius.
|
||||
press(&mut s, PointerKind::Down, 260.0, 260.0);
|
||||
press(&mut s, PointerKind::Up, 260.0, 260.0);
|
||||
assert_eq!(s.tool_options_of("cornerTool")["radius"], json!(40.0));
|
||||
// An Alt-marquee over the left side: both anchors, in one step.
|
||||
let alt = vectorcraft_tools::Mods { alt: true, ..Default::default() };
|
||||
for (k, x, y) in [(PointerKind::Down, 50.0, 50.0), (PointerKind::Drag, 150.0, 350.0), (PointerKind::Up, 150.0, 350.0)] {
|
||||
s.pointer(&PointerEvent::new(k, x, y).with_mods(alt), view).unwrap();
|
||||
}
|
||||
assert_eq!((steps(&s), drawn(&s, id, 0), drawn(&s, id, 3)), (n + 4, 40.0, 40.0));
|
||||
}
|
||||
|
||||
// ---------- Slide Anchor Along Path ----------
|
||||
|
||||
#[test]
|
||||
fn slide_anchor_moves_it_along_the_path_as_one_undo_step() {
|
||||
let mut s = session();
|
||||
let e = circle(&mut s);
|
||||
let before = path(&s, e);
|
||||
let area = kurbo::Shape::area(&before.to_bezpath()).abs();
|
||||
let want = before.subpaths[0].segment(3);
|
||||
let n = steps(&s);
|
||||
let r = s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0.5})).unwrap();
|
||||
assert!(r["strayed"].as_f64().unwrap() < 0.05, "{r}");
|
||||
let after = path(&s, e);
|
||||
assert_eq!((steps(&s), after.anchor_count()), (n + 1, 16));
|
||||
assert!(after.subpaths[0].anchors[3].p.distance(kurbo::ParamCurve::eval(&want, 0.5)) < 1e-9);
|
||||
assert!((kurbo::Shape::area(&after.to_bezpath()).abs() / area - 1.0).abs() < 1e-3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(path(&s, e), before);
|
||||
// No move, no step.
|
||||
s.execute("addon.pathtools.slideAnchor", &json!({"id": e.0, "anchor": 3, "t": 0})).unwrap();
|
||||
assert_eq!(steps(&s), n);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_anchor_refuses_bad_params() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0)], false);
|
||||
let i = l.0;
|
||||
refuses(
|
||||
&mut s,
|
||||
"addon.pathtools.slideAnchor",
|
||||
&[
|
||||
json!({"id": i, "anchor": 0, "t": 0.5}),
|
||||
json!({"id": i, "anchor": 2, "t": -0.5}),
|
||||
json!({"id": i, "anchor": 9, "t": 0.5}),
|
||||
json!({"id": i, "anchor": 1, "t": 1.5}),
|
||||
json!({"id": i, "anchor": 1}),
|
||||
json!({"id": i, "t": 0.5}),
|
||||
json!({"id": i, "subpath": 4, "anchor": 1, "t": 0.5}),
|
||||
json!({"id": u64::MAX, "anchor": 1, "t": 0.5}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slide_tool_previews_and_commits_one_step() {
|
||||
let mut s = session();
|
||||
let l = line(&mut s, &[(100.0, 100.0), (200.0, 100.0), (300.0, 100.0)], false);
|
||||
let view = ViewInfo { smart_guides: false, ..ViewInfo::default() };
|
||||
s.select_tool("slideAnchor", view).unwrap();
|
||||
let n = steps(&s);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 100.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 120.0), view).unwrap();
|
||||
let at = |s: &Session| path(s, l).subpaths[0].anchors[1].p;
|
||||
assert!(at(&s).distance(Point::new(250.0, 100.0)) < 1e-3, "the preview shows");
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, 280.0, 90.0), view).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 280.0, 90.0), view).unwrap();
|
||||
assert!(steps(&s) == n + 1 && at(&s).distance(Point::new(280.0, 100.0)) < 1e-3);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(at(&s), Point::new(200.0, 100.0));
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -840,11 +840,23 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "appearance.copyFrom";
|
||||
let clicked = id_param(p, "source").ok_or_else(|| bad(C, "missing `source` id"))?;
|
||||
let (reverse, append) = (switch_or(p, "reverse", false, C)?, switch_or(p, "append", false, C)?);
|
||||
let defaults_only = switch_or(p, "defaultsOnly", false, C)?;
|
||||
if defaults_only && (reverse || append || p.get("ids").is_some()) {
|
||||
return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`"));
|
||||
}
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
let range = if !reverse && !append && p.get("ids").is_none() { super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b) } else { None };
|
||||
let selected = match ids_param(p, "ids") {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() {
|
||||
super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let selected = if defaults_only {
|
||||
vec![]
|
||||
} else {
|
||||
match ids_param(p, "ids") {
|
||||
Some(v) => outermost(&s.doc()?.doc, v),
|
||||
None => selected_roots(s)?,
|
||||
}
|
||||
};
|
||||
// Reversed, the first selected object's attributes go onto the clicked object; with nothing
|
||||
// selected, what the Eyedropper picked up last does (#4382).
|
||||
@@ -864,7 +876,7 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.eyedropper_sample.clone().ok_or_else(|| bad(C, "select the object to copy from"))?,
|
||||
};
|
||||
let picked = Picked::of(&node);
|
||||
if !reverse {
|
||||
if !reverse && !defaults_only {
|
||||
s.eyedropper_sample = Some(node);
|
||||
}
|
||||
let range = range.filter(|(id, _, _)| targets.contains(id));
|
||||
|
||||
@@ -222,18 +222,16 @@ fn trace_params(s: &Session, c: &str, p: &Value) -> Result<tr::TraceParams> {
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
/// Document swatch `w`'s solid color with the paint a trace fills with it: a global or spot color,
|
||||
/// or a tint of one, linked to its swatch as `swatch_solid` (paint.rs) links it; any other color
|
||||
/// unlinked. The color is the one the paint shows (a Lab spot color's CMYK equivalent when the Spot
|
||||
/// Colors options use CMYK values).
|
||||
/// Document swatch `w`'s displayed solid color and unlinked trace paint. A tint uses its tinted
|
||||
/// color; a Lab spot color uses its CMYK equivalent when the Spot Colors options use CMYK values.
|
||||
fn trace_swatch_paint(d: &Document, w: &Swatch) -> Option<(Color, Paint)> {
|
||||
let linked = d.swatch_link(&w.name).and_then(|(base, tint)| d.tint_paint(&base, tint));
|
||||
let color = linked.as_ref().and_then(Paint::color).or_else(|| w.paint.color())?;
|
||||
Some((color, linked.unwrap_or_else(|| Paint::solid(color))))
|
||||
Some((color, Paint::solid(color)))
|
||||
}
|
||||
|
||||
/// Document Library with `colorGroup`: the solid colors of the active document's color group
|
||||
/// `name` (so named exactly, else ignoring case), each with its paint ([`trace_swatch_paint`]).
|
||||
/// `name` (so named exactly, else ignoring case), each with its unlinked paint ([`trace_swatch_paint`]).
|
||||
/// Errors name command `c`. ([`trace_params`] has checked that the library is the document's
|
||||
/// swatches.)
|
||||
fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result<Vec<(Color, Paint)>> {
|
||||
@@ -249,9 +247,7 @@ fn trace_group_colors(c: &str, s: &Session, name: &str) -> Result<Vec<(Color, Pa
|
||||
}
|
||||
|
||||
/// Document Library in Color mode: look the library's colors (or its color group's) up into
|
||||
/// `params.swatches` → each one as traced (sRGB) with the paint to fill with. The document's
|
||||
/// global and spot colors fill linked to their swatches ([`trace_swatch_paint`]); a swatch
|
||||
/// library's colors fill unlinked. Errors name command `c`.
|
||||
/// `params.swatches` → each one as traced (sRGB) with its unlinked paint. Errors name command `c`.
|
||||
fn trace_library(c: &str, s: &Session, params: &mut tr::TraceParams) -> Result<Vec<([u8; 3], Paint)>> {
|
||||
if params.mode != tr::Mode::Color || params.palette != tr::Palette::DocumentLibrary {
|
||||
return Ok(vec![]);
|
||||
@@ -559,15 +555,11 @@ pub(super) fn trace_make(s: &mut Session, p: &Value, expand: bool) -> Result<Val
|
||||
let unit = xf.determinant().abs().sqrt();
|
||||
let (npaths, anchors, colors) = (res.paths.len(), res.anchor_count(), res.palette.len());
|
||||
let label = if expand { "Image Trace (Make and Expand)" } else { "Image Trace" };
|
||||
// A library color fills with the swatch's own color (its model kept), linked to a
|
||||
// global or spot swatch of the document. Of the swatches that trace as this color,
|
||||
// the first linked one is taken, else the first. Build the lookup once per trace.
|
||||
// A library color fills with the swatch's displayed color (its model kept), unlinked.
|
||||
// Keep the first swatch that traces as a given color. Build the lookup once per trace.
|
||||
let mut by_color: HashMap<[u8; 3], &Paint> = HashMap::new();
|
||||
for (rgb, paint) in &library {
|
||||
let chosen = by_color.entry(*rgb).or_insert(paint);
|
||||
if !matches!(**chosen, Paint::Solid { swatch: Some(_), .. }) && matches!(paint, Paint::Solid { swatch: Some(_), .. }) {
|
||||
*chosen = paint;
|
||||
}
|
||||
by_color.entry(*rgb).or_insert(paint);
|
||||
}
|
||||
let id = s.edit(label, |d, sel| {
|
||||
let src = d.node(target).cloned().ok_or(EngineError::NoNode(target))?;
|
||||
|
||||
@@ -444,10 +444,10 @@ fn expand_pattern_fills(s: &mut Session) -> Result<()> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand the pattern fills under `root` that paint a transparent image into the tiles they paint
|
||||
/// Expand the pattern fills under `root` that paint a transparent or colorized image into the tiles they paint
|
||||
/// ([`expand_patterns_where`]), so each tile image becomes an object of its own; the other
|
||||
/// pattern fills stay. A tile image counts as
|
||||
/// transparent when its key is in `see_through` (it has see-through pixels), or when it, a group
|
||||
/// pattern fills stay. A tile image counts when its key is in `see_through` (it has see-through
|
||||
/// pixels), when it has a colorize paint, or when it, a group
|
||||
/// in the tile art, the fill, the shape or a group the shape is in has opacity or a blending
|
||||
/// mode. Returns how many shapes changed.
|
||||
pub(crate) fn expand_transparent_image_patterns(d: &mut Document, root: NodeId, see_through: &BTreeSet<String>) -> Result<usize> {
|
||||
@@ -473,12 +473,12 @@ fn blended(n: &Node) -> bool {
|
||||
n.opacity < 1.0 || n.blend != BlendMode::Normal
|
||||
}
|
||||
|
||||
/// Whether `n` is or holds a transparent image: one with see-through pixels (key in `keys`), or
|
||||
/// with opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`.
|
||||
/// Whether `n` is or holds an image with see-through pixels (key in `keys`), a colorize paint,
|
||||
/// opacity or a blending mode of its own or of a group in `n`, or anywhere when `on`.
|
||||
fn transparent_image(n: &Node, keys: &BTreeSet<String>, on: bool) -> bool {
|
||||
let on = on || blended(n);
|
||||
match &n.kind {
|
||||
NodeKind::Image(im) => on || keys.contains(&im.key),
|
||||
NodeKind::Image(im) => on || keys.contains(&im.key) || n.extras().colorize.is_some(),
|
||||
_ => n.children().is_some_and(|ch| ch.iter().any(|c| transparent_image(c, keys, on))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,9 +161,8 @@ fn expand_appearances(d: &mut Document) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand every layer's pattern fills that paint a transparent image into their tiles, so those
|
||||
/// images flatten like any other transparent image. The writer would otherwise draw them opaque
|
||||
/// over white.
|
||||
/// Expand every layer's pattern fills that paint an image with see-through pixels, a colorize paint,
|
||||
/// opacity or blending into their tiles, so those images can be flattened.
|
||||
fn expand_image_patterns(d: &mut Document) -> Result<()> {
|
||||
let keys = super::super::flatten::see_through_images(d);
|
||||
let layers: Vec<NodeId> = d.layers.iter().map(|l| l.id).collect();
|
||||
|
||||
@@ -56,6 +56,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_outside_isolation,
|
||||
|s, _| other_layers(s, true)
|
||||
),
|
||||
cmd!(
|
||||
"object.lock.unselected",
|
||||
"Unselected Artwork",
|
||||
["Object", "Lock"],
|
||||
Some("Cmd+Alt+Shift+2"),
|
||||
"{} lock unselected artwork in one undo step, including unselected members of partly selected groups, clipping groups and compound paths, and guides while Lock Guides is on; hidden art and art on locked layers stay unlocked; a selected layer's art counts as selected; in isolation, only art inside the isolated object is affected; object.unlockAll unlocks locked art document-wide → {count}",
|
||||
has_selection,
|
||||
|s, _| unselected_art(s, true)
|
||||
),
|
||||
cmd!(
|
||||
"object.hide.above",
|
||||
"All Artwork Above",
|
||||
@@ -81,7 +90,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some("Cmd+Alt+Shift+3"),
|
||||
"{} hide, in one undo step, every object select.all takes that is not selected and holds no selected object; in a group that holds one it hides the other members one by one, and a clip group, compound path or other object that holds one stays as it is; the objects of a selected layer count as selected; while Lock Guides is on, guide paths count as selected and stay as they are; object.showAll shows them again; disabled in isolation mode → {count}",
|
||||
has_selection_outside_isolation,
|
||||
hide_unselected
|
||||
|s, _| unselected_art(s, false)
|
||||
),
|
||||
cmd!(
|
||||
"object.transformEach",
|
||||
@@ -321,15 +330,24 @@ fn other_layers(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
/// The objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and hold none of
|
||||
/// them. The objects of a selected layer count as selected. Guide paths are left out while `guides_locked`, and so are
|
||||
/// the objects that hold one. The walk goes into a plain group that holds a selected object or such a guide path; a
|
||||
/// clip group, a Shaper group, a compound path and every other object that holds one is left out with its members.
|
||||
fn unselected(d: &vectorcraft_doc::Document, iso: Option<NodeId>, selection: &[NodeId], roots: &[NodeId], guides_locked: bool) -> Vec<NodeId> {
|
||||
/// Returns the objects [`super::select::walk_selectable`] visits that are not among the selected objects `roots` and
|
||||
/// hold none of them. A selected layer's objects count as selected. The walk goes into a plain group, but not a Shaper
|
||||
/// group, that holds a selected object. With `lock`, it also goes into a clip group or compound path that holds one,
|
||||
/// and includes guide paths while `guides_locked`. Without `lock`, guide paths and the objects that hold one are left
|
||||
/// out while `guides_locked`; a clip group, Shaper group, compound path and every other object that holds a selected
|
||||
/// object or such a guide are left out with their members.
|
||||
fn unselected(
|
||||
d: &vectorcraft_doc::Document,
|
||||
iso: Option<NodeId>,
|
||||
selection: &[NodeId],
|
||||
roots: &[NodeId],
|
||||
guides_locked: bool,
|
||||
lock: bool,
|
||||
) -> Vec<NodeId> {
|
||||
let mut roots: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
roots.extend(selection.iter().filter_map(|id| d.node(*id)).filter(|n| n.is_layer()).flat_map(|l| l.layer_art(true)));
|
||||
let mut kept: Vec<NodeId> = roots.iter().copied().collect();
|
||||
if guides_locked {
|
||||
if guides_locked && !lock {
|
||||
super::select::walk_selectable(d, iso, |n| {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. }) {
|
||||
kept.push(n.id);
|
||||
@@ -344,24 +362,30 @@ fn unselected(d: &vectorcraft_doc::Document, iso: Option<NodeId>, selection: &[N
|
||||
out.push(n.id);
|
||||
return false;
|
||||
}
|
||||
!roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none()
|
||||
!roots.contains(&n.id)
|
||||
&& (matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none()
|
||||
|| lock && (matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Compound { .. })))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn hide_unselected(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
fn unselected_art(s: &mut Session, lock: bool) -> Result<Value> {
|
||||
let roots = selected_roots_to_transform(s)?;
|
||||
let guides_locked = s.menu.guides_locked;
|
||||
let st = s.doc()?;
|
||||
let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked);
|
||||
// With nothing to hide, the document and its undo history stay as they are.
|
||||
let ids = unselected(&st.doc, st.isolation, &st.selection.objects, &roots, guides_locked, lock);
|
||||
// With nothing to lock (Lock) or hide (Hide), the document and its undo history stay as they are.
|
||||
if !ids.is_empty() {
|
||||
s.edit("Hide", |d, _| {
|
||||
// Hiding moves no node in the tree, so the paths found up front stay right.
|
||||
s.edit(if lock { "Lock" } else { "Hide" }, |d, _| {
|
||||
// Locking and hiding move no node in the tree, so the paths found up front stay right.
|
||||
let paths = d.index_paths(ids.iter().copied());
|
||||
for id in &ids {
|
||||
if let Some(n) = paths.get(id).and_then(|p| d.node_mut_at(p)) {
|
||||
n.visible = false;
|
||||
if lock {
|
||||
n.locked = true;
|
||||
} else {
|
||||
n.visible = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -35,4 +35,5 @@ pub(crate) use arrange::group_nodes;
|
||||
pub(crate) use bounds::bounds_rewrite;
|
||||
pub use commands::specs;
|
||||
pub(super) use compound::{as_clipping_path, make_clipping_path, release_clip};
|
||||
pub(super) use shape::recut;
|
||||
pub(crate) use transform::{apply_transform, preview_copies, scaling, transform_node, transform_patterns, transforms_objects};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use vectorcraft_doc::AnchorRef;
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::spans::Span;
|
||||
use super::{
|
||||
BTreeSet, CornerKind, EngineError, LiveCorners, LiveShape, NodeId, NodeKind, Result, Session, Value, bad, check_numbers, check_switches,
|
||||
@@ -249,17 +252,29 @@ pub(super) fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(k) => k.clone(),
|
||||
None => c.picked(partial.as_deref()),
|
||||
};
|
||||
let (selected, layout) = (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec());
|
||||
set_corners(path, live, &picked, radius, kind);
|
||||
// Direct-Selected corners stay selected as they gain or lose anchors.
|
||||
if let (Some(anchors), Some(selected)) = (partial, selected)
|
||||
&& let Some(c) = LiveCorners::new(path, live.as_deref())
|
||||
&& c.sources() != layout
|
||||
{
|
||||
*anchors = c.anchors_of(&selected);
|
||||
}
|
||||
recut(path, live, partial, &picked, radius, kind);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
/// [`set_corners`] with the Direct-Selected corners (`partial`, the path's selected anchors)
|
||||
/// staying selected as they gain or lose anchors.
|
||||
pub(in crate::cmd) fn recut(
|
||||
path: &mut PathData,
|
||||
live: &mut Option<Box<LiveShape>>,
|
||||
partial: Option<&mut BTreeSet<AnchorRef>>,
|
||||
picked: &BTreeSet<usize>,
|
||||
radius: Option<f64>,
|
||||
kind: Option<CornerKind>,
|
||||
) {
|
||||
let before = LiveCorners::new(path, live.as_deref()).map(|c| (partial.as_deref().map(|a| c.corners_of(a)), c.sources().to_vec()));
|
||||
set_corners(path, live, picked, radius, kind);
|
||||
if let (Some(anchors), Some((Some(selected), layout))) = (partial, before)
|
||||
&& let Some(c) = LiveCorners::new(path, live.as_deref())
|
||||
&& c.sources() != layout
|
||||
{
|
||||
*anchors = c.anchors_of(&selected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Step Type",
|
||||
[],
|
||||
None,
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
|
||||
"{attribute: \"size\"|\"leading\"|\"tracking\"|\"kerning\"|\"baselineShift\", by?: steps (1; negative steps down), id?, start?: byte, end?: byte, ids?: [id…] (instead of id)} step type by the Preferences › Type increments (size and leading: typeSizeIncrement, pt; tracking and kerning: trackingIncrement, 1/1000 em; baseline shift: baselineShiftIncrement, pt), what the Type tool's Alt+arrows do (Cmd/Ctrl too: five steps; horizontal: ←/→ kerning at the caret or tracking of the selection, ↑/↓ leading, Shift+↑/↓ baseline shift; vertical: ↓/↑ with or without Shift kerning or tracking, ←/→ leading, Shift+→/← baseline shift; mixed selected text objects use the horizontal mapping). The range of `id`, else the type objects in `ids`, else the text the Type tool has selected (at a caret: kerning steps the character before it, leading its paragraph), else every selected type object → {ids}",
|
||||
has_doc,
|
||||
type_step
|
||||
),
|
||||
@@ -1133,7 +1133,7 @@ fn type_step(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Cmd/Ctrl+Alt+Q: tracking 0 and kerning Auto (0 in the Character panel) in the type objects in
|
||||
/// `ids`, else in the Type tool's selected text, else in every selected type object. At a caret,
|
||||
/// only the kerning of the character before it changes, as Alt+←/→ kerns there.
|
||||
/// only the kerning of the character before it changes, as Alt+←/→ in horizontal type or Alt+↓/↑ in vertical type kerns there.
|
||||
fn reset_tracking(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ranges = type_ranges(s, p, "type.resetTracking", |text, a| Some((edit::prev_char(text, a), a)))?;
|
||||
if !ranges.is_empty() {
|
||||
|
||||
@@ -30,7 +30,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sample Color",
|
||||
[],
|
||||
None,
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index} sample a colour into the active fill/stroke (and the selection); with `stop`, recolour that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
|
||||
"{color, stroke?: bool (default: whichever proxy is active), ids?, stop?: index, clearStroke?: bool} sample a color into the active fill/stroke (and the selection; `ids: []` changes only the paint default); `clearStroke: true` requires `stroke: false`, `ids: []` and no `stop`, and clears the default stroke after sampling an image into the fill; with `stop`, recolor that stop of the gradient behind the proxy instead (paint.editGradient); in a CMYK document an RGB color is stored as CMYK through the color settings, as paint.setFill stores it",
|
||||
has_doc,
|
||||
sample_color
|
||||
),
|
||||
@@ -343,7 +343,11 @@ fn eyedropper_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "paint.sampleColor";
|
||||
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
|
||||
let stroke = super::switch_or(p, "stroke", !s.fill_active, C)?;
|
||||
let clear_stroke = super::switch_or(p, "clearStroke", false, C)?;
|
||||
if clear_stroke && (stroke || p.get("stroke") != Some(&Value::Bool(false)) || p.get("ids") != Some(&json!([])) || p.get("stop").is_some()) {
|
||||
return Err(bad(C, "`clearStroke` requires `stroke: false`, `ids: []` and no `stop`"));
|
||||
}
|
||||
let c = p.get("color").ok_or_else(|| bad(C, "missing color"))?;
|
||||
let (id, mut q) = match p.get("stop") {
|
||||
None | Some(Value::Null) => (if stroke { "paint.setStroke" } else { "paint.setFill" }, json!({ "color": c })),
|
||||
@@ -364,7 +368,11 @@ fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
q["ids"] = ids.clone();
|
||||
}
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
(spec.run)(s, &q)
|
||||
let result = (spec.run)(s, &q)?;
|
||||
if clear_stroke {
|
||||
s.paint.stroke = Paint::None;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// ---------- artboards ----------
|
||||
|
||||
@@ -2316,6 +2316,88 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_sample_paints_next_face_without_changing_selection() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
s.execute("stroke.set", &json!({"weight": 12})).unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.default", &json!({})).unwrap();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let face_before = vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint();
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(255, 0, 0)));
|
||||
assert_eq!(s.paint.stroke.color(), Some(Color::rgb8(0, 0, 255)));
|
||||
assert_eq!(s.paint.stroke_width, 12.0);
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color(), Some(Color::WHITE));
|
||||
assert_eq!(node(&s, src).appearance.fill_paint().color(), Some(Color::rgb8(255, 0, 0)));
|
||||
assert_eq!(vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint(), face_before);
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
|
||||
assert_eq!(
|
||||
vectorcraft_tools::builder::face_at(&node(&s, g), Point::new(125.0, 50.0)).unwrap().appearance.fill_paint().color(),
|
||||
Some(Color::rgb8(255, 0, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_image_samples_fill_and_clears_stroke() {
|
||||
let mut s = session();
|
||||
let img = image::RgbaImage::from_raw(1, 1, vec![0, 255, 0, 255]).unwrap();
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, _| {
|
||||
d.images.insert("px".into(), ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let im = ImageObject {
|
||||
key: "px".into(),
|
||||
width: 1,
|
||||
height: 1,
|
||||
xf: Affine::translate((300.0, 0.0)) * Affine::scale(100.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im)))).map_err(|e| EngineError::Other(e.to_string()))
|
||||
})
|
||||
.unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0)));
|
||||
assert!(s.paint.stroke.is_none());
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::rgb8(0, 0, 255)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 300.0, 0.0, 100.0, 100.0);
|
||||
s.execute("paint.setFill", &json!({"gradient": {"stops": [{"offset": 0, "color": "#ff0000"}, {"offset": 1, "color": "#0000ff"}]}})).unwrap();
|
||||
let dst = rect(&mut s, 500.0, 0.0, 50.0, 50.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#ffffff"})).unwrap();
|
||||
let fill = s.paint.fill.clone();
|
||||
let before = (s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone());
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintBucket", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 350.0, 50.0).with_mods(Mods { alt: true, shift: true, ..Default::default() }), v).unwrap();
|
||||
let sampled = s.paint.stroke.color().unwrap().to_rgb();
|
||||
assert!(sampled[0] > 0.0 && sampled[2] > 0.0 && sampled[1] < 0.01, "{sampled:?}");
|
||||
assert_eq!(s.paint.fill, fill);
|
||||
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().selection.clone()), before);
|
||||
assert_eq!(node(&s, dst).appearance.stroke_paint().color(), Some(Color::WHITE));
|
||||
assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_marquee_selects_what_it_touches() {
|
||||
let mut s = session();
|
||||
@@ -3106,7 +3188,7 @@ fn image_trace_document_library_uses_a_color_group() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_document_library_links_fills_to_global_swatches() {
|
||||
fn image_trace_document_library_fills_are_unlinked() {
|
||||
let mut s = session();
|
||||
// A global color, a tint of another global color and a process color.
|
||||
s.execute("swatch.new", &json!({"name": "Ink", "color": "#c82828", "global": true})).unwrap();
|
||||
@@ -3126,7 +3208,7 @@ fn image_trace_document_library_links_fills_to_global_swatches() {
|
||||
});
|
||||
let img = add_image(&mut s, &r, Affine::IDENTITY);
|
||||
let params = json!({"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 3});
|
||||
// Each fill as (its color, the swatch it links to, its tint), one per color.
|
||||
// Each fill as (its color, its swatch link, its tint), one per color.
|
||||
let fills = |s: &Session, id: &Value| {
|
||||
let mut v: Vec<(String, Option<String>, f32)> = node(s, NodeId(id.as_u64().unwrap()))
|
||||
.children()
|
||||
@@ -3148,27 +3230,26 @@ fn image_trace_document_library_links_fills_to_global_swatches() {
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert_eq!(got.len(), 3, "{got:?}");
|
||||
assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}");
|
||||
assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
// Editing the global swatches recolors the trace: the tint at its own tint.
|
||||
// Editing the global swatches leaves the traced colors unchanged.
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap();
|
||||
s.execute("swatch.edit", &json!({"name": "Sky", "color": "#000000"})).unwrap();
|
||||
let tint = hex_of(&mut s, "Sky 50%");
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert!(got.contains(&fill("#ff8800", Some("Ink"), 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, Some("Sky"), 0.5)), "{got:?}");
|
||||
assert!(got.contains(&fill("#c82828", None, 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill(&tint, None, 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
for _ in 0..3 {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// A color group's global swatches link too.
|
||||
// A color group's global swatches also fill without links.
|
||||
s.execute("swatch.newGroup", &json!({"name": "Brand", "swatches": ["Ink", "Leaf"]})).unwrap();
|
||||
let mut group = params.clone();
|
||||
group["colorGroup"] = json!("Brand");
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"id": img.0, "params": group})).unwrap();
|
||||
let got = fills(&s, &res["id"]);
|
||||
assert!(got.contains(&fill("#c82828", Some("Ink"), 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
assert!(got.contains(&fill("#c82828", None, 1.0)) && got.contains(&fill("#28a03c", None, 1.0)), "{got:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
// A swatch library's colors stay unlinked.
|
||||
let mut web = params.clone();
|
||||
@@ -3179,7 +3260,7 @@ fn image_trace_document_library_links_fills_to_global_swatches() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_document_library_links_to_a_global_swatch_after_a_process_swatch_of_its_color() {
|
||||
fn image_trace_document_library_same_color_stays_unlinked_in_swatch_order() {
|
||||
let mut s = session();
|
||||
// A process swatch, then two global swatches, all of one color.
|
||||
s.execute("swatch.new", &json!({"name": "Red", "color": "#c82828"})).unwrap();
|
||||
@@ -3204,15 +3285,43 @@ fn image_trace_document_library_links_to_a_global_swatch_after_a_process_swatch_
|
||||
let red = Color::rgb8(200, 40, 40);
|
||||
let reds: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some(red.to_rgba8(1.0))).collect();
|
||||
assert!(!reds.is_empty(), "{got:?}");
|
||||
assert!(reds.iter().all(|p| matches!(p, Paint::Solid { swatch: Some(w), .. } if w == "Ink")), "{got:?}");
|
||||
assert!(reds.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}");
|
||||
let tints: Vec<&Paint> = got.iter().filter(|p| p.color().map(|c| c.to_rgba8(1.0)) == Some([tr, tg, tb, 255])).collect();
|
||||
assert!(!tints.is_empty(), "{got:?}");
|
||||
assert!(tints.iter().all(|p| matches!(p, Paint::Solid { swatch: Some(w), tint, .. } if w == "Sky" && *tint == 0.5)), "{got:?}");
|
||||
// Editing the global swatch recolors the trace.
|
||||
assert!(tints.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{got:?}");
|
||||
// Editing the global swatch leaves the trace unchanged.
|
||||
s.execute("swatch.edit", &json!({"name": "Ink", "color": "#ff8800"})).unwrap();
|
||||
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
|
||||
let orange = Color::rgb8(255, 136, 0).to_rgba8(1.0);
|
||||
assert!(g.children().unwrap().iter().any(|n| n.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) == Some(orange)));
|
||||
assert!(!g.children().unwrap().iter().any(|n| n.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) == Some(orange)));
|
||||
assert_eq!(g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect::<Vec<_>>(), got);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_trace_document_library_same_color_is_unlinked_in_both_swatch_orders() {
|
||||
let trace_fill = |global_first: bool| {
|
||||
let mut s = session();
|
||||
for global in [global_first, !global_first] {
|
||||
s.execute("swatch.new", &json!({"name": if global { "Ink" } else { "Red" }, "color": "#c82828", "global": global})).unwrap();
|
||||
}
|
||||
let r = vectorcraft_trace::Raster::from_fn(60, 60, |_, _| [200, 40, 40, 255]);
|
||||
add_image(&mut s, &r, Affine::IDENTITY);
|
||||
let res = s
|
||||
.execute(
|
||||
"imageTrace.makeAndExpand",
|
||||
&json!({"params": {"mode": "color", "palette": "documentLibrary", "library": "document", "colors": 2}}),
|
||||
)
|
||||
.unwrap();
|
||||
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
|
||||
let fills: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
|
||||
assert!(!fills.is_empty());
|
||||
assert!(fills.iter().all(|p| matches!(p, Paint::Solid { swatch: None, .. })), "{fills:?}");
|
||||
fills
|
||||
};
|
||||
let process_first = trace_fill(false);
|
||||
let global_first = trace_fill(true);
|
||||
assert_eq!(process_first, global_first);
|
||||
assert!(process_first.iter().all(|p| p.color() == Some(Color::rgb8(200, 40, 40))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3255,7 +3364,7 @@ fn image_trace_document_library_matches_a_lab_spot_by_its_cmyk_equivalent() {
|
||||
let res = s.execute("imageTrace.makeAndExpand", &json!({"params": params})).unwrap();
|
||||
let g = node(&s, NodeId(res["id"].as_u64().unwrap()));
|
||||
let got: Vec<Paint> = g.children().unwrap().iter().map(|n| n.appearance.fill_paint()).collect();
|
||||
let spot = Paint::Solid { color: shown, swatch: Some("Lab Ink".into()), tint: 1.0 };
|
||||
let spot = Paint::solid(shown);
|
||||
assert!(got.contains(&spot), "{got:?}");
|
||||
assert!(!got.iter().any(|p| p.color() == Some(process)), "{got:?}");
|
||||
}
|
||||
|
||||
@@ -68,6 +68,113 @@ fn picking_up_without_weight_keeps_the_target_weight() {
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "pickUp": {"nope": true}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_changes_only_defaults_and_respects_options() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
let art = s.doc().unwrap().doc.clone();
|
||||
let selection = s.doc().unwrap().selection.clone();
|
||||
let undo = undo_len(&s);
|
||||
s.execute("eyedropper.setOptions", &json!({"pickUp": {"appearance": {"stroke": {"weight": false}}}})).unwrap();
|
||||
let width = s.paint.stroke_width;
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000");
|
||||
assert_eq!(s.paint.stroke.color().unwrap().to_hex(), "#0000ff");
|
||||
assert_eq!(s.paint.stroke_width, width);
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(s.doc().unwrap().selection, selection);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
assert_eq!(node(&s, dst).appearance.fill_paint().color().unwrap().to_hex(), "#ffffff");
|
||||
for extra in [json!({"ids": []}), json!({"reverse": true}), json!({"append": true})] {
|
||||
let mut p = json!({"source": src.0, "defaultsOnly": true});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("appearance.copyFrom", &p).is_err());
|
||||
}
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": "yes"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_does_not_replace_last_eyedropper_sample() {
|
||||
let mut s = session();
|
||||
let (src, dst) = source_and_target(&mut s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(s.execute("appearance.copyFrom", &json!({"source": dst.0, "reverse": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_attribute_sample_does_not_commit_highlighted_type() {
|
||||
let mut s = session();
|
||||
s.prefs.placeholder_text = false;
|
||||
let src = rect(&mut s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let view = ViewInfo::default();
|
||||
s.select_tool("type", view).unwrap();
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::new(kind, 20.0, 300.0), view).unwrap();
|
||||
}
|
||||
let dst = s.doc().unwrap().selection.objects[0];
|
||||
s.tool_text("ABCD", view).unwrap();
|
||||
s.set_tool_option("select", &json!({"start": 0, "end": 2}));
|
||||
let before = node(&s, dst);
|
||||
let undo = undo_len(&s);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(node(&s, dst), before);
|
||||
assert_eq!(undo_len(&s), undo, "sampling does not commit pending typing");
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_color_sample_changes_only_defaults_and_supplies_next_fill() {
|
||||
let mut s = session();
|
||||
let src = rect(&mut s, 300.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff", "ids": [dst.0]})).unwrap();
|
||||
let dst_before = node(&s, dst);
|
||||
let art = s.doc().unwrap().doc.clone();
|
||||
let undo = undo_len(&s);
|
||||
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "ids": []})).unwrap();
|
||||
assert_eq!(s.paint.fill.color().unwrap().to_hex(), "#00ff00");
|
||||
assert_eq!(node(&s, dst), dst_before);
|
||||
assert_eq!(s.doc().unwrap().doc, art);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
s.execute("appearance.copyFrom", &json!({"source": src.0, "defaultsOnly": true})).unwrap();
|
||||
assert_eq!(node(&s, dst), dst_before);
|
||||
assert_eq!(undo_len(&s), undo);
|
||||
let r = s.execute("livePaint.fill", &json!({"ids": [dst.0], "point": [35, 35]})).unwrap();
|
||||
let face = NodeId(r["face"].as_u64().unwrap());
|
||||
assert_eq!(node(&s, face).appearance.fill_paint().color().unwrap().to_hex(), "#ff0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_color_sample_sets_only_fill_default_and_clears_stroke() {
|
||||
let mut s = session();
|
||||
let dst = rect(&mut s, 10.0);
|
||||
s.execute("paint.setStroke", &json!({"color": "#0000ff"})).unwrap();
|
||||
let before = (node(&s, dst), s.doc().unwrap().selection.clone());
|
||||
s.execute("paint.sampleColor", &json!({"color": "#00ff00", "stroke": false, "ids": [], "clearStroke": true})).unwrap();
|
||||
assert_eq!(s.paint.fill.color(), Some(Color::rgb8(0, 255, 0)));
|
||||
assert!(s.paint.stroke.is_none());
|
||||
assert_eq!((node(&s, dst), s.doc().unwrap().selection.clone()), before);
|
||||
let state = (s.paint.clone(), node(&s, dst));
|
||||
for p in [
|
||||
json!({"color": "#ffffff", "stroke": true, "ids": [], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "ids": [], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [dst.0], "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [], "stop": 0, "clearStroke": true}),
|
||||
json!({"color": "#ffffff", "stroke": false, "ids": [], "clearStroke": "yes"}),
|
||||
json!({"color": "#ffffff", "stroke": "no", "ids": [], "clearStroke": true}),
|
||||
json!({"color": {"bad": true}, "stroke": false, "ids": [], "clearStroke": true}),
|
||||
] {
|
||||
assert!(s.execute("paint.sampleColor", &p).is_err(), "{p}");
|
||||
assert_eq!((s.paint.clone(), node(&s, dst)), state, "{p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_appearance_attribute_copies_the_whole_stack() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -520,6 +520,67 @@ fn pattern_over_blue(tile: &[u8], opacity: f32) -> Session {
|
||||
s
|
||||
}
|
||||
|
||||
/// A pattern of opaque grayscale pixels whose black squares draw red and white squares leave the blue background visible.
|
||||
fn colorized_pattern_over_blue() -> Session {
|
||||
fn colorize_image(n: &mut vectorcraft_doc::Node) -> bool {
|
||||
if matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)) {
|
||||
n.extras_mut().colorize = Some(Box::new(vectorcraft_color::Paint::solid(vectorcraft_color::Color::rgb8(255, 0, 0))));
|
||||
return true;
|
||||
}
|
||||
n.children_mut().is_some_and(|children| children.iter_mut().any(|child| colorize_image(std::sync::Arc::make_mut(child))))
|
||||
}
|
||||
|
||||
let tile = image::GrayImage::from_fn(32, 32, |x, y| image::Luma([if (x < 16) == (y < 16) { 0 } else { 255 }]));
|
||||
let mut bytes = vec![];
|
||||
tile.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
let mut s = pattern_over_blue(&bytes, 1.0);
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
assert!(d.patterns[0].art.iter_mut().any(|art| colorize_image(std::sync::Arc::make_mut(art))));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_colorized_grayscale_image_pattern_fill_is_expanded() {
|
||||
let mut s = colorized_pattern_over_blue();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let keys = crate::cmd::flatten::see_through_images(d);
|
||||
assert!(keys.is_empty());
|
||||
let layer = d.layers[0].id;
|
||||
assert_eq!(crate::cmd::expand::expand_transparent_image_patterns(d, layer, &keys).unwrap(), 1);
|
||||
let mut patterns = 0;
|
||||
let mut tile_images = 0;
|
||||
d.walk(|n| {
|
||||
patterns += n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter(
|
||||
|i| matches!(i, vectorcraft_doc::appearance::AppearanceItem::Fill(f) if matches!(f.paint, vectorcraft_color::Paint::Pattern { .. })),
|
||||
)
|
||||
.count();
|
||||
tile_images += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Image(_)));
|
||||
});
|
||||
assert_eq!(patterns, 0);
|
||||
assert!(tile_images > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colorized_grayscale_image_pattern_keeps_its_colors_in_legacy_ai() {
|
||||
let mut s = colorized_pattern_over_blue();
|
||||
let points = [(72.0, 72.0), (88.0, 72.0), (136.0, 152.0), (152.0, 152.0)];
|
||||
let before: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect();
|
||||
assert_eq!(before[0], [255, 0, 0], "{before:?}");
|
||||
assert_eq!(before[1], [0, 0, 255], "{before:?}");
|
||||
let r = s.execute("file.saveCopy", &json!({"format": "ai", "aiVersion": "7", "transparentImages": "flatten"})).unwrap();
|
||||
let saved_warnings = warnings(&r);
|
||||
s.execute("document.open", &json!({"name": "t.ai", "dataBase64": r["dataBase64"]})).unwrap();
|
||||
let after: Vec<[u8; 3]> = points.iter().map(|&(x, y)| colour_at(&s.doc().unwrap().doc, x, y)).collect();
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(*b) <= 3), "before {before:?} after {after:?}");
|
||||
}
|
||||
assert!(saved_warnings.iter().any(|w| w.contains("flattened with the art under them")), "{saved_warnings:?}");
|
||||
}
|
||||
|
||||
/// #3666: a pattern fill whose tile is an image, under opacity, is flattened with the art under it
|
||||
/// like a transparent image, instead of being written opaque over white.
|
||||
#[test]
|
||||
|
||||
@@ -518,8 +518,9 @@ fn lock_and_hide_with_nothing_to_lock_or_hide_record_no_undo_step() {
|
||||
|
||||
/// Hide › Unselected Artwork hides, in one undo step, each object that is not selected and holds no selected object:
|
||||
/// here `b`, the member `n` of the group that holds the selected `m`, and `p` on another layer. The selected objects,
|
||||
/// that group, the clip group that holds the selected `c2` with all its members, a locked object, the art on a locked
|
||||
/// layer and an object hidden before stay as they are, and so does the selection. Show All shows them again.
|
||||
/// that group, the clip group that holds the selected `c2` with all its members, the compound path that holds the
|
||||
/// selected `hole` with both paths, a guide under Lock Guides, a locked object, the art on a locked layer and an
|
||||
/// object hidden before stay as they are, and so does the selection. Show All shows them again.
|
||||
#[test]
|
||||
fn hide_unselected_artwork_hides_every_object_that_is_not_selected() {
|
||||
let mut s = session();
|
||||
@@ -534,6 +535,14 @@ fn hide_unselected_artwork_hides_every_object_that_is_not_selected() {
|
||||
let c3 = rect(&mut s, 80.0, 0.0, 30.0, 30.0);
|
||||
sel(&mut s, &[c1, c2, c3]);
|
||||
let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
let outer = rect(&mut s, 0.0, 200.0, 100.0, 100.0);
|
||||
let hole = rect(&mut s, 40.0, 240.0, 20.0, 20.0);
|
||||
sel(&mut s, &[outer, hole]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
let guide = rect(&mut s, 200.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[guide]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
let k = rect(&mut s, 120.0, 0.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
|
||||
let h = rect(&mut s, 140.0, 0.0, 10.0, 10.0);
|
||||
@@ -544,13 +553,13 @@ fn hide_unselected_artwork_hides_every_object_that_is_not_selected() {
|
||||
let q = rect(&mut s, 20.0, 100.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": locked_layer.0, "locked": true})).unwrap();
|
||||
let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::<Vec<_>>();
|
||||
sel(&mut s, &[a, m, c2]);
|
||||
sel(&mut s, &[a, m, c2, hole]);
|
||||
let steps = undo_len(&s);
|
||||
assert_eq!(s.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 3}));
|
||||
assert_eq!(shown(&s, &[b, n, p, h]), [false; 4]);
|
||||
assert_eq!(shown(&s, &[a, m, g, c1, c2, c3, clip, k, q]), [true; 9]);
|
||||
assert_eq!(shown(&s, &[a, m, g, c1, c2, c3, clip, compound, outer, hole, guide, k, q]), [true; 13]);
|
||||
assert!(node(&s, k).locked);
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2]));
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2, hole]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shown(&s, &[b, n, p]), [true; 3]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
@@ -650,6 +659,151 @@ fn hide_unselected_artwork_keeps_groups_with_locked_guides_and_the_selected_laye
|
||||
assert_eq!(selected(&s), [other]);
|
||||
}
|
||||
|
||||
/// Lock Unselected Artwork locks `b`, `n` in the group that holds selected `m`, `p` on another layer, and the clip
|
||||
/// group's unselected members `c1` and clipping path `c3` in one undo step labeled "Lock". The clip group container,
|
||||
/// `c2`, the selected objects, group `g`, hidden object `h` and art `q` on a locked layer stay unlocked. `k` was locked
|
||||
/// before. The selection stays as it is. Unlock All unlocks all six locked objects and selects them.
|
||||
#[test]
|
||||
fn lock_unselected_artwork_locks_every_object_that_is_not_selected() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[m, n]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let c1 = rect(&mut s, 80.0, 0.0, 10.0, 10.0);
|
||||
let c2 = rect(&mut s, 85.0, 5.0, 10.0, 10.0);
|
||||
let c3 = rect(&mut s, 80.0, 0.0, 30.0, 30.0);
|
||||
sel(&mut s, &[c1, c2, c3]);
|
||||
let clip = id_of(&s.execute("object.clippingMask.make", &json!({})).unwrap());
|
||||
let k = rect(&mut s, 120.0, 0.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": k.0, "locked": true})).unwrap();
|
||||
let h = rect(&mut s, 140.0, 0.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": h.0, "visible": false})).unwrap();
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let p = rect(&mut s, 0.0, 100.0, 10.0, 10.0);
|
||||
let locked_layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
let q = rect(&mut s, 20.0, 100.0, 10.0, 10.0);
|
||||
s.execute("layer.setProps", &json!({"id": locked_layer.0, "locked": true})).unwrap();
|
||||
let locked = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).locked).collect::<Vec<_>>();
|
||||
sel(&mut s, &[a, m, c2]);
|
||||
let steps = undo_len(&s);
|
||||
assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 5}));
|
||||
assert_eq!(locked(&s, &[b, n, p, c1, c3, k]), [true; 6]);
|
||||
assert_eq!(locked(&s, &[a, m, g, c2, clip, h, q]), [false; 7]);
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2]));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Lock");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(locked(&s, &[b, n, p]), [false; 3]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(locked(&s, &[b, n, p]), [true; 3]);
|
||||
assert_eq!(s.execute("object.unlockAll", &json!({})).unwrap(), json!({"count": 6}));
|
||||
assert_eq!(locked(&s, &[b, n, p, c1, c3, k]), [false; 6]);
|
||||
let mut unlocked = selected(&s);
|
||||
unlocked.sort();
|
||||
let mut want = vec![b, n, p, c1, c3, k];
|
||||
want.sort();
|
||||
assert_eq!(unlocked, want);
|
||||
}
|
||||
|
||||
/// Lock Unselected Artwork affects only the isolated group's other members. Lock Guides does not protect an unselected
|
||||
/// guide from this command. With nothing left to lock it records no undo step.
|
||||
#[test]
|
||||
fn lock_unselected_artwork_scopes_isolation_and_locks_guides() {
|
||||
let mut s = session();
|
||||
let guide = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[guide]);
|
||||
s.execute("view.guides.make", &json!({})).unwrap();
|
||||
s.execute("view.guides.lock", &json!({"locked": true})).unwrap();
|
||||
let out = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[m, n]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
let lock = |s: &mut Session| s.execute("object.lock.unselected", &json!({})).unwrap();
|
||||
let locked = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).locked).collect::<Vec<_>>();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[m]);
|
||||
let info = find_command("object.lock.unselected").unwrap().info(&s);
|
||||
assert!(info.enabled, "{info:?}");
|
||||
let before = undo_len(&s);
|
||||
assert_eq!(lock(&mut s), json!({"count": 1}));
|
||||
assert_eq!(locked(&s, &[m, n, out, guide]), [false, true, false, false]);
|
||||
assert_eq!((undo_len(&s), selected(&s)), (before + 1, vec![m]));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
sel(&mut s, &[m]);
|
||||
assert!(find_command("object.lock.unselected").unwrap().info(&s).enabled);
|
||||
assert_eq!(lock(&mut s), json!({"count": 2}));
|
||||
assert_eq!(locked(&s, &[n, out, guide, g, m]), [true, true, true, false, false]);
|
||||
select_with_a_step_to_redo(&mut s, &[m]);
|
||||
let before = history(&s);
|
||||
assert_eq!(lock(&mut s), json!({"count": 0}));
|
||||
assert_eq!(history(&s), before);
|
||||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||||
}
|
||||
|
||||
/// Lock Unselected Artwork locks the unselected path of a partly selected compound path, but needs a selection.
|
||||
#[test]
|
||||
fn lock_unselected_artwork_descends_into_compound_and_needs_selection() {
|
||||
let mut s = session();
|
||||
let outer = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
|
||||
let hole = rect(&mut s, 40.0, 40.0, 20.0, 20.0);
|
||||
sel(&mut s, &[outer, hole]);
|
||||
let compound = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let before = history(&s);
|
||||
assert!(!find_command("object.lock.unselected").unwrap().info(&s).enabled);
|
||||
assert!(matches!(s.execute("object.lock.unselected", &json!({})), Err(EngineError::Disabled(..))));
|
||||
assert_eq!(history(&s), before);
|
||||
sel(&mut s, &[hole]);
|
||||
assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 1}));
|
||||
assert!(node(&s, outer).locked);
|
||||
assert!(!node(&s, hole).locked);
|
||||
assert!(!node(&s, compound).locked);
|
||||
assert_eq!(selected(&s), [hole]);
|
||||
}
|
||||
|
||||
/// Lock Unselected Artwork leaves all art on a selected layer unlocked and locks unselected art on another layer.
|
||||
#[test]
|
||||
fn lock_unselected_artwork_keeps_a_selected_layers_art() {
|
||||
let mut s = session();
|
||||
let selected_layer = id_of(&s.execute("layer.new", &json!({})).unwrap());
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let other = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[selected_layer]);
|
||||
assert_eq!(s.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 1}));
|
||||
assert_eq!([a, b, other].map(|id| node(&s, id).locked), [false, false, true]);
|
||||
assert_eq!(selected(&s), [selected_layer]);
|
||||
}
|
||||
|
||||
/// In a plain group, Lock and Hide Unselected Artwork affect the same members of identical documents.
|
||||
#[test]
|
||||
fn lock_and_hide_unselected_artwork_choose_the_same_objects() {
|
||||
let prepare = || {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0);
|
||||
let m = rect(&mut s, 40.0, 0.0, 10.0, 10.0);
|
||||
let n = rect(&mut s, 60.0, 0.0, 10.0, 10.0);
|
||||
sel(&mut s, &[m, n]);
|
||||
let g = id_of(&s.execute("object.group", &json!({})).unwrap());
|
||||
sel(&mut s, &[a, m]);
|
||||
(s, [a, b, m, n, g])
|
||||
};
|
||||
let (mut locked, lock_ids) = prepare();
|
||||
let (mut hidden, hide_ids) = prepare();
|
||||
assert_eq!(lock_ids, hide_ids);
|
||||
assert_eq!(locked.execute("object.lock.unselected", &json!({})).unwrap(), json!({"count": 2}));
|
||||
assert_eq!(hidden.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 2}));
|
||||
let changed_by_lock: Vec<_> = lock_ids.into_iter().filter(|id| node(&locked, *id).locked).collect();
|
||||
let changed_by_hide: Vec<_> = hide_ids.into_iter().filter(|id| !node(&hidden, *id).visible).collect();
|
||||
assert_eq!(changed_by_lock, changed_by_hide);
|
||||
assert_eq!(changed_by_lock, [lock_ids[1], lock_ids[3]]);
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -368,6 +368,57 @@ fn transforms_without_transform_objects_move_only_the_pattern() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_backquote_drag_changes_pattern_fill_and_stroke_without_moving_mixed_art() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let patterned = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
let solid = rect(&mut s, 220.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [patterned.0], "swatch": "Dots"})).unwrap();
|
||||
s.execute("prefs.set", &json!({"key": "transformPatternTiles", "value": false})).unwrap();
|
||||
sel(&mut s, &[patterned, solid]);
|
||||
let before_patterned = node(&s, patterned);
|
||||
let before_solid = node(&s, solid);
|
||||
let history = crate::tests::history(&s).0;
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 145.0, 125.0) }, v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 145.0, 125.0) }, v).unwrap();
|
||||
let after = node(&s, patterned);
|
||||
assert_eq!(after.geometric_bounds(), before_patterned.geometric_bounds());
|
||||
assert_eq!(node(&s, solid), before_solid);
|
||||
for paint in [after.appearance.fill_paint(), after.appearance.stroke_paint()] {
|
||||
let Paint::Pattern { xf, .. } = paint else { panic!("pattern paint expected") };
|
||||
assert_eq!(xf.as_coeffs()[4], 20.0);
|
||||
}
|
||||
assert_eq!(crate::tests::history(&s).0, history + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, patterned), before_patterned);
|
||||
assert_eq!(node(&s, solid), before_solid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_backquote_drag_changes_stroke_only_pattern() {
|
||||
let mut s = session();
|
||||
make_dots(&mut s);
|
||||
let id = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [id.0], "swatch": "Dots"})).unwrap();
|
||||
sel(&mut s, &[id]);
|
||||
let before = node(&s, id).geometric_bounds();
|
||||
let v = ViewInfo { show_bbox: false, ..Default::default() };
|
||||
s.select_tool("selection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 100.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Drag, 120.0, 125.0) }, v).unwrap();
|
||||
s.pointer(&PointerEvent { backquote: true, ..PointerEvent::new(PointerKind::Up, 120.0, 125.0) }, v).unwrap();
|
||||
let after = node(&s, id);
|
||||
assert_eq!(after.geometric_bounds(), before);
|
||||
let Paint::Pattern { xf, .. } = after.appearance.stroke_paint() else { panic!("pattern stroke expected") };
|
||||
assert_eq!(xf.as_coeffs()[4], 20.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_reports_patterns_and_edit_state() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -112,10 +112,10 @@ fn alt_smooth_fidelity(s: &mut Session) -> Option<f64> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Alt switches Pencil and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity.
|
||||
/// Alt switches Pencil, Paintbrush and Blob Brush to Smooth, using Smooth Tool Options' stored Fidelity.
|
||||
#[test]
|
||||
fn alt_smoothing_takes_the_smooth_tools_fidelity() {
|
||||
for id in ["pencil", "blobBrush"] {
|
||||
for id in ["pencil", "paintbrush", "blobBrush"] {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
|
||||
select(&mut s, "smooth");
|
||||
|
||||
@@ -220,8 +220,8 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Alt switches the Pencil or Blob Brush to Smooth, using Smooth Tool Options' Fidelity.
|
||||
if matches!(t.id(), "pencil" | "blobBrush")
|
||||
// Alt switches the Pencil, Paintbrush or Blob Brush to Smooth, using Smooth Tool Options' Fidelity.
|
||||
if matches!(t.id(), "pencil" | "paintbrush" | "blobBrush")
|
||||
&& let Some(v) = prefs.tool_settings.get("smooth").and_then(|s| s.get("fidelity"))
|
||||
{
|
||||
t.set_option("smoothFidelity", v);
|
||||
@@ -633,8 +633,8 @@ impl Session {
|
||||
|
||||
/// Set options of tool `id` (default: the active tool). Another tool's persistent options are
|
||||
/// stored for when it is chosen, and the active tool takes those it shares with it (the
|
||||
/// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for the Pencil's and
|
||||
/// Blob Brush's Alt smoothing). Returns the tool's options.
|
||||
/// Liquify tools' Global Brush Dimensions, the Smooth tool's Fidelity for Pencil, Paintbrush
|
||||
/// and Blob Brush Alt smoothing). Returns the tool's options.
|
||||
pub fn set_tool_options(&mut self, id: Option<&str>, values: &Map<String, Value>) -> Value {
|
||||
let active = id.is_none_or(|id| id == self.tool.id());
|
||||
self.tool_set_since_reset |= active;
|
||||
|
||||
@@ -419,6 +419,18 @@ fn structured_junk() {
|
||||
"addon.color.filter",
|
||||
json!({"effect": "addon.color.duotone", "params": {"tones": [{"color": "#ff0000", "swatch": "[Registration]"}], "skipSpot": true}}),
|
||||
),
|
||||
("effect.apply", json!({"effect": "addon.path.dashes", "params": {"dash1": 1e308, "gap1": 1e308, "offset": -1e308, "alignCorners": true}})),
|
||||
("effect.apply", json!({"effect": "addon.path.dashes", "params": {"dash1": -1e308, "gap1": 1e-308, "dash5": 1e-9, "gap5": 0}})),
|
||||
("effect.apply", json!({"effect": "addon.path.addAnchors", "params": {"target": "x", "spacing": "x", "count": 1e308, "maxCount": 1e308}})),
|
||||
("effect.apply", json!({"effect": "addon.path.addAnchors", "params": {"spacing": "distance", "distance": 1e-308, "maxCount": -1e308}})),
|
||||
("effect.apply", json!({"effect": "addon.path.extend", "params": {"startLength": 1e308, "endLength": -1e308, "startPercent": -1e308}})),
|
||||
("effect.apply", json!({"effect": "addon.path.extend", "params": {"endPercent": 1e308, "endShape": "arc", "startShape": "x"}})),
|
||||
("effect.apply", json!({"effect": "addon.path.close", "params": {"mode": "x"}})),
|
||||
("effect.apply", json!({"effect": "addon.path.smooth", "params": {"ratio": 1e308}})),
|
||||
("effect.apply", json!({"effect": "addon.path.retract", "params": {"handles": "shorter", "length": 1e308}})),
|
||||
("effect.apply", json!({"effect": "addon.path.reverse"})),
|
||||
("effect.apply", json!({"effect": "addon.path.simplify", "params": {"tolerance": 1e308, "protectCorners": false}})),
|
||||
("effect.apply", json!({"effect": "addon.path.split", "params": {"anchors": "x"}})),
|
||||
("addon.color.printFixes", json!({"inkLimit": 1e308, "overprintBlack": -1e308})),
|
||||
("addon.color.printFixes", json!({"inkLimit": 1, "overprintBlack": 1, "removeWhiteOverprint": true, "scope": "document", "textOnly": true})),
|
||||
("addon.color.printFixes", json!({"inkLimit": [300], "scope": 3, "ids": "x"})),
|
||||
@@ -543,6 +555,30 @@ fn structured_junk() {
|
||||
("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"})),
|
||||
// Path Precision Tools: the Corner Tool and Slide Anchor Along Path.
|
||||
("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e6, "kind": "chamfer"})),
|
||||
("addon.pathtools.cornerSet", json!({"ids": [2, 3], "anchors": [0, 1, u64::MAX], "radius": 5})),
|
||||
("addon.pathtools.cornerSet", json!({"items": [{"id": 2, "anchors": [0]}, {"id": u64::MAX}], "radius": 1e-300})),
|
||||
("addon.pathtools.cornerSet", json!({"items": [1, null, {"anchors": "x"}], "radius": 1})),
|
||||
("addon.pathtools.cornerSet", json!({"ids": [2], "radius": 1e308, "kind": "spiky"})),
|
||||
("addon.pathtools.cornerSet", json!({"items": "all", "radius": f64::NAN, "anchors": [-1, 1.5, "x"]})),
|
||||
("addon.pathtools.cornerRemove", json!({"ids": [0, 1, 2, 3, u64::MAX]})),
|
||||
("addon.pathtools.cornerRemove", json!({"items": [{"id": 2, "anchors": [u64::MAX]}]})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.98})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "subpath": u64::MAX, "anchor": 0, "t": -0.5})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": u64::MAX, "t": 1e308})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": u64::MAX, "anchor": "x", "t": "y"})),
|
||||
// Path Precision Tools: the Shared Edge Merge Tool and Keep Shared Points Together.
|
||||
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [0, 0]})),
|
||||
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [10, 10], "diameter": 200, "tolerance": 3, "ends": true})),
|
||||
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 2, "at": [0, 0]})),
|
||||
("addon.pathtools.mergeEdge", json!({"a": 2, "b": u64::MAX, "at": [1e308, -1e308]})),
|
||||
("addon.pathtools.mergeEdge", json!({"a": 2, "b": 3, "at": [1, 2, 3], "diameter": 1e308, "tolerance": -1, "follow": "up"})),
|
||||
("addon.pathtools.mergeEdge", json!({"a": "x", "b": null, "at": "y", "follow": 3, "ends": "z"})),
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": "maybe"})),
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": true})),
|
||||
("addon.pathtools.slideAnchor", json!({"id": 2, "anchor": 1, "t": 0.5})),
|
||||
("addon.pathtools.movePointsTogether", json!({"enabled": false})),
|
||||
// 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"})),
|
||||
@@ -559,6 +595,23 @@ fn structured_junk() {
|
||||
("addon.pen.reshape", json!({"id": 2, "segment": 0, "t": "x", "dx": 1, "dy": 1, "mode": "symmetric"})),
|
||||
("addon.pen.nextSegment", json!({"curved": "yes"})),
|
||||
("addon.pen.closeJoin", json!({"ids": [u64::MAX]})),
|
||||
("addon.cleanup.breakText", json!({"by": "glyphs", "ids": [0, 1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})),
|
||||
("addon.cleanup.breakText", json!({"by": ["lines"]})),
|
||||
("addon.cleanup.breakText", json!({"by": "paragraphs"})),
|
||||
("addon.cleanup.breakText", json!({"by": "words", "ids": "all"})),
|
||||
("addon.cleanup.combineText", json!({"ids": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]})),
|
||||
("addon.cleanup.combineText", json!({"ids": [-1, 1e308]})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": "paragraph", "ids": [1, 2, 3, 4, 5, 6, 7, 8, u64::MAX]})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": 3})),
|
||||
("addon.cleanup.removeBreaks", json!({"kind": "line", "ids": null})),
|
||||
("addon.cleanup.splitText", json!({"at": 1e308})),
|
||||
("addon.cleanup.alignInPlace", json!({"align": "right", "ids": [1, 2, 3, 4, 5, 6, 7, 8]})),
|
||||
("addon.cleanup.alignInPlace", json!({"align": null, "ids": [-1]})),
|
||||
("addon.cleanup.resetTextTransform", json!({"scale": "yes"})),
|
||||
("addon.cleanup.resetTextTransform", json!({"scale": true, "ids": [1, 2, 3, 4, 5, 6, 7, 8]})),
|
||||
("addon.cleanup.replaceFonts", json!({"font": ""})),
|
||||
("addon.cleanup.replaceFonts", json!({"font": ["Source Sans 3"]})),
|
||||
("addon.cleanup.replaceFonts", json!({"font": "\u{0}"})),
|
||||
];
|
||||
let mut failures = vec![];
|
||||
for fx in Fixture::ALL {
|
||||
@@ -571,6 +624,43 @@ fn structured_junk() {
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Artwork Cleanup's text tools while a Type tool edits type with junk ranges (past the end,
|
||||
/// reversed, inside a character): never a panic, and the document stays sound.
|
||||
#[test]
|
||||
fn cleanup_text_tools_with_junk_editing_ranges() {
|
||||
let ranges = [(0, u64::MAX / 4), (u64::MAX, 1), (5, 5), (1, 2), (u64::MAX / 4, u64::MAX / 4)];
|
||||
let mut failures = vec![];
|
||||
for fx in Fixture::ALL {
|
||||
let mut texts = vec![];
|
||||
for l in &fx.session().doc().unwrap().doc.layers {
|
||||
l.walk(&mut |n| {
|
||||
if matches!(n.kind, vectorcraft_doc::NodeKind::Text(_)) {
|
||||
texts.push(n.id.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
for id in texts {
|
||||
for (anchor, caret) in ranges {
|
||||
for (cmd, p) in [("addon.cleanup.splitText", json!({})), ("addon.cleanup.removeBreaks", json!({"kind": "paragraph"}))] {
|
||||
let mut s = fx.session();
|
||||
let r = catch_quiet(|| {
|
||||
let _ = s.execute("select.set", &json!({"ids": [id]}));
|
||||
let _ = s.select_tool("type", Default::default());
|
||||
s.set_tool_option("textSelection", &json!({"editing": id, "anchor": anchor, "caret": caret}));
|
||||
no_panic(s.execute(cmd, &p))
|
||||
});
|
||||
if let Err(msg) = r {
|
||||
failures.push(format!("PANIC {cmd} {id} {anchor}..{caret} [{fx:?}]: {msg}"));
|
||||
} else if let Err(e) = check_session(&s) {
|
||||
failures.push(format!("{cmd} {id} {anchor}..{caret} [{fx:?}]: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(failures.is_empty(), "{} failures:\n{}", failures.len(), failures.join("\n"));
|
||||
}
|
||||
|
||||
/// Shape Builder Tool Options (Gap Detection) with junk values: never a panic, and the tool still
|
||||
/// answers with options and works on a drag afterwards.
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user