mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 17:47:43 +00:00
Merge main into fix/shape-builder-merge-off-ui-thread
This commit is contained in:
@@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Brush Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across. scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
"{name, params?: {…fields to change (brush.get shows them; values outside their ranges are clamped). calligraphic: angle −180..180°, roundness 0..100%, size 0..1296 pt, variation: [angle°, roundness%, size pt], modes: [fixed|random|pressure for angle, roundness, size] (pressure goes from value − variation at the lightest pen pressure to value + variation at the heaviest). scatter: size [a, b] 1..10000%, spacing [a, b] 1..10000%, scatter [a, b] −1000..1000%, rotation [a, b] −180..180°, modes: [fixed|random|pressure for size, spacing, scatter, rotation] (fixed: a; random: between a and b; pressure: a at the lightest pen pressure to b at the heaviest), rotation_relative_to_path. art: width 1..1000%, width_mode: null (fixed)|pressure, width_max 1..1000% (pressure: width at the lightest pen pressure to width_max at the heaviest, half way on a stroke drawn without pressure), scale: {mode: proportional|stretch|betweenGuides, start, end: 0..1 of the art length}, direction: leftToRight|rightToLeft|topToBottom|bottomToTop, flip_along, flip_across, overlap. pattern: scale 1..10000%, spacing 0..10000%, fit: stretch|addSpace|approximate, flip_along, flip_across, outer_corner and inner_corner: corner tile art or null, outer_source and inner_source: what that kind of corner draws without its art: none|autoCentered|autoBetween|autoSliced|autoOverlap (a tile made from the side tile), or null (the other kind of corner's art). scatter, art and pattern: colorization: {method: none|tints|tintsAndShades|hueShift, key: colour (hueShift: the art colour that becomes the stroke colour; without a key: the solid color the art's fills and strokes use most, black when they use none)}; colorization and the art brush's scale change only the fields given}, newName? (trimmed; blank: the name is kept; a new bristle brush name has at most 31 characters, and a longer name the brush has is kept)} edit a brush definition (strokes using it update) → {name}",
|
||||
has_doc,
|
||||
brush_options
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! and the traced filled paths above it, mapped with the image's transform. Expand drops the
|
||||
//! image (a plain group of paths remains); Release puts the image back in place of the group.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Builder",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} → {ids, merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
"{ids?|id?: [id…] (default: selection), points: [[x,y]…] (drag path; one point = click; may be left out with rect), rect?: [x, y, width, height] (Shift-drag marquee from where the drag started, x and y, to x+width and y+height; width and height may be negative: touches every region whose outline crosses the rectangle or has an anchor inside it and the region holding the whole rectangle, the region under x, y comes first and the one under the opposite corner comes next; with points, it also touches the regions along them; for pickColorFrom, x, y is the drag's first point and the opposite corner is its last), erase?: bool (erase mode, Alt: deletes the regions touched, and the pieces of open paths that cut regions and the edges (outline pieces between the points where paths meet; see shapeBuilder.regions) within tolerance, except that a point in a region no more than twice the tolerance across there touches the region; a deleted edge opens its path there, and edges bounding a deleted region go with it; what is left of each path keeps its look, with gradients fitted to its bounds kept where they were on it, its name, opacity mask and attributes, and its fill rule unless merged regions shaped it), splitStroke?: false (In Merge Mode, Clicking Stroke Splits the Path; not with erase or rect: when the one point of a click is within tolerance of a stroke, the path that stroke is on splits in two instead of a merge: the path keeps the rest of it, and a copy of the path holding the run of it from the anchor or crossing point before the click to the one after it, in the path's direction, goes directly above it; both keep the path's gradients where they were on it; the other objects stay as they are; with pickColorFrom \"swatches\" or fill, the run's stroke takes that paint; a path in a Live Paint group or a Shaper Group does not split, and nor does a path the run is all of, such as a lone line, or all but 0.05 pt or less of (a ten-thousandth of the larger side of a path over 500 pt)), tolerance?: pt (2, erase or splitStroke), gap?: pt (0–72; 0: Gap Detection off), openFilledAsClosed?: true (Consider Open Filled Paths as Closed: a filled open path bounds a region as if an invisible edge closed it; false: it cuts the regions it crosses like an unfilled one), pickColorFrom?: \"artwork\" (default: the merged shape takes the look of the front-most object under the first point, else under the last point, else of the front-most object) | \"swatches\" (the current fill, with the rest of the look of the front-most object over the first region the drag touches that an object covers, else of the front-most object), fill?: \"#rrggbb\" | {color|swatch|gradient|none} (overrides pickColorFrom)} replaces the paths it uses where they are; other selected objects (type, hidden or locked objects, clipping paths) stay, and a group goes only when it is left empty → {ids (selected after: each selected object still there, or what took its place), merged, regions, lines, edges (counts deleted, merged or split), split?: id (the run split off, after a split)}; after a split, ids are the roots, with the rest and the run in place of the path when it was one",
|
||||
has_selection_or_ids,
|
||||
sb_merge
|
||||
),
|
||||
@@ -301,6 +302,89 @@ fn remnant(d: &mut Document, src: &Node, path: PathData, rule: Option<FillRule>)
|
||||
n
|
||||
}
|
||||
|
||||
/// Put each list of `outs` in place of the leaf of `leaves` at the same index (none: the leaf stays
|
||||
/// as it is), then remove the groups under `roots` that this leaves empty. A Live Paint or Shaper
|
||||
/// group (the outermost one under the root) is replaced as a whole, by the lists of its leaves,
|
||||
/// with a copy of each leaf that stays and of each hidden or locked object in it. Other objects
|
||||
/// under the roots, such as type, hidden or locked objects and clipping paths, stay where they are. Returns what to select: each root
|
||||
/// still there, or what went in its place.
|
||||
fn replace_leaves(d: &mut Document, roots: &[NodeId], leaves: &[Node], outs: Vec<Option<Vec<Node>>>) -> Result<Vec<NodeId>> {
|
||||
let listed: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
let paths = d.index_paths(leaves.iter().map(|l| l.id));
|
||||
// Each leaf's unit (the leaf, or the group replaced as a whole) and the groups from the unit's
|
||||
// parent up to its root, innermost first.
|
||||
let mut places: Vec<(NodeId, Vec<NodeId>)> = vec![];
|
||||
for leaf in leaves {
|
||||
let anc = paths.get(&leaf.id).and_then(|p| d.ancestry_at(p)).unwrap_or_default();
|
||||
let below = anc.iter().position(|a| listed.contains(a)).and_then(|r| anc.get(r..)).unwrap_or_default();
|
||||
let whole = below.iter().position(|a| d.node(*a).is_some_and(|n| is_live_paint(n) || n.extras().shaper.is_some()));
|
||||
let at = whole.unwrap_or(below.len().saturating_sub(1));
|
||||
let unit = below.get(at).copied().unwrap_or(leaf.id);
|
||||
places.push((unit, below.get(..at).unwrap_or_default().iter().rev().copied().collect()));
|
||||
}
|
||||
// (unit, groups above it, what goes in place of each of its leaves, whether it changes); a
|
||||
// unit's leaves are next to each other, in paint order.
|
||||
type Unit = (NodeId, Vec<NodeId>, Vec<(NodeId, Vec<Node>)>, bool);
|
||||
let mut units: Vec<Unit> = vec![];
|
||||
for ((leaf, out), (unit, above)) in leaves.iter().zip(outs).zip(places) {
|
||||
let changed = out.is_some();
|
||||
let list = match out {
|
||||
Some(list) => list,
|
||||
None if unit != leaf.id => vec![d.reid(leaf)],
|
||||
None => vec![],
|
||||
};
|
||||
match units.last_mut() {
|
||||
Some(u) if u.0 == unit => {
|
||||
u.2.push((leaf.id, list));
|
||||
u.3 |= changed;
|
||||
}
|
||||
_ => units.push((unit, above, vec![(leaf.id, list)], changed)),
|
||||
}
|
||||
}
|
||||
units.retain(|u| u.3);
|
||||
let ids: Vec<NodeId> = units.iter().map(|u| u.0).collect();
|
||||
let mut replaced = Beside::replacing(d, &ids);
|
||||
let mut put: HashMap<NodeId, Vec<NodeId>> = HashMap::new();
|
||||
let mut emptied = vec![];
|
||||
for (unit, above, lists, _) in units {
|
||||
// A group replaced as a whole keeps a copy of each hidden or locked object in it, where it was.
|
||||
let members: Vec<Node> = d.node(unit).map(|n| b::builder_members(n).into_iter().cloned().collect()).unwrap_or_default();
|
||||
let mut at: HashMap<NodeId, usize> = HashMap::new();
|
||||
for (k, l) in lists.iter().enumerate() {
|
||||
at.entry(l.0).or_insert(k);
|
||||
}
|
||||
let mut lists: Vec<Option<Vec<Node>>> = lists.into_iter().map(|l| Some(l.1)).collect();
|
||||
let mut list = vec![];
|
||||
for m in &members {
|
||||
if let Some(l) = at.remove(&m.id).and_then(|k| lists.get_mut(k)).and_then(Option::take) {
|
||||
list.extend(l);
|
||||
} else if (m.locked || !m.visible) && m.id != unit {
|
||||
list.push(d.reid(m));
|
||||
}
|
||||
}
|
||||
list.extend(lists.into_iter().flatten().flatten());
|
||||
if list.is_empty() {
|
||||
emptied.push(above);
|
||||
}
|
||||
put.insert(unit, list.iter().map(|n| n.id).collect());
|
||||
replaced.put(d, unit, list)?;
|
||||
}
|
||||
replaced.finish(d)?;
|
||||
// A group goes only when nothing is left in it.
|
||||
for above in emptied {
|
||||
for g in above {
|
||||
match d.node(g) {
|
||||
None => continue,
|
||||
Some(n) if !n.is_layer() && n.children().is_some_and(|c| c.is_empty()) => {
|
||||
d.remove(g)?;
|
||||
}
|
||||
Some(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(roots.iter().flat_map(|r| put.get(r).cloned().unwrap_or_else(|| d.node(*r).map(|_| vec![*r]).unwrap_or_default())).collect())
|
||||
}
|
||||
|
||||
// ---------- Live Paint ----------
|
||||
|
||||
/// The source nodes a root contributes (a Live Paint group contributes its own sources).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, Beside, BuilderMap, EngineError, FillRule, Node, NodeId, PathData, Point, Rect, Result, Session, Touched, Value, b, bad, check_numbers,
|
||||
f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_roots, replace_with_pieces, root_ids,
|
||||
f64_or, fill_param, finite, gap_param, ids_json, json, marquee_param, po, points_param, remnant, replace_leaves, replace_with_pieces, root_ids,
|
||||
shape_node, switch_or,
|
||||
};
|
||||
|
||||
@@ -125,43 +125,48 @@ pub(super) fn sb_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let covered =
|
||||
if swatches { picked.iter().find_map(|r| r.top()) } else { first.as_ref().and_then(top_at).or_else(|| last.as_ref().and_then(top_at)) };
|
||||
let style_src = covered.or(map.shapes.len().checked_sub(1));
|
||||
let mut new_nodes: Vec<Node> = vec![];
|
||||
// What goes in place of each object the map was made from (none: it stays as it is).
|
||||
let mut outs: Vec<Option<Vec<Node>>> = vec![];
|
||||
let mut merged_id = None;
|
||||
for (i, leaf) in leaves.iter().take(map.shapes.len()).enumerate() {
|
||||
let mut out = None;
|
||||
let pieces: Vec<usize> = map.lines.iter().enumerate().filter(|(_, (l, _))| l.key == i as u64).map(|(k, _)| k).collect();
|
||||
if !pieces.is_empty() {
|
||||
// An open path loses the pieces deleted and those between two merged (or deleted) regions.
|
||||
let keep: Vec<usize> =
|
||||
pieces.iter().copied().filter(|k| !touched.lines.contains(k) && !map.line_between(*k, &touched.regions)).collect();
|
||||
if keep.len() == pieces.len() {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
} else {
|
||||
if keep.len() != pieces.len() {
|
||||
let mut kept = vec![];
|
||||
for k in keep {
|
||||
if let Some((l, _)) = map.lines.get(k) {
|
||||
new_nodes.push(remnant(d, leaf, l.path.clone(), None));
|
||||
kept.push(remnant(d, leaf, l.path.clone(), None));
|
||||
}
|
||||
}
|
||||
out = Some(kept);
|
||||
}
|
||||
} else if let Some(Some(rest)) = geometry.rests.get(i) {
|
||||
// A shape loses the regions deleted or merged, then the edges deleted (each one on
|
||||
// its outline, shared edges from every shape along them). What is left of its own
|
||||
// outline keeps its fill rule; united faces fill with non-zero.
|
||||
if !rest.path.is_empty() {
|
||||
new_nodes.push(remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero)));
|
||||
}
|
||||
} else {
|
||||
new_nodes.push(d.reid(leaf));
|
||||
out = Some(if rest.path.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![remnant(d, leaf, rest.path.clone(), rest.united.then_some(FillRule::NonZero))]
|
||||
});
|
||||
}
|
||||
// The merged shape goes in place of the object it takes its look from, so it stays in
|
||||
// that object's group (a clip group too).
|
||||
if !erase && !union.is_empty() && Some(i) == style_src {
|
||||
let mut m = shape_node(d, union.clone(), Some(leaf));
|
||||
if let Some(f) = &fill {
|
||||
m.appearance.set_fill(f.clone());
|
||||
}
|
||||
merged_id = Some(m.id);
|
||||
new_nodes.push(m);
|
||||
out.get_or_insert_with(|| vec![leaf.clone()]).push(m);
|
||||
}
|
||||
outs.push(out);
|
||||
}
|
||||
let new_ids = replace_roots(d, &roots, new_nodes)?;
|
||||
let new_ids = replace_leaves(d, &roots, &leaves, outs)?;
|
||||
sel.set(new_ids.iter().copied());
|
||||
Ok((new_ids, merged_id, touched, None))
|
||||
})?;
|
||||
|
||||
@@ -322,12 +322,16 @@ pub(crate) fn convert_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
/// Put `d` in colour `mode` and, with `convert`, convert every colour of its art, symbols,
|
||||
/// pattern tiles and swatches through the colour settings (`intent`, default: theirs) → how many
|
||||
/// colours of the art, symbols and patterns changed. Gray colours stay Gray (they print on the
|
||||
/// black plate in either mode); RGB greys are colours like any other and separate through the
|
||||
/// CMYK profile, unless `grays` puts them on the black plate. Lab colours (spot colour
|
||||
/// definitions) are device independent and fit either mode. Document Color Mode and
|
||||
/// Put `d` in color `mode` and, with `convert`, convert every color of its art (opacity masks
|
||||
/// included), symbols, graph designs, pattern tiles (the tile that pattern editing's Cancel puts
|
||||
/// back included), graphic styles, brushes (their art and a Hue Shift key color) and swatches
|
||||
/// through the color settings (`intent`, default: theirs) → how many colors of the art (opacity
|
||||
/// masks included), symbols, graph designs and pattern tiles changed; the colors of graphic
|
||||
/// styles and brushes convert without being counted. Gray colors stay
|
||||
/// Gray (they print on the black plate in either mode); RGB grays are colors like any other and
|
||||
/// separate through the CMYK profile, unless `grays` puts them on the black plate. Lab colors
|
||||
/// (spot color definitions) are device independent and fit either mode. A tint of a global
|
||||
/// swatch becomes the converted swatch's color at its tint. Document Color Mode and
|
||||
/// `document.open {colorMode}` both convert this way.
|
||||
pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode, convert: bool, intent: Option<Intent>, grays: Grays) -> usize {
|
||||
d.color_mode = mode;
|
||||
@@ -344,18 +348,6 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
|
||||
Color::Rgb { .. } if k_only && Neutral::of(col).is_some() => c.convert(&c.convert(col, Model::Gray, intent), Model::Cmyk, intent),
|
||||
_ => c.convert(col, model, intent),
|
||||
};
|
||||
let mut changed = 0usize;
|
||||
let mut count = |col: &Color, _: proof::Link| {
|
||||
let n = conv(col);
|
||||
if n != *col {
|
||||
changed += 1;
|
||||
}
|
||||
n
|
||||
};
|
||||
proof::map_document_colors(d, &mut count);
|
||||
for a in d.patterns.iter_mut().flat_map(|p| &mut p.art) {
|
||||
proof::map_node_colors(Arc::make_mut(a), &mut count);
|
||||
}
|
||||
for sw in d.swatches_iter_mut() {
|
||||
match &mut sw.paint {
|
||||
Paint::Solid { color, .. } => *color = conv(color),
|
||||
@@ -369,6 +361,73 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
|
||||
}
|
||||
// Tints are their converted swatch's colour at their tint (exact ink percentages).
|
||||
let bases: Vec<(String, Color)> = d.swatches_iter().filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?))).collect();
|
||||
let changed = std::cell::Cell::new(0usize);
|
||||
let mut count = |col: &Color, link: proof::Link| {
|
||||
let n = match link.and_then(|(name, tint)| Some((bases.iter().find(|(n, _)| n == name)?, tint))) {
|
||||
Some(((_, base), tint)) if tint < 1.0 => base.tinted(tint),
|
||||
_ => conv(col),
|
||||
};
|
||||
if n != *col {
|
||||
changed.set(changed.get() + 1);
|
||||
}
|
||||
n
|
||||
};
|
||||
let patterns = d.patterns.iter_mut().chain(d.pattern_edit.iter_mut().filter_map(|e| e.original.as_mut()));
|
||||
for a in d
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(d.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(d.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(patterns.flat_map(|p| &mut p.art))
|
||||
{
|
||||
map_art_colors(Arc::make_mut(a), &mut count);
|
||||
}
|
||||
// The reply counts the colors of the document's art only, not those of the graphic styles and brushes.
|
||||
let in_art = changed.get();
|
||||
for it in d.graphic_styles.iter_mut().flat_map(|g| &mut g.appearance.items) {
|
||||
let paint = match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(l) => &mut l.paint,
|
||||
vectorcraft_doc::AppearanceItem::Stroke(l) => &mut l.paint,
|
||||
};
|
||||
map_paint_colors(paint, &mut count);
|
||||
}
|
||||
// Convert each brush's art. A Hue Shift key color takes the converted color of the art color
|
||||
// it matches (a tint of a global swatch included), so the key still matches the converted art;
|
||||
// a key that matches no art color converts on its own.
|
||||
let in_styles = changed.get();
|
||||
let mut brushes = vectorcraft_brush::library(d);
|
||||
let mut keys = false;
|
||||
for b in &mut brushes {
|
||||
let old_key = match b.kind.colorization() {
|
||||
vectorcraft_brush::Colorization::HueShift { key } => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
let mut new_key = None;
|
||||
for art in b.art_mut() {
|
||||
map_art_colors(art, &mut |col: &Color, link: proof::Link| {
|
||||
let n = count(col, link);
|
||||
if new_key.is_none() && old_key.is_some_and(|k| matches_key(col, &k)) {
|
||||
new_key = Some(n);
|
||||
}
|
||||
n
|
||||
});
|
||||
}
|
||||
let colorization = match &mut b.kind {
|
||||
vectorcraft_brush::BrushKind::Art(a) => Some(&mut a.colorization),
|
||||
vectorcraft_brush::BrushKind::Scatter(s) => Some(&mut s.colorization),
|
||||
vectorcraft_brush::BrushKind::Pattern(p) => Some(&mut p.colorization),
|
||||
vectorcraft_brush::BrushKind::Calligraphic(_) | vectorcraft_brush::BrushKind::Bristle(_) => None,
|
||||
};
|
||||
if let Some(vectorcraft_brush::Colorization::HueShift { key }) = colorization {
|
||||
let n = new_key.unwrap_or_else(|| conv(key));
|
||||
keys |= n != *key;
|
||||
*key = n;
|
||||
}
|
||||
}
|
||||
if keys || changed.get() > in_styles {
|
||||
vectorcraft_brush::store(d, &brushes);
|
||||
}
|
||||
// This pass gives tint swatches their converted swatch's color at their tint; the passes above already did the same for the art's tints.
|
||||
d.map_solid_paints(&mut |c, link, tint| match bases.iter().find(|(n, _)| Some(n) == link.as_ref()) {
|
||||
Some((_, base)) if *tint < 1.0 => {
|
||||
*c = base.tinted(*tint);
|
||||
@@ -376,7 +435,43 @@ pub(crate) fn set_color_mode(d: &mut vectorcraft_doc::Document, mode: ColorMode,
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
changed
|
||||
in_art
|
||||
}
|
||||
|
||||
/// [`proof::map_node_colors`] over `n`'s subtree and the art of every opacity mask in it.
|
||||
fn map_art_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) {
|
||||
proof::map_node_colors(n, f);
|
||||
map_mask_colors(n, f);
|
||||
}
|
||||
|
||||
fn map_mask_colors(n: &mut vectorcraft_doc::Node, f: &mut dyn FnMut(&Color, proof::Link) -> Color) {
|
||||
if let Some(mask) = n.extras_if_any().and_then(|e| e.mask.as_deref_mut()) {
|
||||
map_art_colors(Arc::make_mut(&mut mask.art), f);
|
||||
}
|
||||
if let Some(ch) = n.children_mut() {
|
||||
for c in ch.iter_mut() {
|
||||
map_mask_colors(Arc::make_mut(c), f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether art color `c` is the Hue Shift key color `key`, with the tolerance the brush's
|
||||
/// colorization matches it with.
|
||||
fn matches_key(c: &Color, key: &Color) -> bool {
|
||||
c.to_rgb().iter().zip(key.to_rgb()).map(|(a, b)| (a - b).abs()).sum::<f32>() < 0.02
|
||||
}
|
||||
|
||||
/// `f` applied to a solid paint's color or a gradient's stop colors, given their swatch link.
|
||||
fn map_paint_colors(p: &mut Paint, f: &mut dyn FnMut(&Color, proof::Link) -> Color) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, tint } => *color = f(color, swatch.as_deref().map(|s| (s, *tint))),
|
||||
Paint::Gradient(g) => {
|
||||
for st in &mut g.gradient.stops {
|
||||
st.color = f(&st.color, st.swatch.as_deref().map(|s| (s, st.tint)));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn values(c: &Color) -> Vec<f32> {
|
||||
|
||||
@@ -198,8 +198,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Text",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again → {id}",
|
||||
has_doc,
|
||||
"{id? (default: the selected type)} Object › Perspective › Edit Text: show type in perspective flat where it is drawn, in isolation mode, to edit it (text.* commands, the Type tool); exiting isolation (object.exitIsolation, Esc) projects it again; refused while an opacity mask is edited → {id}",
|
||||
super::maskedit::outside_mask_editing,
|
||||
edit_text
|
||||
),
|
||||
]
|
||||
|
||||
@@ -234,7 +234,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Document Color Mode",
|
||||
["File", "Document Color Mode"],
|
||||
None,
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every colour of the art, symbols, pattern tiles and swatches through the colour settings; swatch links kept; Gray colours stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB greys separate through the CMYK profile like any colour, into four-colour greys and a rich black) | \"black\" (to CMYK, RGB greys with R = G = B go on the black plate only, K = their grey value)} → {changed}",
|
||||
"{mode: \"cmyk\"|\"rgb\", convert?: true (convert every color of the art (opacity masks included), symbols, graph designs, pattern tiles, graphic styles, brushes and swatches through the color settings; swatch links kept; Gray colors stay Gray, on the black plate), intent?, grays?: \"profile\" (default: RGB grays separate through the CMYK profile like any color, into four-color grays and a rich black) | \"black\" (to CMYK, RGB grays with R = G = B go on the black plate only, K = their gray value)} → {changed: how many colors of the art, symbols, graph designs and pattern tiles changed; the colors of graphic styles and brushes are not counted}",
|
||||
has_doc,
|
||||
super::colormgmt::convert_mode
|
||||
),
|
||||
|
||||
@@ -13,13 +13,16 @@ use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster};
|
||||
use vectorcraft_geom::{Point, Rect};
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document};
|
||||
use super::super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_doc, str_param};
|
||||
use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{ArtboardPick, Encoded, FormatOption};
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
/// Most pixels along a side of a preview.
|
||||
const MAX_PREVIEW: f64 = 2048.0;
|
||||
/// Save needs a lightweight compatibility preview, not an uncompressed 16 MiB image beside
|
||||
/// a small vector. TIFF format choices are unchanged; there is no public resolution option.
|
||||
const SAVE_PREVIEW: f64 = 1024.0;
|
||||
/// Most pixels along a side of a thumbnail.
|
||||
const THUMBNAIL: f64 = 128.0;
|
||||
|
||||
@@ -260,7 +263,20 @@ fn native(doc: &Document, board: Option<usize>, include_linked: bool) -> Vec<u8>
|
||||
/// artboard (after a central file of the whole document when there are several and art shows).
|
||||
/// Transparency is flattened first (once for all of them); the previews show `doc` itself.
|
||||
pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
encode_document(doc, p, false)
|
||||
}
|
||||
|
||||
/// Save keeps a single page's original frame and all native editing data, unlike Export's
|
||||
/// optional art-bound crop or separate file per artboard. Existing EPS loss warnings still apply.
|
||||
pub(super) fn encode_save(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
encode_document(doc, p, true)
|
||||
}
|
||||
|
||||
fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
|
||||
let set = parse(p)?;
|
||||
if saving && (set.use_artboards == Some(false) || bool_or(p, "selectedOnly", false)) {
|
||||
return Err(bad(C, "EPS Save retains the artboard and all artwork: use document.export for cropped or selected-only EPS"));
|
||||
}
|
||||
let original = doc;
|
||||
let (full, warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let doc = &*full;
|
||||
@@ -272,7 +288,13 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
} else {
|
||||
Cow::Borrowed(original)
|
||||
};
|
||||
let pages = pages(doc, &set)?;
|
||||
let pages = if saving {
|
||||
let board = set.boards.one(doc.artboards.len()).map_err(|e| bad(C, e))?;
|
||||
let rect = doc.artboards.get(board).ok_or_else(|| bad(C, "the document has no artboard"))?.rect;
|
||||
vec![(Some(board), rect, Point::new(rect.x0, rect.y1))]
|
||||
} else {
|
||||
pages(doc, &set)?
|
||||
};
|
||||
if pages.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
@@ -284,13 +306,14 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
if transparent && flat.is_some() {
|
||||
enc.warnings.push("transparency is flattened into opaque art and images (EPS has none): see the flattener preset".into());
|
||||
}
|
||||
let preview_limit = if saving { SAVE_PREVIEW } else { MAX_PREVIEW };
|
||||
let created = vectorcraft_doc::metadata::output_time();
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
for (board, region, origin) in pages {
|
||||
let rect = vectorcraft_eps::preview_rect(region, origin);
|
||||
let preview = match set.eps.preview {
|
||||
Preview::None => None,
|
||||
kind => Some(render(&mut renderer, doc, rect, MAX_PREVIEW, set.eps.transparent_preview && kind == Preview::TiffColor)?),
|
||||
kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?),
|
||||
};
|
||||
let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?;
|
||||
let o = EpsOptions {
|
||||
@@ -298,7 +321,7 @@ pub(super) fn encode(doc: &Document, p: &Value) -> Result<Encoded> {
|
||||
origin,
|
||||
title: doc.title.clone(),
|
||||
created,
|
||||
native: Some(native(&editing, board, set.include_linked)),
|
||||
native: Some(native(&editing, if saving { None } else { board }, set.include_linked)),
|
||||
..set.eps.clone()
|
||||
};
|
||||
let out = vectorcraft_eps::export(drawn, &o, preview.as_ref(), thumbnail.as_ref()).map_err(|e| bad(C, e))?;
|
||||
|
||||
@@ -430,8 +430,19 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
/// `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
|
||||
// What reading the file left out of the document, if anything.
|
||||
let losses: Vec<String> = warnings.iter().filter(|w| is_loss(w)).cloned().collect();
|
||||
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
|
||||
// fallbacks must ask before overwriting the richer source they could not fully read.
|
||||
let losses: Vec<String> = warnings
|
||||
.iter()
|
||||
.filter(|w| {
|
||||
is_loss(w)
|
||||
|| (format.id == "eps"
|
||||
&& (matches!(w.as_str(), EDITING_DAMAGED | EDITING_STALE)
|
||||
|| w.starts_with("this file's PostScript couldn't be read")
|
||||
|| w.starts_with("the file was read up to an error")))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let source_path = path.clone().filter(|_| !losses.is_empty());
|
||||
// A template (saved by Save as Template, or an .ait/.vctemplate file) opens as a new untitled
|
||||
// document.
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{DocState, Prefs};
|
||||
|
||||
/// The formats Save As offers, in menu order (append-only). The other writable formats are exports:
|
||||
/// they never become the document's own file.
|
||||
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai"];
|
||||
pub const SAVE_FORMATS: &[&str] = &["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"];
|
||||
|
||||
/// The formats Save as Template writes, the default first: a native template, or a
|
||||
/// PDF-compatible `.ait` (written as a `.ai` file is).
|
||||
@@ -431,9 +431,9 @@ pub fn reads_option(f: &Format, key: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Formats that carry the whole native document: they lose nothing, and record links relative to
|
||||
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it.
|
||||
/// where they are written. A `.ai` or `.ait` file is a PDF carrying it; EPS carries it in comments.
|
||||
fn is_native(f: &Format) -> bool {
|
||||
matches!(f.id, "vectorcraft" | "template" | "ai" | "ait")
|
||||
matches!(f.id, "vectorcraft" | "template" | "ai" | "ait" | "eps")
|
||||
}
|
||||
|
||||
fn is_pdf(f: &Format) -> bool {
|
||||
@@ -520,7 +520,7 @@ impl<W: std::io::Write> std::io::Write for Counted<W> {
|
||||
/// `separateArtboards`, those `range` names (default all).
|
||||
fn separate_boards(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<usize>> {
|
||||
// A legacy .ai file is a file per artboard already.
|
||||
if !is_native(f) || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? {
|
||||
if !is_native(f) || f.id == "eps" || !bool_or(p, "separateArtboards", false) || super::legacy_ai::wanted(cmd, f, p)? {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let range = str_param(p, "range").filter(|r| !r.trim().is_empty()).map(str::to_string);
|
||||
@@ -737,8 +737,12 @@ impl SaveJob {
|
||||
/// Encode one file of `doc` in the job's format.
|
||||
fn encode_one(&self, doc: &Document) -> Result<Encoded> {
|
||||
let (cmd, f) = (self.plan.mode.command(), self.plan.format);
|
||||
let enc = if matches!(f.id, "ai" | "ait") { encode_ai(cmd, f, doc, &self.params) } else { encode_all(doc, f.id, &self.params) }
|
||||
.map_err(|e| named_for_save(cmd, e))?;
|
||||
let enc = match f.id {
|
||||
"ai" | "ait" => encode_ai(cmd, f, doc, &self.params),
|
||||
"eps" => super::eps::encode_save(doc, &self.params),
|
||||
_ => encode_all(doc, f.id, &self.params),
|
||||
}
|
||||
.map_err(|e| named_for_save(cmd, e))?;
|
||||
// A legacy .ai file per artboard.
|
||||
let per_artboard = f.id == "ai" && super::legacy_ai::wanted(cmd, f, &self.params)?;
|
||||
if enc.files.len() != 1 && !per_artboard {
|
||||
@@ -952,7 +956,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version or preview given flat), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly; separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
@@ -961,7 +965,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save As…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+S"),
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
"{…as document.save} {path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own; ait, a .ait template, is written by file.saveAsTemplate), options?, svg?, modified?} the document takes on the new path, name and format (except a template, which is always a copy) → {path, format, bytes, warnings}; no path → {dataBase64, format, name, folder?, warnings}",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::SaveAs, p)
|
||||
),
|
||||
|
||||
@@ -174,7 +174,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
let regions: Option<Vec<vectorcraft_geom::Rect>> =
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// SVG has no filters for the Photoshop-style effects: their objects go in as images.
|
||||
// Render effects SVG cannot represent faithfully, including source-model Darkness shadows.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
|
||||
@@ -297,3 +297,95 @@ fn svgz_opens_and_pastes_like_svg() {
|
||||
assert_eq!(r["count"], 2);
|
||||
assert!(s.execute("document.open", &json!({"name": "bad.svgz", "dataBase64": vectorcraft_format::base64_encode(&[0x1f, 0x8b, 1, 2])})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_darkness_preserves_source_model_and_gradient_appearance() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{ColorMode, Effect};
|
||||
use vectorcraft_geom::Rect;
|
||||
for kind in ["rgb", "cmykSource", "gradient", "cmykDocument"] {
|
||||
let mut s = session(1);
|
||||
s.edit("paint source", |d, _| {
|
||||
if kind == "cmykDocument" {
|
||||
d.color_mode = ColorMode::Cmyk;
|
||||
}
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.fill_mut().unwrap().paint = match kind {
|
||||
"cmykSource" => Paint::solid(Color::cmyk(0.15, 0.3, 0.4, 0.2)),
|
||||
"gradient" => Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(
|
||||
GradientKind::Linear,
|
||||
vec![GradientStop::new(0.0, Color::BLACK), GradientStop::new(1.0, Color::rgb(0.8, 0.4, 0.2))],
|
||||
)))),
|
||||
_ => Paint::solid(Color::rgb(0.8, 0.4, 0.2)),
|
||||
};
|
||||
n.appearance.items.retain(|i| i.is_fill());
|
||||
n.appearance.effects = vec![Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
visible: true,
|
||||
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
|
||||
}];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let native = s.doc().unwrap().doc.clone();
|
||||
let text = svg(&mut s, json!({}));
|
||||
if kind == "rgb" {
|
||||
assert!(text.contains("feComponentTransfer") && !text.contains("<image"));
|
||||
} else {
|
||||
assert!(text.contains("<image"), "{kind}: the shadow must preserve rendered appearance");
|
||||
}
|
||||
let back = vectorcraft_svg::import(&text).unwrap();
|
||||
let region = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let expected = vectorcraft_render::Renderer::new().render_region(&native, region, 1.0, true);
|
||||
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
|
||||
for x in 80..115 {
|
||||
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "{kind} at {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() {
|
||||
const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
// Color settings are process-wide: exercise this profile in a separate test process.
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
return;
|
||||
}
|
||||
use vectorcraft_color::{Color, Paint, cms};
|
||||
let settings = cms::ColorSettings { rgb: cms::WIDE_GAMUT_RGB.into(), ..Default::default() };
|
||||
cms::set_active(&settings).unwrap();
|
||||
assert!(!cms::active().rgb_is_srgb());
|
||||
let mut s = session(1);
|
||||
s.edit("RGB source", |d, _| {
|
||||
let id = d.layers[0].children().unwrap()[0].id;
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.fill_mut().unwrap().paint = Paint::solid(Color::rgb(0.8, 0.4, 0.2));
|
||||
n.appearance.items.retain(|i| i.is_fill());
|
||||
n.appearance.effects = vec![vectorcraft_doc::Effect {
|
||||
id: "stylize.dropShadow".into(),
|
||||
visible: true,
|
||||
params: json!({"shade":"darkness","darkness":25,"x":60,"y":0,"blur":0,"opacity":100,"mode":"normal"}),
|
||||
}];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let region = vectorcraft_geom::Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let expected = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 1.0, true);
|
||||
let text = svg(&mut s, json!({}));
|
||||
assert!(text.contains("<image"), "a non-sRGB source must preserve its adjusted rendered colors");
|
||||
cms::set_active(&cms::ColorSettings::default()).unwrap();
|
||||
let back = vectorcraft_svg::import(&text).unwrap();
|
||||
let actual = vectorcraft_render::Renderer::new().render_region(&back, region, 1.0, true);
|
||||
for x in 80..115 {
|
||||
let (a, b) = (actual.pixel(x, 30), expected.pixel(x, 30));
|
||||
assert!(a.iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "at {x}: {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
//! object updates live; the renderer doesn't paint the editing layer (the mask is seen through its
|
||||
//! effect, as in the reference app). Leaving the mode removes the layer, and saving or exporting
|
||||
//! never writes it ([`Document::without_edit_modes`]). Meanwhile the Transparency panel's commands
|
||||
//! act on the masked object (see `opacitymask::transparency_targets`).
|
||||
//! act on the masked object (see `opacitymask::transparency_targets`). Mask editing is not entered
|
||||
//! in isolation mode ([`outside_isolation_mode`]), and isolation mode is not entered while a mask is
|
||||
//! edited ([`outside_mask_editing`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -27,8 +29,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Edit Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does → {layer}",
|
||||
has_doc,
|
||||
"{id?} edit the mask art of `id` (default: the first selected object with a mask) in place, as clicking the mask thumbnail in the Transparency panel does; refused in isolation mode → {layer}",
|
||||
outside_isolation_mode,
|
||||
enter
|
||||
),
|
||||
cmd!(
|
||||
@@ -45,13 +47,33 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"View Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing) → {on, id}",
|
||||
has_doc,
|
||||
"{on?: bool (default: toggle), id?} show only the opacity mask of `id` (default: the object whose mask is edited, else the first selected object with a mask) on the canvas, as greyscale coverage, and edit it, as Alt-clicking the mask thumbnail does; off shows the artwork again (still editing); refused in isolation mode → {on, id}",
|
||||
outside_isolation_mode,
|
||||
view_mask
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Is the document in isolation mode? Pattern editing mode and opacity mask editing isolate their
|
||||
/// art too, and do not count as isolation mode here.
|
||||
pub(crate) fn in_isolation_mode(st: &crate::DocState) -> bool {
|
||||
st.isolation.is_some() && st.doc.pattern_edit.is_none() && st.doc.mask_edit.is_none()
|
||||
}
|
||||
|
||||
/// A document is open and it is not in isolation mode ([`in_isolation_mode`]): opacity mask
|
||||
/// editing is not entered in isolation mode.
|
||||
pub(crate) fn outside_isolation_mode(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if in_isolation_mode(st) { Err("exit isolation mode first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// A document is open and no opacity mask is being edited: isolation mode is not entered while
|
||||
/// an opacity mask is edited.
|
||||
pub(crate) fn outside_mask_editing(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if st.doc.mask_edit.is_some() { Err("stop editing the opacity mask first".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Mask art made of `children`: a plain group, so the mask keeps one art node. Editing spreads it
|
||||
/// back out onto the editing layer.
|
||||
pub(crate) fn mask_group(children: Vec<Arc<Node>>) -> Node {
|
||||
|
||||
@@ -13,7 +13,7 @@ use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2};
|
||||
use vectorcraft_render::AntiAlias;
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
|
||||
|
||||
use super::edit::{duplicate_in, selected_roots};
|
||||
use super::edit::{duplicate_in, selected_roots, selected_roots_to_transform};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, fileio, find_command, has_doc, has_selection,
|
||||
has_selection_outside_isolation, switch_or,
|
||||
@@ -74,6 +74,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_outside_isolation,
|
||||
|s, _| other_layers(s, false)
|
||||
),
|
||||
cmd!(
|
||||
"object.hide.unselected",
|
||||
"Unselected Artwork",
|
||||
["Object", "Hide"],
|
||||
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; guide paths stay as they are while Lock Guides is on; object.showAll shows them again; disabled in isolation mode → {count}",
|
||||
has_selection_outside_isolation,
|
||||
hide_unselected
|
||||
),
|
||||
cmd!(
|
||||
"object.transformEach",
|
||||
"Transform Each…",
|
||||
@@ -312,6 +321,46 @@ 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 walk goes into a plain group that holds one of `roots`; a clip group, a Shaper group, a compound path and
|
||||
/// every other object that holds one is left out with its members. Guide paths are left out while `guides_locked`.
|
||||
fn unselected(d: &vectorcraft_doc::Document, iso: Option<NodeId>, roots: &[NodeId], guides_locked: bool) -> Vec<NodeId> {
|
||||
let holds: HashSet<NodeId> = d.index_paths(roots.iter().copied()).values().filter_map(|p| d.ancestry_at(p)).flatten().collect();
|
||||
let roots: HashSet<NodeId> = roots.iter().copied().collect();
|
||||
let mut out = vec![];
|
||||
super::select::walk_selectable(d, iso, |n| {
|
||||
if !holds.contains(&n.id) {
|
||||
if !(guides_locked && matches!(n.kind, NodeKind::Path { guide: true, .. })) {
|
||||
out.push(n.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
!roots.contains(&n.id) && matches!(n.kind, NodeKind::Group { clip: false, .. }) && n.extras().shaper.is_none()
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn hide_unselected(s: &mut Session, _: &Value) -> 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, &roots, guides_locked);
|
||||
// With nothing to 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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "count": ids.len() }))
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
/// Small deterministic PRNG (xorshift64*) for Transform Each → Random.
|
||||
|
||||
@@ -26,8 +26,8 @@ fn preview_bounds(s: &Session, p: &Value, cmd: &str) -> Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Param `key` of the Align commands, Shear and `text.setFormat`'s lists: one of `values`, or
|
||||
/// `None` when it is missing or null. Any other value is an error that lists `values`.
|
||||
/// Param `key` of the Align commands, Shear, Unlock All, Show All and `text.setFormat`'s lists: one
|
||||
/// of `values`, or `None` when it is missing or null. Any other value is an error that lists `values`.
|
||||
pub(crate) fn choice<'a>(p: &'a Value, cmd: &str, key: &str, values: &[&str]) -> Result<Option<&'a str>> {
|
||||
let Some(v) = p.get(key).filter(|v| !v.is_null()) else { return Ok(None) };
|
||||
match v.as_str() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::align::choice;
|
||||
use super::{Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, id_param, json, ok, selected_roots};
|
||||
|
||||
pub(super) enum Arrange {
|
||||
@@ -172,8 +173,42 @@ fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
|
||||
v
|
||||
}
|
||||
|
||||
pub(super) fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
|
||||
/// The objects that `f` picks for `object.unlockAll` and `object.showAll` (`cmd`): in the whole
|
||||
/// document, or with `scope: "group"` inside the groups that hold the selected objects. A selected
|
||||
/// group is the group that holds its own members, and the objects of a group inside another such
|
||||
/// group are taken once. With `scope: "group"` and no selected object in a group, it returns an
|
||||
/// error.
|
||||
fn collect_flag_in_scope(s: &Session, cmd: &str, p: &Value, f: impl Fn(&Node) -> bool) -> Result<Vec<NodeId>> {
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
if choice(p, cmd, "scope", &["document", "group"])? != Some("group") {
|
||||
return Ok(collect_flag(d, f));
|
||||
}
|
||||
let is_group = |id: &NodeId| d.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Group { .. }));
|
||||
let mut groups: Vec<NodeId> = st.selection.objects.iter().filter_map(|id| d.ancestry(*id)?.into_iter().rev().find(is_group)).collect();
|
||||
groups.sort();
|
||||
groups.dedup();
|
||||
if groups.is_empty() {
|
||||
return Err(EngineError::Other("nothing selected is in a group".into()));
|
||||
}
|
||||
let mut v = vec![];
|
||||
for g in &groups {
|
||||
if d.ancestry(*g).is_some_and(|a| a.iter().any(|up| up != g && groups.contains(up))) {
|
||||
continue;
|
||||
}
|
||||
for c in d.node(*g).and_then(Node::children).into_iter().flatten() {
|
||||
c.walk(&mut |n| {
|
||||
if !n.is_layer() && f(n) {
|
||||
v.push(n.id)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn unlock_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = collect_flag_in_scope(s, "object.unlockAll", p, |n| n.locked)?;
|
||||
// With nothing locked, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
@@ -211,8 +246,8 @@ pub(super) fn hide(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
pub(super) fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
|
||||
pub(super) fn show_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = collect_flag_in_scope(s, "object.showAll", p, |n| !n.visible)?;
|
||||
// With nothing hidden, the document, the selection and the undo history stay as they are.
|
||||
if ids.is_empty() {
|
||||
return Ok(json!({ "count": 0 }));
|
||||
|
||||
@@ -15,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Transform",
|
||||
[],
|
||||
None,
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too)",
|
||||
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?, strokes?: bool, corners?: bool, typeAreas?: bool, anchors?: bool, objects?: bool (Transform Objects, default true; false: the art stays and, with patterns, only its pattern tiles transform), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles), releaseOutside?: bool} apply an affine to the selection (or ids); anchors (without ids or copy): paths with anchors selected with Direct Selection have only those anchors and their handles transformed, as the Transform tools and object.move/rotate/scale/reflect/shear do (default: the whole objects); strokes/corners: Scale Strokes & Effects / Scale Corners (default: the preferences); typeAreas: area type among them (not type inside a group) reshapes its frame by the matrix and its text reflows at its size, as a bounding-box handle drag does (default: the type transforms too); releaseOutside (without copy or anchors): a Shaper Group's original shape that ends wholly outside the bounds the group's original shapes had before takes shaper.releaseSource in the same undo step, named Remove from Shaper Group, as the Selection tool's moves in Construction mode do",
|
||||
has_doc,
|
||||
transform
|
||||
),
|
||||
@@ -154,7 +154,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_outside_isolation,
|
||||
lock
|
||||
),
|
||||
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", outside_isolation, unlock_all),
|
||||
cmd!(
|
||||
"object.unlockAll",
|
||||
"Unlock All",
|
||||
["Object"],
|
||||
Some("Cmd+Alt+2"),
|
||||
"{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} unlock the locked objects and select them → {count}",
|
||||
outside_isolation,
|
||||
unlock_all
|
||||
),
|
||||
cmd!(
|
||||
"object.hide",
|
||||
"Selection",
|
||||
@@ -164,7 +172,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection_outside_isolation,
|
||||
hide
|
||||
),
|
||||
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", outside_isolation, show_all),
|
||||
cmd!(
|
||||
"object.showAll",
|
||||
"Show All",
|
||||
["Object"],
|
||||
Some("Cmd+Alt+3"),
|
||||
"{scope?: \"document\"|\"group\" (default document; group: only the objects inside the groups that hold the selected objects, a selected group holding its own; the in-window menu item chosen with Shift+Alt)} show the hidden objects and select them → {count}",
|
||||
outside_isolation,
|
||||
show_all
|
||||
),
|
||||
cmd!(
|
||||
"object.compoundPath.make",
|
||||
"Make",
|
||||
@@ -224,8 +240,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Enter Isolation Mode",
|
||||
[],
|
||||
None,
|
||||
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused",
|
||||
has_doc,
|
||||
"{id?} isolate a group, a layer or a single object (default: the first selected object); type, a hidden or locked object, an object in a hidden or locked group or layer, and a guide path while Lock Guides is on are refused, and so is every object while an opacity mask is edited",
|
||||
crate::cmd::maskedit::outside_mask_editing,
|
||||
isolate
|
||||
),
|
||||
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
|
||||
|
||||
@@ -235,13 +235,40 @@ fn resize_type_area(n: &mut Node, xf: Affine) -> bool {
|
||||
|
||||
pub(super) fn transform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_switches(p, &TRANSFORM_SWITCHES, "object.transform")?;
|
||||
check_switches(p, &["anchors"], "object.transform")?;
|
||||
check_switches(p, &["anchors", "releaseOutside"], "object.transform")?;
|
||||
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
|
||||
let (ids, anchors) = match ids_param(p, "ids") {
|
||||
Some(v) => (outermost(&s.doc()?.doc, v), false),
|
||||
None => (selected_roots_to_transform(s)?, bool_or(p, "anchors", false)),
|
||||
};
|
||||
apply_transform(s, "Transform", ids, m, p, anchors)
|
||||
// Shaper's Construction mode: an original shape moved wholly outside its group's bounds leaves
|
||||
// the group.
|
||||
let moved = if bool_or(p, "releaseOutside", false) && !bool_or(p, "copy", false) && !anchors {
|
||||
crate::cmd::shaper::moving_sources(s, &ids)?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
// Outside a drag and an undo group, the move and the removal are one undo step, named for the
|
||||
// removal when there is one.
|
||||
let grouped = !moved.is_empty() && s.doc()?.interaction.is_none() && s.doc()?.undo_group.is_none();
|
||||
if grouped {
|
||||
s.begin_named_undo_group(None);
|
||||
}
|
||||
let r = apply_transform(s, "Transform", ids, m, p, anchors);
|
||||
let released = match &r {
|
||||
Ok(_) if !moved.is_empty() => crate::cmd::shaper::release_outside(s, &moved),
|
||||
_ => Ok(false),
|
||||
};
|
||||
if grouped && let Some(st) = s.active_mut() {
|
||||
st.undo_group = None;
|
||||
if matches!(released, Ok(true))
|
||||
&& let Some(e) = st.history.undo.last_mut()
|
||||
{
|
||||
e.label = "Remove from Shaper Group".into();
|
||||
}
|
||||
}
|
||||
released?;
|
||||
r
|
||||
}
|
||||
|
||||
pub(super) fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -24,8 +24,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Make Opacity Mask",
|
||||
["Window", "Transparency"],
|
||||
None,
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask → {id, editing}",
|
||||
has_doc,
|
||||
"{ids?, clip?, invert?} the topmost of the selected objects (or of `ids`) becomes the mask of the others (grouped if several). One object gets an empty mask and enters mask editing: draw the mask, then transparency.stopEditingOpacityMask; one target (an object or a layer) is refused in isolation mode → {id, editing}",
|
||||
can_make,
|
||||
make
|
||||
),
|
||||
cmd!(
|
||||
@@ -162,6 +162,22 @@ fn masked_targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// Make Opacity Mask: a document is open, and one selected object, which [`make`] gives an empty
|
||||
/// mask and takes into mask editing, is not in isolation mode
|
||||
/// ([`super::maskedit::outside_isolation_mode`]). The selection is one object when it has one
|
||||
/// subject root, which `make` masks without `ids`, and at most one outermost object, which it
|
||||
/// masks with `ids` (a call's `ids` stand in for the selection here). `make` itself refuses any
|
||||
/// single target in isolation mode, a layer included, so a selection with a target counts as one
|
||||
/// object.
|
||||
fn can_make(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let Some(st) = s.active() else { return Ok(()) };
|
||||
let roots = super::appearance::subject_roots(s).map_or(0, |r| r.len());
|
||||
let outermost = super::edit::outermost(&st.doc, st.selection.in_paint_order(&st.doc)).len();
|
||||
let targeted = st.selection.target.is_some();
|
||||
if roots == 1 && (outermost <= 1 || targeted) { super::maskedit::outside_isolation_mode(s) } else { Ok(()) }
|
||||
}
|
||||
|
||||
fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "transparency.makeOpacityMask";
|
||||
let d = &s.doc()?.doc;
|
||||
@@ -178,6 +194,10 @@ fn make(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => super::appearance::subject_roots(s)?,
|
||||
};
|
||||
let Some((&top, below)) = ids.split_last() else { return Err(bad(C, "select the art and, on top of it, the mask object")) };
|
||||
// One target gets an empty mask and enters mask editing, which isolation mode does not allow.
|
||||
if below.is_empty() && super::maskedit::in_isolation_mode(s.doc()?) {
|
||||
return Err(bad(C, "exit isolation mode first"));
|
||||
}
|
||||
let clip = bool_or(p, "clip", !s.menu.new_masks_unclipped);
|
||||
let invert = bool_or(p, "invert", s.menu.new_masks_inverted);
|
||||
// Several objects: the top one is the mask. One: an empty mask, drawn in mask editing.
|
||||
|
||||
@@ -222,18 +222,27 @@ pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let whole = s.doc()?.selection.whole();
|
||||
let sc = Scaling { patterns: super::object::transform_patterns(s, p, &whole)?, ..Scaling::default() };
|
||||
transform_anchors(s, "Move", vectorcraft_geom::Affine::translate(d), sc)?;
|
||||
// Transform Again repeats a move that moved something; a drag records it when it commits.
|
||||
let st = s.doc_mut()?;
|
||||
if st.interaction.is_none() && (d.x != 0.0 || d.y != 0.0) {
|
||||
let options = json!({ "copy": false, "patterns": sc.patterns });
|
||||
st.last_transform = Some(crate::LastTransform::Matrix(vectorcraft_geom::Affine::translate(d), options));
|
||||
st.last_perspective = None;
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Apply `xf` to the direct-selected anchors, their handles with them, and (with `sc`) to the
|
||||
/// objects selected whole (those with no entry in the selection's `anchors`).
|
||||
/// objects selected whole (those with no entry in the selection's `anchors`). An object inside
|
||||
/// another selected object, such as a path in a selected group, moves once, with that object.
|
||||
pub(crate) fn transform_anchors(s: &mut Session, label: &str, xf: vectorcraft_geom::Affine, sc: Scaling) -> Result<()> {
|
||||
let sel = s.doc()?.selection.clone();
|
||||
let ids = super::edit::outermost(&s.doc()?.doc, sel.objects.clone());
|
||||
let c = xf.as_coeffs();
|
||||
let is_translation = (c[0] - 1.0).abs() < 1e-9 && c[1].abs() < 1e-9 && c[2].abs() < 1e-9 && (c[3] - 1.0).abs() < 1e-9;
|
||||
let delta = vectorcraft_geom::Vec2::new(c[4], c[5]);
|
||||
s.edit(label, |doc, _| {
|
||||
for id in &sel.objects {
|
||||
for id in &ids {
|
||||
match sel.anchors.get(id) {
|
||||
Some(set) => {
|
||||
let path = path_mut(doc, *id)?;
|
||||
|
||||
@@ -14,7 +14,10 @@ use vectorcraft_pathops as po;
|
||||
use vectorcraft_pathops::{BoolOp, PathfinderOp};
|
||||
|
||||
use super::edit::selected_roots;
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, bool_or, cmd, has_doc, has_multi, has_selection, str_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, always, bad, bool_or, checked_ids_or_selection, cmd, has_doc, has_multi, has_selection, number_or,
|
||||
str_param, switch_or,
|
||||
};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -153,6 +156,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
simplify
|
||||
),
|
||||
cmd!(
|
||||
"object.path.smooth",
|
||||
"Smooth…",
|
||||
["Object", "Path"],
|
||||
None,
|
||||
"{level?: 0..100 (50; the Smooth slider: 0 minimum smoothing, the path staying within 0.1% of its longer side, 100 maximum: refitted with curves within up to 2.5% of it, corners kept only where the path turns back, never with more anchor points), auto?: bool (Auto-Smooth: level 50), ids?: [id…] (default: the selection)} smooth each path under the objects, compound path members included, as one undo step → {ids, before, after} anchor counts, and the level used",
|
||||
has_selection,
|
||||
smooth
|
||||
),
|
||||
cmd!("object.path.addAnchorPoints", "Add Anchor Points", ["Object", "Path"], None, "{} → {ids}", has_selection, add_anchor_points),
|
||||
cmd!(
|
||||
"object.path.divideObjectsBelow",
|
||||
@@ -957,20 +969,20 @@ fn path_ids(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
/// selected segments when they were picked as segments). `None` selects the path whole.
|
||||
type PartSelection = Option<(BTreeSet<AnchorRef>, Option<BTreeSet<SegmentRef>>)>;
|
||||
|
||||
/// Replace each path under the selection ([`path_ids`]) by what `f` makes of it, given its
|
||||
/// Replace each path under `roots` ([`path_ids`]) by what `f` makes of it, given its
|
||||
/// direct-selected anchors and picked segments (none when it is selected whole): → (their ids, how
|
||||
/// many anchors they have before and after). A path that `f` would empty or returns unchanged is
|
||||
/// left as it is, with its live shape and its direct selection; a replaced path loses its live
|
||||
/// shape and has the direct selection `f` gives it.
|
||||
fn edit_paths(
|
||||
s: &mut Session,
|
||||
roots: &[NodeId],
|
||||
label: &str,
|
||||
f: impl Fn(&PathData, Option<&BTreeSet<AnchorRef>>, Option<&BTreeSet<SegmentRef>>) -> (PathData, PartSelection),
|
||||
) -> Result<(Vec<NodeId>, usize, usize)> {
|
||||
let roots = selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let (d, sel) = (&st.doc, &st.selection);
|
||||
let ids = path_ids(d, &roots);
|
||||
let ids = path_ids(d, roots);
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other(format!("{label}: select paths")));
|
||||
}
|
||||
@@ -1064,7 +1076,8 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let whole = bool_or(p, "wholePaths", false);
|
||||
// A path with segments direct-selected simplifies only their runs, and keeps those runs (and the
|
||||
// other anchors it had selected) direct-selected.
|
||||
let (ids, before, after) = edit_paths(s, "Simplify", |path, anchors, segments| {
|
||||
let roots = selected_roots(s)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Simplify", |path, anchors, segments| {
|
||||
let Some(picked) = selected_segments(path, anchors, segments).filter(|_| !whole) else {
|
||||
return (po::simplify_with(path, &opts), None);
|
||||
};
|
||||
@@ -1084,8 +1097,20 @@ fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Object › Path › Smooth (`object.path.smooth`): [`po::smooth_level`] on each path under the
|
||||
/// objects given or selected, as one undo step; Auto-Smooth (`auto`) uses [`po::SMOOTH_AUTO_LEVEL`].
|
||||
fn smooth(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.path.smooth";
|
||||
let level = number_or(p, "level", po::SMOOTH_AUTO_LEVEL, C)?.round().clamp(0.0, 100.0);
|
||||
let level = if switch_or(p, "auto", false, C)? { po::SMOOTH_AUTO_LEVEL } else { level };
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Smooth", |path, _, _| (po::smooth_level(path, level), None))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after, "level": level }))
|
||||
}
|
||||
|
||||
fn add_anchor_points(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let (ids, before, after) = edit_paths(s, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?;
|
||||
let roots = selected_roots(s)?;
|
||||
let (ids, before, after) = edit_paths(s, &roots, "Add Anchor Points", |path, _, _| (po::add_anchor_points(path), None))?;
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>(), "before": before, "after": after }))
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
//! images with the same helpers.
|
||||
//!
|
||||
//! SVG has filters for shadows, glows, blurs and feathers but none for the Photoshop-style
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize: SVG export turns only the objects carrying
|
||||
//! one of those into images the same way ([`flatten_pixel_effects`]).
|
||||
//! effects (Radial Blur, Unsharp Mask…) or Rasterize. Darkness shadows also need rendered images
|
||||
//! when source colors or gradients cannot be represented by an RGB component-transfer filter.
|
||||
//! SVG export prepares these images with [`flatten_pixel_effects`].
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -452,9 +453,8 @@ fn placed_symbol(d: &Document, n: &Node) -> Option<Node> {
|
||||
enum Which {
|
||||
/// Every raster effect (PDF).
|
||||
All,
|
||||
/// Objects with a Photoshop-style effect or Rasterize, which SVG can't draw (with all their
|
||||
/// raster effects).
|
||||
Pixel,
|
||||
/// Effects SVG cannot represent faithfully, with their other raster effects.
|
||||
Pixel { cmyk: bool },
|
||||
}
|
||||
|
||||
/// Has `n` (its own, its fills' or strokes') raster effects `which` turns into an image?
|
||||
@@ -462,10 +462,34 @@ fn imaged(n: &Node, which: Which) -> bool {
|
||||
let fx = raster_fx(n);
|
||||
match which {
|
||||
Which::All => !fx.is_empty(),
|
||||
Which::Pixel => fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_))),
|
||||
Which::Pixel { cmyk } => {
|
||||
fx.iter().any(|f| matches!(f, RasterFx::Pixel(_) | RasterFx::Rasterize(_)))
|
||||
|| (fx.iter().any(|f| matches!(f, RasterFx::DropShadow { darkness: Some(_), .. })) && (cmyk || !simple_rgb_shadow_source(n, &fx)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter on one uniform RGB paint can express the source-color adjustment exactly.
|
||||
/// Other paints need the existing rendered-effect image to keep their source-model semantics.
|
||||
fn simple_rgb_shadow_source(n: &Node, fx: &[RasterFx]) -> bool {
|
||||
if !vectorcraft_color::cms::active().rgb_is_srgb() || !matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. }) || fx.len() != 1 {
|
||||
return false;
|
||||
}
|
||||
// Other effects can generate varying source colors even from a single plain paint.
|
||||
let only_shadow = std::iter::once(&n.appearance.effects)
|
||||
.chain(n.appearance.items.iter().map(AppearanceItem::effects))
|
||||
.flatten()
|
||||
.all(|e| !e.visible || e.id == "stylize.dropShadow");
|
||||
if !only_shadow || n.extras().mask.is_some() {
|
||||
return false;
|
||||
}
|
||||
let mut paints = n.appearance.items.iter().filter(|i| effects::paints(i));
|
||||
let Some(item) = paints.next() else {
|
||||
return false;
|
||||
};
|
||||
paints.next().is_none() && matches!(item.paint(), Paint::Solid { color: vectorcraft_color::Color::Rgb { .. }, swatch: None, .. })
|
||||
}
|
||||
|
||||
/// Does `n` hold raster effects that `which` turns into images: its own, its fills' and strokes',
|
||||
/// its opacity mask's or (groups and layers) its members'? Hidden objects and template layers
|
||||
/// aren't drawn.
|
||||
@@ -566,7 +590,7 @@ pub fn flatten_raster_effects(doc: &Document) -> Option<Document> {
|
||||
/// Mask…) or Rasterize are images as [`flatten_raster_effects`] makes them (SVG export: its
|
||||
/// filters draw the other raster effects live), or `None` when there are none.
|
||||
pub fn flatten_pixel_effects(doc: &Document) -> Option<Document> {
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel, doc))
|
||||
flatten(doc, &mut FlattenReport::new(Which::Pixel { cmyk: doc.color_mode == ColorMode::Cmyk }, doc))
|
||||
}
|
||||
|
||||
fn flatten(doc: &Document, report: &mut FlattenReport) -> Option<Document> {
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original; while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -53,6 +53,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
expand
|
||||
),
|
||||
cmd!(
|
||||
"shaper.releaseSource",
|
||||
"Remove from Shaper Group",
|
||||
[],
|
||||
None,
|
||||
"{source: id, group?: id (default: the Shaper Group that holds `source`)} take one original shape out of its Shaper Group: it becomes a path just above the group, keeps its look and is selected, and Construction mode ends; the group's result is computed again from the shapes left, and a group left with none is removed. In Construction mode, a Selection-tool move that ends with an original shape wholly outside the bounds the group's original shapes had does the same in the move's undo step (object.transform's `releaseOutside`) → {id}",
|
||||
has_doc,
|
||||
release_source
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -678,7 +687,7 @@ fn scribble(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
end_isolation(s)?;
|
||||
Ok(json!({"id": id.0, "regions": touched.len(), "strokes": strokes}))
|
||||
}
|
||||
|
||||
@@ -730,13 +739,25 @@ fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
group
|
||||
};
|
||||
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten();
|
||||
// While an opacity mask or a pattern is edited, the source pick leaves its editing layer isolated.
|
||||
let isolation = source.then(|| source_group(node).map(|n| n.id)).flatten().filter(|_| editing_layer(&st.doc).is_none());
|
||||
s.select(|_, sel| sel.set([target]))?;
|
||||
if let Some(id) = isolation {
|
||||
s.doc_mut()?.isolation = Some(id);
|
||||
}
|
||||
Ok(json!({"id": target.0}))
|
||||
}
|
||||
/// The layer an opacity mask or a pattern is edited in, which stays isolated while that mode is on.
|
||||
fn editing_layer(d: &Document) -> Option<NodeId> {
|
||||
d.mask_edit.map(|m| m.layer).or_else(|| d.pattern_edit.as_ref().map(|e| e.layer))
|
||||
}
|
||||
/// Leave isolation mode, Construction mode included. While an opacity mask or a pattern is edited,
|
||||
/// its editing layer stays isolated.
|
||||
fn end_isolation(s: &mut Session) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.isolation = editing_layer(&st.doc);
|
||||
Ok(())
|
||||
}
|
||||
fn target(s: &Session, p: &Value) -> Result<NodeId> {
|
||||
let id = id_param(p, "id")
|
||||
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
|
||||
@@ -757,7 +778,7 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set(ids.iter().copied());
|
||||
Ok(ids)
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
end_isolation(s)?;
|
||||
Ok(json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -774,6 +795,108 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sel.set([id]);
|
||||
Ok(())
|
||||
})?;
|
||||
s.doc_mut()?.isolation = None;
|
||||
end_isolation(s)?;
|
||||
Ok(json!({"id": id.0}))
|
||||
}
|
||||
|
||||
/// The editable Shaper Group whose original shapes include `source`.
|
||||
fn holder(d: &Document, source: NodeId) -> Option<NodeId> {
|
||||
let sources = d.parent_of(source)?;
|
||||
let group = d.parent_of(sources)?;
|
||||
(d.node(group).and_then(source_group).is_some_and(|g| g.id == sources) && d.is_editable(source)).then_some(group)
|
||||
}
|
||||
|
||||
/// Take each of `shapes` (group, original shape) out of its Shaper Group and put it just above that
|
||||
/// group; the shapes taken out are selected, alone when `only`, else along with what was
|
||||
/// selected. [`refresh`] then computes each group's result again, or removes a group that holds no
|
||||
/// shape any more. Construction mode ends when one of those groups was in it.
|
||||
fn take_out(s: &mut Session, shapes: &[(NodeId, NodeId)], only: bool) -> Result<()> {
|
||||
let sources: Vec<NodeId> = {
|
||||
let d = &s.doc()?.doc;
|
||||
shapes.iter().filter_map(|(group, _)| d.node(*group).and_then(source_group).map(|n| n.id)).collect()
|
||||
};
|
||||
s.edit("Remove from Shaper Group", |d, sel| {
|
||||
// The topmost shape goes first, so each one put just above its group goes below the ones
|
||||
// that were above it.
|
||||
let mut order = shapes.to_vec();
|
||||
order.sort_by_key(|(_, source)| std::cmp::Reverse(d.position(*source).map_or(0, |p| p.1)));
|
||||
for (group, source) in order {
|
||||
let node = d.remove(source)?;
|
||||
let (parent, index, _) = d.position(group).ok_or(EngineError::NoNode(group))?;
|
||||
d.insert(parent, index + 1, Arc::unwrap_or_clone(node))?;
|
||||
}
|
||||
if only {
|
||||
sel.set(shapes.iter().map(|(_, source)| *source));
|
||||
}
|
||||
for &(_, source) in shapes {
|
||||
if !sel.contains(source) {
|
||||
sel.objects.push(source);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let st = s.doc_mut()?;
|
||||
if st.isolation.is_some_and(|i| sources.contains(&i)) {
|
||||
st.isolation = None;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "shaper.releaseSource";
|
||||
let source = id_param(p, "source").ok_or_else(|| bad(C, "source must be the id of a Shaper Group's original shape"))?;
|
||||
let held = holder(&s.doc()?.doc, source);
|
||||
let group = match p.get("group") {
|
||||
None | Some(Value::Null) => held,
|
||||
Some(_) => Some(id_param(p, "group").ok_or_else(|| bad(C, "group must be the id of a Shaper Group"))?),
|
||||
};
|
||||
let group = group.filter(|g| held == Some(*g)).ok_or_else(|| bad(C, "the shape is not an editable original shape of that Shaper Group"))?;
|
||||
take_out(s, &[(group, source)], true)?;
|
||||
Ok(json!({"id": source.0}))
|
||||
}
|
||||
|
||||
/// What `object.transform`'s `releaseOutside` checks once the move is made: each of `ids` that is
|
||||
/// an original shape of an editable Shaper Group, with that group and the bounds the group's
|
||||
/// original shapes have before the move.
|
||||
pub(crate) fn moving_sources(s: &Session, ids: &[NodeId]) -> Result<Vec<(NodeId, NodeId, Rect)>> {
|
||||
let d = &s.doc()?.doc;
|
||||
Ok(ids
|
||||
.iter()
|
||||
.filter_map(|&id| {
|
||||
let group = holder(d, id)?;
|
||||
Some((group, id, d.node(group).and_then(source_group).and_then(Node::geometric_bounds)?))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Do bounds `b` lie wholly outside `bounds`? Bounds that touch an edge, and flat bounds (a
|
||||
/// horizontal or vertical line's) on the same line as flat `bounds`, are not outside.
|
||||
pub(crate) fn wholly_outside(b: Rect, bounds: Rect) -> bool {
|
||||
b.x0 > bounds.x1 || b.x1 < bounds.x0 || b.y0 > bounds.y1 || b.y1 < bounds.y0
|
||||
}
|
||||
|
||||
/// Take out of their Shaper Groups the `moved` shapes ([`moving_sources`]) that now lie wholly
|
||||
/// outside the bounds their group's original shapes had before the move. Inside a drag the step
|
||||
/// is named for that. → whether any shape was taken out.
|
||||
pub(crate) fn release_outside(s: &mut Session, moved: &[(NodeId, NodeId, Rect)]) -> Result<bool> {
|
||||
let outside: Vec<(NodeId, NodeId)> = {
|
||||
let d = &s.doc()?.doc;
|
||||
moved
|
||||
.iter()
|
||||
.filter(|(_, id, before)| d.node(*id).and_then(Node::geometric_bounds).is_some_and(|b| wholly_outside(b, *before)))
|
||||
.map(|(group, id, _)| (*group, *id))
|
||||
.collect()
|
||||
};
|
||||
if outside.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
// Shapes the group can't be computed from without the ones taken out keep it as it is: the
|
||||
// edit is rolled back, and the move stays a plain move.
|
||||
if take_out(s, &outside, false).is_err() {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(it) = &mut s.doc_mut()?.interaction {
|
||||
it.label = "Remove from Shaper Group".into();
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint, Swatch, SwatchGroup, tint_percent};
|
||||
use vectorcraft_doc::pattern::pattern_paint;
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, node_colors};
|
||||
use vectorcraft_doc::swatches::{LinkFn, color_name, map_node_solid_paints, node_colors};
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
|
||||
use super::paint::paint_from;
|
||||
@@ -257,7 +257,7 @@ fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for n in &names {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut unlink))
|
||||
Ok(map_swatch_links(d, &mut unlink))
|
||||
})?;
|
||||
map_default_paints(s, &mut unlink);
|
||||
Ok(json!({"deleted": names, "unlinked": unlinked}))
|
||||
@@ -529,6 +529,130 @@ pub(super) fn map_default_paints(s: &mut Session, f: &mut LinkFn) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a swatch-link rewrite to the document ([`Document::map_solid_paints`]) and to the art of
|
||||
/// the brushes in its library, which is stored again only when a brush's paint changed. A Hue Shift
|
||||
/// key that was the color of a recolored paint takes the paint's new color. Returns the number of
|
||||
/// changed paints. Stored entries are stored again as they were read, except that a brush whose
|
||||
/// paint changed is merged into its entry ([`merge_stored`]), so fields the brush definition does
|
||||
/// not read are kept at every level.
|
||||
pub(super) fn map_swatch_links(d: &mut Document, f: &mut LinkFn) -> usize {
|
||||
use vectorcraft_brush::{Brush, BrushKind, Colorization};
|
||||
let mut n = d.map_solid_paints(f);
|
||||
// Each stored entry, its brush when it parses, the brush as written before the edit (only
|
||||
// when its paint changed), and whether its paint changed.
|
||||
let mut lib: Vec<(Value, Option<Brush>, Value, bool)> = match d.unknown.get(vectorcraft_brush::DOC_KEY) {
|
||||
Some(v) => v.as_array().into_iter().flatten().map(|e| (e.clone(), Brush::from_json(e.clone()).ok(), Value::Null, false)).collect(),
|
||||
None => {
|
||||
vectorcraft_brush::library(d).into_iter().map(|b| (serde_json::to_value(&b).unwrap_or_default(), Some(b), Value::Null, false)).collect()
|
||||
}
|
||||
};
|
||||
let mut in_brushes = 0;
|
||||
for (_, b, base, changed) in lib.iter_mut() {
|
||||
let Some(b) = b else { continue };
|
||||
let before_edit = serde_json::to_value(&*b).unwrap_or_default();
|
||||
let key = match b.kind.colorization() {
|
||||
Colorization::HueShift { key } => Some(key),
|
||||
_ => None,
|
||||
};
|
||||
let before = key.map(|_| art_paint_colors(b.art()));
|
||||
let in_brush = b.art_mut().into_iter().map(|art| map_node_solid_paints(art, f)).sum::<usize>();
|
||||
*changed = in_brush > 0;
|
||||
if *changed {
|
||||
*base = before_edit;
|
||||
}
|
||||
in_brushes += in_brush;
|
||||
let (Some(key), Some(before)) = (key, before) else { continue };
|
||||
// Colors are compared by hex value, as `art_colors` (Brush Options' key colors) lists them.
|
||||
let same = |a: &Color, b: &Color| a.to_hex() == b.to_hex();
|
||||
let after = art_paint_colors(b.art());
|
||||
// The key moves when the art's fills and strokes (not opacity masks) had the key color and
|
||||
// none has it now, to the first new color of a paint that had it.
|
||||
let moved = before.iter().any(|c| same(c, &key)) && !after.iter().any(|c| same(c, &key));
|
||||
let new_key = before.iter().zip(&after).find(|(o, _)| same(o, &key)).map(|(_, n)| *n);
|
||||
if let Some(key) = new_key.filter(|_| moved) {
|
||||
let c = match &mut b.kind {
|
||||
BrushKind::Art(a) => &mut a.colorization,
|
||||
BrushKind::Scatter(s) => &mut s.colorization,
|
||||
BrushKind::Pattern(p) => &mut p.colorization,
|
||||
BrushKind::Calligraphic(_) | BrushKind::Bristle(_) => continue,
|
||||
};
|
||||
*c = Colorization::HueShift { key };
|
||||
}
|
||||
}
|
||||
if in_brushes > 0 {
|
||||
let lib = lib
|
||||
.into_iter()
|
||||
.map(|(raw, b, base, changed)| match b.filter(|_| changed) {
|
||||
Some(b) => merge_stored(raw, &base, serde_json::to_value(b).unwrap_or_default()),
|
||||
None => raw,
|
||||
})
|
||||
.collect();
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(lib));
|
||||
n += in_brushes;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// `new` (a definition as written after an edit) with the parts of `raw` (its stored entry) that
|
||||
/// `base` (the definition as read from `raw` and written before the edit) does not hold. An object
|
||||
/// keeps the fields of `raw` that `base` leaves out, and drops the fields that `base` holds and
|
||||
/// `new` leaves out. An array takes the length of `new`, each element merged with the element at
|
||||
/// the same index in `raw`.
|
||||
fn merge_stored(raw: Value, base: &Value, new: Value) -> Value {
|
||||
match (raw, new) {
|
||||
(Value::Object(mut raw), Value::Object(new)) => {
|
||||
let mut out = serde_json::Map::new();
|
||||
for (k, v) in new {
|
||||
let v = match raw.remove(&k) {
|
||||
Some(r) => merge_stored(r, base.get(k.as_str()).unwrap_or(&Value::Null), v),
|
||||
None => v,
|
||||
};
|
||||
out.insert(k, v);
|
||||
}
|
||||
out.extend(raw.into_iter().filter(|(k, _)| base.get(k.as_str()).is_none()));
|
||||
Value::Object(out)
|
||||
}
|
||||
(Value::Array(raw), Value::Array(new)) => {
|
||||
let mut raw = raw.into_iter();
|
||||
let merged = new.into_iter().enumerate().map(|(i, v)| match raw.next() {
|
||||
Some(r) => merge_stored(r, base.get(i).unwrap_or(&Value::Null), v),
|
||||
None => v,
|
||||
});
|
||||
Value::Array(merged.collect())
|
||||
}
|
||||
(_, new) => new,
|
||||
}
|
||||
}
|
||||
|
||||
/// The solid colors of the fills and strokes under `nodes` (type runs' fills and strokes included,
|
||||
/// opacity masks left out), in tree order and with repeats, the paints [`vectorcraft_brush::art_colors`]
|
||||
/// counts.
|
||||
fn art_paint_colors(nodes: Vec<&vectorcraft_doc::Node>) -> Vec<Color> {
|
||||
fn walk(n: &vectorcraft_doc::Node, out: &mut Vec<Color>) {
|
||||
for it in &n.appearance.items {
|
||||
let (visible, paint) = match it {
|
||||
vectorcraft_doc::AppearanceItem::Fill(f) => (f.visible, &f.paint),
|
||||
vectorcraft_doc::AppearanceItem::Stroke(s) => (s.visible, &s.paint),
|
||||
};
|
||||
out.extend(paint.color().filter(|_| visible));
|
||||
}
|
||||
if let vectorcraft_doc::NodeKind::Text(t) = &n.kind {
|
||||
for r in &t.runs {
|
||||
let stroke = (r.style.stroke_width > 0.0).then_some(&r.style.stroke);
|
||||
out.extend([Some(&r.style.fill), stroke].into_iter().flatten().filter_map(Paint::color));
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
walk(c, out);
|
||||
}
|
||||
}
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
walk(n, &mut out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "swatch.edit";
|
||||
let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?.to_string();
|
||||
@@ -592,7 +716,7 @@ fn swatch_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut |c, l, t| relink.apply(c, l, t)))
|
||||
Ok(map_swatch_links(d, &mut |c, l, t| relink.apply(c, l, t)))
|
||||
})?;
|
||||
relink.defaults(s);
|
||||
Ok(json!({"name": to, "relinked": relinked}))
|
||||
@@ -747,7 +871,7 @@ fn swatch_merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for n in merged {
|
||||
d.remove_swatch(n);
|
||||
}
|
||||
Ok(d.map_solid_paints(&mut relink))
|
||||
Ok(map_swatch_links(d, &mut relink))
|
||||
})?;
|
||||
map_default_paints(s, &mut relink);
|
||||
Ok(json!({"name": keep, "merged": merged, "relinked": relinked}))
|
||||
@@ -803,7 +927,7 @@ fn swatch_spot_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.filter(|w| w.spot && matches!(w.paint, Paint::Solid { color: Color::Lab { .. }, swatch: None, .. }))
|
||||
.filter_map(|w| Some((w.name.clone(), d.global_color(&w.name)?)))
|
||||
.collect();
|
||||
let relinked = d.map_solid_paints(&mut follow_bases(&bases));
|
||||
let relinked = map_swatch_links(d, &mut follow_bases(&bases));
|
||||
Ok((relinked, bases))
|
||||
})?;
|
||||
map_default_paints(s, &mut follow_bases(&bases));
|
||||
@@ -880,7 +1004,7 @@ pub(super) fn rewrite_group(d: &mut Document, group: &str, colors: &[Color], ren
|
||||
let g = &mut d.swatch_groups[gi];
|
||||
(g.name, g.swatches) = (name.clone(), swatches);
|
||||
let names = g.swatches.iter().map(|w| w.name.clone()).collect();
|
||||
let relinked = if relinks.is_empty() { 0 } else { d.map_solid_paints(&mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
|
||||
let relinked = if relinks.is_empty() { 0 } else { map_swatch_links(d, &mut |c, l, t| relinks.iter().any(|r| r.apply(c, l, t))) };
|
||||
Ok(GroupEdit { name, swatches: names, relinks, relinked })
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ use vectorcraft_color::{Paint, Swatch, SwatchGroup, SwatchLibrary, default_swatc
|
||||
use super::fileio::{create_dir, extension, read_file, write_file, write_new_file};
|
||||
use super::menucmds::squash;
|
||||
use super::package::free_name;
|
||||
use super::swatch::{map_default_paints, str_list, swatch_json};
|
||||
use super::swatch::{map_default_paints, map_swatch_links, str_list, swatch_json};
|
||||
use super::{Color, CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -644,7 +644,7 @@ fn reset_defaults(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(d.swatches, d.swatch_groups) = (defaults.into(), groups.into());
|
||||
added.extend(d.swatches_iter().filter(|w| !before.contains(&w.name)).map(|w| w.name.clone()));
|
||||
live.extend(d.swatches_iter().filter(|w| w.global).map(|w| w.name.clone()));
|
||||
d.map_solid_paints(&mut |_, l, _| unlink_dead(&live, l));
|
||||
map_swatch_links(d, &mut |_, l, _| unlink_dead(&live, l));
|
||||
return Ok(added);
|
||||
}
|
||||
for (i, w) in defaults.into_iter().enumerate() {
|
||||
|
||||
@@ -37,7 +37,7 @@ pub use journal::Journal;
|
||||
pub use tooling::{UiRequest, ViewInfo};
|
||||
pub use vectorcraft_doc as doc;
|
||||
pub use vectorcraft_doc::cancel;
|
||||
pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, simplify_level_tolerance};
|
||||
pub use vectorcraft_pathops::{SIMPLIFY_LEVEL, SMOOTH_AUTO_LEVEL, simplify_level_tolerance};
|
||||
pub use vectorcraft_tools as tools;
|
||||
|
||||
/// Coordinates beyond this (points) are rejected: ~1,400 m, far past Illustrator's large canvas.
|
||||
|
||||
@@ -256,6 +256,11 @@ impl Session {
|
||||
// A preview that left the document as it was records no Transform Again either (as
|
||||
// `keep_interaction` records no undo step): the last real transformation stands.
|
||||
let changed = !Arc::ptr_eq(&it.doc, &st.doc);
|
||||
// How far a drag of direct-selected anchors moved them (`path.moveAnchors`).
|
||||
let moved = vectorcraft_geom::Vec2::new(
|
||||
preview.1.get("dx").and_then(Value::as_f64).unwrap_or(0.0),
|
||||
preview.1.get("dy").and_then(Value::as_f64).unwrap_or(0.0),
|
||||
);
|
||||
st.keep_interaction(it);
|
||||
if preview.0 == "object.transform" {
|
||||
let m = cmd::matrix_param(&preview.1, "matrix");
|
||||
@@ -270,6 +275,16 @@ impl Session {
|
||||
st.last_transform = Some(LastTransform::Matrix(m, options));
|
||||
st.last_perspective = None;
|
||||
}
|
||||
} else if preview.0 == "path.moveAnchors" && (moved.x != 0.0 || moved.y != 0.0) {
|
||||
// A drag of direct-selected anchors (from an anchor, or from a fill with anchors
|
||||
// selected): Transform Again moves the selected anchors and the objects selected whole
|
||||
// by the same distance. A drag that ends where it began leaves the last transform.
|
||||
let mut options = serde_json::json!({ "copy": false });
|
||||
if let Some(patterns) = preview.1.get("patterns") {
|
||||
options["patterns"] = patterns.clone();
|
||||
}
|
||||
st.last_transform = Some(LastTransform::Matrix(vectorcraft_geom::Affine::translate(moved), options));
|
||||
st.last_perspective = None;
|
||||
} else if preview.0 == "object.transformEach" && changed {
|
||||
// Transform Again repeats it as it stood when OK was pressed.
|
||||
st.last_transform = Some(LastTransform::Each(preview.1.clone()));
|
||||
|
||||
@@ -140,6 +140,7 @@ mod shortcutset;
|
||||
mod simplify_part;
|
||||
mod slices;
|
||||
mod smartguides;
|
||||
mod smooth_path;
|
||||
mod store;
|
||||
mod strokegeom;
|
||||
mod strokegradient;
|
||||
@@ -1671,6 +1672,71 @@ fn lock_hide_unlock_all_and_show_all_with_nothing_to_do_record_no_undo_step() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Unlock All and Show All with `scope: "group"` unlock or show only the objects inside the groups
|
||||
/// that hold the selected objects: the group nearest above a selected object, or a selected group
|
||||
/// itself. With no selected object in a group they are refused and change nothing.
|
||||
#[test]
|
||||
fn unlock_all_and_show_all_within_the_groups_of_the_selection() {
|
||||
let mut s = session();
|
||||
let ids = |s: &mut Session, cmd: &str| NodeId(s.execute(cmd, &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let [a, b, m, k, h, s1, s2, j, j2] = [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0].map(|i| rect(&mut s, i * 30.0, 0.0, 20.0, 20.0));
|
||||
// `g` holds `m`, `k`, `h` and the group `sub`, which holds `s1` and `s2`; `other` holds `j` and `j2`.
|
||||
s.execute("select.set", &json!({"ids": [s1.0, s2.0]})).unwrap();
|
||||
let sub = ids(&mut s, "object.group");
|
||||
s.execute("select.set", &json!({"ids": [m.0, k.0, h.0, sub.0]})).unwrap();
|
||||
let g = ids(&mut s, "object.group");
|
||||
s.execute("select.set", &json!({"ids": [j.0, j2.0]})).unwrap();
|
||||
let other = ids(&mut s, "object.group");
|
||||
for id in [a, k, s1, j] {
|
||||
s.execute("layer.setProps", &json!({"id": id.0, "locked": true})).unwrap();
|
||||
}
|
||||
s.execute("layer.setProps", &json!({"id": h.0, "visible": false})).unwrap();
|
||||
let locked = |s: &Session| [a, k, s1, j].map(|id| s.doc().unwrap().doc.node(id).unwrap().locked);
|
||||
let group = json!({"scope": "group"});
|
||||
|
||||
// A member of `g`, or `g` itself, selected: `k` and `s1` inside `sub` are unlocked and selected.
|
||||
for selected in [m, g] {
|
||||
s.execute("select.set", &json!({"ids": [selected.0]})).unwrap();
|
||||
let before = history(&s);
|
||||
assert_eq!(s.execute("object.unlockAll", &group).unwrap(), json!({"count": 2}), "{selected:?}");
|
||||
assert_eq!(locked(&s), [true, false, false, true], "{selected:?}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [k, s1], "{selected:?}");
|
||||
assert_eq!(history(&s).0, before.0 + 1, "{selected:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// `s2` selected: only `sub` counts. With `m` selected too, `sub`'s objects are taken once.
|
||||
for (selected, count) in [(vec![s2.0], 1), (vec![m.0, s2.0], 2)] {
|
||||
s.execute("select.set", &json!({"ids": selected})).unwrap();
|
||||
assert_eq!(s.execute("object.unlockAll", &group).unwrap(), json!({"count": count}), "{selected:?}");
|
||||
assert!(!s.doc().unwrap().doc.node(s1).unwrap().locked, "{selected:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
// Show All within `g` shows `h` and selects it; `other` holds nothing hidden.
|
||||
s.execute("select.set", &json!({"ids": [m.0]})).unwrap();
|
||||
assert_eq!(s.execute("object.showAll", &group).unwrap(), json!({"count": 1}));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [h]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
assert_eq!(s.execute("object.showAll", &group).unwrap(), json!({"count": 0}));
|
||||
|
||||
// `b`, outside every group, or nothing selected: refused, and nothing changes.
|
||||
for selected in [vec![b.0], vec![]] {
|
||||
s.execute("select.set", &json!({"ids": selected})).unwrap();
|
||||
let (doc, before) = (s.doc().unwrap().doc.clone(), history(&s));
|
||||
for cmd in ["object.unlockAll", "object.showAll"] {
|
||||
let e = s.execute(cmd, &group).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::Other(msg) if msg == "nothing selected is in a group"), "{cmd}: {e:?}");
|
||||
}
|
||||
assert!(s.doc().unwrap().doc == doc && history(&s) == before, "{selected:?}");
|
||||
}
|
||||
// Any other scope is refused; `scope: "document"` and no scope take the whole document.
|
||||
let e = s.execute("object.unlockAll", &json!({"scope": "layer"})).unwrap_err();
|
||||
assert!(matches!(&e, EngineError::BadParams { msg, .. } if msg == "`scope` must be one of document, group"), "{e:?}");
|
||||
assert_eq!(s.execute("object.unlockAll", &json!({"scope": "document"})).unwrap(), json!({"count": 4}));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.execute("object.showAll", &json!({})).unwrap(), json!({"count": 1}));
|
||||
}
|
||||
|
||||
/// Set Bounds with the current position and size records no undo step on a rotated object, with
|
||||
/// and without Use Preview Bounds, and on an unrotated object with Use Preview Bounds. In these
|
||||
/// cases computing the transform can leave a rounding error in it.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Blend Options (defaults with nothing selected, `object.blend.info`) and the Blend tool:
|
||||
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18).
|
||||
//! start points from clicked anchors, adding keys to a blend instead of nesting it (M8.18), and
|
||||
//! Escape dropping the first pick.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::live::{BlendOrientation, BlendSpacing, BlendSpec};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
|
||||
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -140,6 +141,32 @@ fn the_blend_tool_blends_from_clicked_anchors_and_keeps_adding() {
|
||||
assert!(matches!(r.unwrap().as_slice(), [UiRequest::Dialog(k, _)] if k == "blendOptions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_drops_the_blend_tools_first_pick() {
|
||||
let mut s = session();
|
||||
let (a, b, c) = (rect(&mut s, 0.0, 0.0, 40.0), rect(&mut s, 200.0, 0.0, 40.0), rect(&mut s, 400.0, 0.0, 40.0));
|
||||
s.select_tool("blend", ViewInfo::default()).unwrap();
|
||||
let click = |s: &mut Session, x: f64, y: f64| s.pointer(&PointerEvent::new(PointerKind::Down, x, y), ViewInfo::default()).unwrap();
|
||||
let esc = |s: &mut Session| s.tool_key(ToolKey::Escape, Mods::default(), ViewInfo::default()).unwrap();
|
||||
click(&mut s, 20.0, 20.0);
|
||||
assert!(s.tool_busy());
|
||||
let (layers, depth) = (s.doc().unwrap().doc.layers.clone(), s.doc().unwrap().history.undo.len());
|
||||
assert!(esc(&mut s).is_empty());
|
||||
assert!(!s.tool_busy(), "Escape drops the pick");
|
||||
assert_eq!((&s.doc().unwrap().doc.layers, s.doc().unwrap().history.undo.len()), (&layers, depth), "and changes nothing");
|
||||
// The next two clicks blend b and c; a is not part of it.
|
||||
click(&mut s, 220.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, layers, "b is picked first");
|
||||
click(&mut s, 420.0, 20.0);
|
||||
let g = s.doc().unwrap().selection.objects[0];
|
||||
assert_eq!(spec(&s, g).0, vec![b, c]);
|
||||
// Escape after the blend is made: the next click starts a new blend instead of adding to it.
|
||||
esc(&mut s);
|
||||
click(&mut s, 20.0, 20.0);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
|
||||
assert_eq!(spec(&s, g).0, vec![b, c], "a was not added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_points_round_trip_and_old_blends_load() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -297,6 +297,85 @@ fn shape_builder_fill_option_and_untouched_shapes_kept() {
|
||||
assert_eq!(outline(&node(&s, sq)).0.anchor_count(), 4);
|
||||
}
|
||||
|
||||
fn parent(s: &Session, id: NodeId) -> Option<NodeId> {
|
||||
s.doc().unwrap().doc.parent_of(id)
|
||||
}
|
||||
|
||||
/// The merge replaces only the shapes it uses: a selected type object stays as it was.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_selected_type_object() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 300, "y": 300, "text": "Label"})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, t.0]})).unwrap();
|
||||
let before = node(&s, t);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(t), Some(&before));
|
||||
assert_eq!(layer_children(&s), 2, "the merged shape and the type");
|
||||
assert!(ids(&r).contains(&t), "{r}");
|
||||
}
|
||||
|
||||
/// A hidden member of a selected group stays hidden in its group, with the merged shape.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_hidden_group_member() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let c = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, c.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.hide", &json!({"ids": [c.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [125, 50]]})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert_eq!((parent(&s, c), node(&s, c).visible, parent(&s, merged)), (Some(g), false, Some(g)));
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
/// A locked member of a selected group is neither cut nor moved, and does not cut the others.
|
||||
#[test]
|
||||
fn shape_builder_merge_leaves_a_locked_group_member() {
|
||||
let mut s = session();
|
||||
let (_, b) = two(&mut s);
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.lock", &json!({"ids": [b.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let before = node(&s, b);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(b), Some(&before));
|
||||
assert_eq!(parent(&s, b), Some(g));
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
assert!((area(&s, merged) - 10000.0).abs() < 1.0, "A whole: {}", area(&s, merged));
|
||||
}
|
||||
|
||||
/// In a clip group, the clipping path stays and the merged shape stays inside the group.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_clip_groups_clipping_path() {
|
||||
let mut s = session();
|
||||
let (a, b) = two(&mut s);
|
||||
let m = rect(&mut s, 0.0, 0.0, 120.0, 100.0);
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0, m.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"ids": [g.0], "points": [[25, 50], [125, 50]]})).unwrap();
|
||||
let merged = NodeId(r["merged"].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
let NodeKind::Group { children, clip: true } = &gn.kind else { panic!("a clip group: {:?}", gn.kind) };
|
||||
assert_eq!(children.first().map(|c| c.id), Some(m), "the clipping path");
|
||||
assert_eq!(parent(&s, merged), Some(g));
|
||||
}
|
||||
|
||||
/// A group goes when deleting regions leaves it empty, and stays while anything is left in it.
|
||||
#[test]
|
||||
fn shape_builder_erase_removes_only_a_group_it_empties() {
|
||||
let mut s = session();
|
||||
two(&mut s);
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50]], "erase": true})).unwrap();
|
||||
assert_eq!((ids(&r), node(&s, g).children().unwrap().len()), (vec![g], 2));
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[60, 50], [140, 50]], "erase": true})).unwrap();
|
||||
assert!(ids(&r).is_empty() && s.doc().unwrap().doc.node(g).is_none(), "{r}");
|
||||
assert_eq!(layer_children(&s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_builder_miss_is_an_error_and_undo_restores() {
|
||||
let mut s = session();
|
||||
@@ -2615,12 +2694,45 @@ fn live_paint_bucket_tool_fills_face() {
|
||||
let gn = node(&s, g);
|
||||
let f = vectorcraft_tools::builder::face_at(&gn, vectorcraft_geom::Point::new(125.0, 50.0)).unwrap();
|
||||
assert_eq!(f.appearance.fill_paint(), s.paint.fill);
|
||||
// Live Paint Selection selects the face.
|
||||
// A Live Paint Selection click selects the face.
|
||||
s.select_tool("livePaintSelection", v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, 125.0, 50.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, 125.0, 50.0), v).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![f.id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_paint_selection_marquee_selects_what_it_touches() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let v = ViewInfo::default();
|
||||
s.select_tool("livePaintSelection", v).unwrap();
|
||||
let drag = |s: &mut Session, from: (f64, f64), to: (f64, f64), m: Mods, escape: bool| {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from.0, from.1).with_mods(m), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, to.0, to.1).with_mods(m), v).unwrap();
|
||||
if escape {
|
||||
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to.0, to.1).with_mods(m), v).unwrap();
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
};
|
||||
let gn = node(&s, g);
|
||||
let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id;
|
||||
let edge = |x: f64| vectorcraft_tools::builder::edge_near(&gn, Point::new(x, 50.0), 0.5).unwrap().id;
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
// Across the left side: the left face and that side.
|
||||
assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), Mods::default(), false), vec![face(25.0), edge(0.0)]);
|
||||
// Shift-drag across the right side adds the right face and that side.
|
||||
assert_eq!(drag(&mut s, (160.0, 40.0), (140.0, 60.0), shift, false), vec![face(25.0), edge(0.0), face(125.0), edge(150.0)]);
|
||||
// Shift-drag across the left side again takes those out.
|
||||
assert_eq!(drag(&mut s, (-10.0, 40.0), (40.0, 60.0), shift, false), vec![face(125.0), edge(150.0)]);
|
||||
// Escape during the drag, with or without Shift, from a press on a face or outside the group:
|
||||
// the selection stays as it was.
|
||||
for (from, m) in [((-10.0, 40.0), shift), ((25.0, 50.0), Mods::default()), ((25.0, 50.0), shift)] {
|
||||
assert_eq!(drag(&mut s, from, (40.0, 60.0), m, true), vec![face(125.0), edge(150.0)], "{from:?} {m:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn line(s: &mut Session, a: (f64, f64), b: (f64, f64)) -> NodeId {
|
||||
let r = s.execute("shape.line", &json!({"x1": a.0, "y1": a.1, "x2": b.0, "y2": b.1})).unwrap();
|
||||
NodeId(r["id"].as_u64().unwrap())
|
||||
@@ -2763,11 +2875,77 @@ fn shape_builder_keeps_the_edges_of_an_unfilled_live_paint_group() {
|
||||
let c = rect(&mut s, 160.0, 20.0, 100.0, 20.0);
|
||||
set_fill(&mut s, c, Color::rgb(0.0, 1.0, 0.0));
|
||||
s.execute("select.set", &json!({"ids": [g.0, c.0]})).unwrap();
|
||||
let before = node(&s, g);
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
// The merge does not use the group, so it stays as it was, with every edge: the outline of
|
||||
// 0..150 × 0..100 and the edges at x = 50 and 100.
|
||||
assert_eq!(s.doc().unwrap().doc.node(g), Some(&before));
|
||||
assert!(ids(&r).contains(&g), "{r}");
|
||||
let edges: Vec<NodeId> = vectorcraft_tools::builder::edges(&before).iter().map(|e| e.id).collect();
|
||||
assert!((stroked_length(&s, &edges) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &edges));
|
||||
}
|
||||
|
||||
/// A locked face of a Live Paint group the merge replaces is not a region, and a copy of it stays,
|
||||
/// still locked.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_locked_live_paint_face() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let locked = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id;
|
||||
s.execute("object.lock", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let map = s.execute("shapeBuilder.regions", &json!({})).unwrap();
|
||||
assert!(map["regions"].as_array().unwrap().iter().all(|r| !r["sources"].as_array().unwrap().iter().any(|i| *i == locked.0)), "{map}");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2, "{r}");
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
// Every edge of the group: the outline of 0..150 × 0..100 and the edges at x = 50 and 100.
|
||||
assert!((stroked_length(&s, &out) - 700.0).abs() < 1.0, "{}", stroked_length(&s, &out));
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect();
|
||||
assert_eq!(kept.len(), 1, "{r}");
|
||||
assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id));
|
||||
}
|
||||
|
||||
/// A hidden face of a Live Paint group the merge replaces is not a region, and a copy of it stays,
|
||||
/// still hidden.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_a_hidden_live_paint_face() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
let hidden = vectorcraft_tools::builder::face_at(&node(&s, g), vectorcraft_geom::Point::new(125.0, 50.0)).unwrap().id;
|
||||
s.execute("object.hide", &json!({"ids": [hidden.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert_eq!(r["regions"], 2, "{r}");
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| !n.visible).collect();
|
||||
assert_eq!(kept.len(), 1, "{r}");
|
||||
assert!((area(&s, kept[0].id) - 5000.0).abs() < 1.0, "{}", area(&s, kept[0].id));
|
||||
}
|
||||
|
||||
/// A locked face without a fill and a locked edge without a stroke, in a Live Paint group the
|
||||
/// merge replaces, each keep a copy, still locked.
|
||||
#[test]
|
||||
fn shape_builder_merge_keeps_unpainted_locked_live_paint_members() {
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
s.execute("livePaint.fill", &json!({"group": g.0, "point": [125, 50], "none": true})).unwrap();
|
||||
let n = node(&s, g);
|
||||
let face = vectorcraft_tools::builder::face_at(&n, Point::new(125.0, 50.0)).unwrap().id;
|
||||
let edge = vectorcraft_tools::builder::visible_edge_near(&n, Point::new(150.0, 50.0), 4.0).unwrap().id;
|
||||
let names = (node(&s, face).name, node(&s, edge).name);
|
||||
assert_ne!(names.0, names.1);
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(edge).unwrap().appearance.set_stroke(Paint::None);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("object.lock", &json!({"ids": [face.0, edge.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[25, 50], [75, 50]]})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node(g).is_none());
|
||||
let kept: Vec<Node> = ids(&r).into_iter().map(|i| node(&s, i)).filter(|n| n.locked).collect();
|
||||
let faces = kept.iter().filter(|n| n.name == names.0 && n.appearance.fill_paint().is_none() && (area(&s, n.id) - 5000.0).abs() < 1.0).count();
|
||||
let edges = kept.iter().filter(|n| n.name == names.1 && n.appearance.stroke_paint().is_none()).count();
|
||||
assert_eq!((kept.len(), faces, edges), (2, 1, 1), "{r}");
|
||||
}
|
||||
|
||||
/// A plain group whose name starts with "Live Paint" is not a Live Paint group: the Shape Builder
|
||||
@@ -2791,7 +2969,9 @@ fn shape_builder_takes_the_paths_of_a_plain_group_named_live_paint() {
|
||||
assert_eq!(map["regions"].as_array().unwrap().len(), 2, "{map}");
|
||||
let r = s.execute("shapeBuilder.merge", &json!({"points": [[200, 30]]})).unwrap();
|
||||
let out = ids(&r);
|
||||
assert!(out.iter().any(|i| (area(&s, *i) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}");
|
||||
// The merge keeps the unused group whole, so its rectangle is inside an id of the reply.
|
||||
let kept_area = |n: &Node| n.children().map_or_else(|| area(&s, n.id), |c| c.iter().map(|c| area(&s, c.id)).sum());
|
||||
assert!(out.iter().any(|i| (kept_area(&node(&s, *i)) - 10000.0).abs() < 1.0), "the group's rectangle is kept: {r}");
|
||||
// A stroke of the group's rectangle splits as a plain path's does.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0]})).unwrap();
|
||||
|
||||
@@ -154,6 +154,115 @@ fn images_bend_as_a_raster_mesh_warp() {
|
||||
assert_eq!(svg.matches("<image").count(), 1, "the pieces share the image's pixels");
|
||||
}
|
||||
|
||||
/// A selected embedded 2×2 image, 80×80 pt at (100, 100): a red column left of a blue one.
|
||||
fn two_color_image(s: &mut Session) -> NodeId {
|
||||
let img = image::RgbaImage::from_fn(2, 2, |x, _| if x == 0 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.edit("Place", |d, sel| {
|
||||
d.images.insert("two".into(), vectorcraft_doc::ImageBlob::png(png));
|
||||
let id = d.alloc_id();
|
||||
let xf = Affine::translate((100.0, 100.0)) * Affine::scale(40.0);
|
||||
let im = vectorcraft_doc::ImageObject { key: "two".into(), width: 2, height: 2, xf, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(d.default_layer(), 0, Node::new(id, NodeKind::Image(Box::new(im))))?;
|
||||
sel.set([id]);
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// The pixels well inside the image's frame (the clip edges left out) that are a mix of its red
|
||||
/// and blue rather than one of them.
|
||||
fn mixed_pixels(s: &Session) -> usize {
|
||||
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, Rect::new(110.0, 110.0, 170.0, 170.0), 2.0, true);
|
||||
let mut mixed = 0;
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let p = img.pixel(x, y);
|
||||
mixed += (p[0] > 40 && p[2] > 40) as usize;
|
||||
}
|
||||
}
|
||||
mixed
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anti_alias_off_draws_distorted_images_without_smoothing() {
|
||||
let mut s = session();
|
||||
let im = two_color_image(&mut s);
|
||||
sel(&mut s, &[im]);
|
||||
let e = arch(&mut s, 2.0, json!({}));
|
||||
let smooth = mixed_pixels(&s);
|
||||
assert!(smooth > 400, "with Anti-Alias on the colors blend where the pixels meet: {smooth}");
|
||||
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
|
||||
let mut pieces = 0;
|
||||
for n in evaluated(&s, e) {
|
||||
n.walk(&mut |c| pieces += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
|
||||
}
|
||||
assert!(pieces >= 32, "{pieces} pixelated pieces");
|
||||
let hard = mixed_pixels(&s);
|
||||
assert!(hard * 50 < smooth, "with Anti-Alias off each pixel is red or blue: {hard} mixed of {smooth}");
|
||||
// The SVG asks viewers not to smooth the pieces either.
|
||||
let svg = |s: &mut Session| {
|
||||
let b64 = s.execute("document.export", &json!({"format": "svg"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
String::from_utf8(vectorcraft_format::base64_decode(&b64).unwrap()).unwrap()
|
||||
};
|
||||
assert!(svg(&mut s).matches("image-rendering").count() >= 32);
|
||||
s.execute("object.envelope.options", &json!({"antiAlias": true})).unwrap();
|
||||
assert!(!svg(&mut s).contains("image-rendering"));
|
||||
s.execute("object.envelope.options", &json!({"antiAlias": false})).unwrap();
|
||||
// Expanded, the pieces keep it through a native save.
|
||||
s.execute("object.envelope.expand", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(d, false)).unwrap();
|
||||
let mut kept = 0;
|
||||
back.walk(|c| kept += (matches!(c.kind, NodeKind::Image(_)) && c.extras().pixelated) as usize);
|
||||
assert_eq!(kept, pieces);
|
||||
}
|
||||
|
||||
fn clip_groups_in(nodes: &[Node]) -> usize {
|
||||
let mut n = 0;
|
||||
for c in nodes {
|
||||
c.walk(&mut |c| n += matches!(c.kind, NodeKind::Group { clip: true, .. }) as usize);
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_masks_distorted_images_instead_of_clipping_them() {
|
||||
let mut s = session();
|
||||
let im = red_image(&mut s);
|
||||
sel(&mut s, &[im]);
|
||||
let e = arch(&mut s, 80.0, json!({}));
|
||||
let region = Rect::new(80.0, 60.0, 160.0, 160.0);
|
||||
let clipped = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
|
||||
assert!(clip_groups_in(&evaluated(&s, e)) >= 32);
|
||||
s.execute("object.envelope.options", &json!({"preserveShape": "transparency"})).unwrap();
|
||||
let out = evaluated(&s, e);
|
||||
assert_eq!(clip_groups_in(&out), 0, "no clipping masks");
|
||||
let (mut images, mut masked) = (0, 0);
|
||||
for n in &out {
|
||||
n.walk(&mut |c| {
|
||||
if matches!(c.kind, NodeKind::Image(_)) {
|
||||
images += 1;
|
||||
masked += c.extras().mask.as_ref().is_some_and(|m| m.clip && !m.invert) as usize;
|
||||
}
|
||||
});
|
||||
}
|
||||
assert!(images >= 32 && masked == images, "{masked} of {images} pieces masked");
|
||||
// It draws as the clipping masks did.
|
||||
let alpha = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, region, 2.0, true);
|
||||
let (mut sum, mut n) = (0u64, 0u64);
|
||||
for y in 0..clipped.height {
|
||||
for x in 0..clipped.width {
|
||||
let (a, b) = (clipped.pixel(x, y), alpha.pixel(x, y));
|
||||
sum += (0..4).map(|i| (a[i] as i32 - b[i] as i32).unsigned_abs() as u64).sum::<u64>();
|
||||
n += 4;
|
||||
}
|
||||
}
|
||||
assert!(sum < n, "mean difference {} per channel", sum as f64 / n as f64);
|
||||
assert!(alpha.pixel(80, 74)[0] > 200 && alpha.pixel(80, 74)[1] < 60, "the bent image shows: {:?}", alpha.pixel(80, 74));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symbols_bend_with_the_envelope() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -492,6 +492,112 @@ 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.
|
||||
#[test]
|
||||
fn hide_unselected_artwork_hides_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 shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::<Vec<_>>();
|
||||
sel(&mut s, &[a, m, c2]);
|
||||
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!(node(&s, k).locked);
|
||||
assert_eq!((undo_len(&s), selected(&s)), (steps + 1, vec![a, m, c2]));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shown(&s, &[b, n, p]), [true; 3]);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("object.showAll", &json!({})).unwrap();
|
||||
assert_eq!(shown(&s, &[b, n, p, h]), [true; 4]);
|
||||
}
|
||||
|
||||
/// Hide › Unselected Artwork leaves the art on a locked artboard as it is, as Select All leaves it unselected.
|
||||
#[test]
|
||||
fn hide_unselected_artwork_leaves_the_art_on_a_locked_artboard() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 0.0, 0.0, 10.0, 10.0), rect(&mut s, 20.0, 0.0, 10.0, 10.0));
|
||||
let on_locked = rect(&mut s, 2050.0, 50.0, 10.0, 10.0);
|
||||
s.execute("artboard.new", &json!({"x": 2000, "y": 0, "width": 400, "height": 400})).unwrap();
|
||||
s.execute("artboard.setProps", &json!({"index": 1, "locked": true})).unwrap();
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(s.execute("object.hide.unselected", &json!({})).unwrap(), json!({"count": 1}));
|
||||
assert_eq!([a, b, on_locked].map(|id| node(&s, id).visible), [true, false, true]);
|
||||
}
|
||||
|
||||
/// In isolation mode Hide › Unselected Artwork is disabled: the menu item is off, and the command returns an error that
|
||||
/// names isolation mode and changes nothing. While Lock Guides is on it leaves the guides as they are. With nothing
|
||||
/// left to hide it records no undo step and returns a count of 0. A compound path that holds a selected path stays as
|
||||
/// it is.
|
||||
#[test]
|
||||
fn hide_unselected_artwork_keeps_locked_guides_and_is_disabled_in_isolation_mode() {
|
||||
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 hide = |s: &mut Session| s.execute("object.hide.unselected", &json!({})).unwrap();
|
||||
let shown = |s: &Session, ids: &[NodeId]| ids.iter().map(|id| node(s, *id).visible).collect::<Vec<_>>();
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
select_with_a_step_to_redo(&mut s, &[m]);
|
||||
let before = history(&s);
|
||||
let info = find_command("object.hide.unselected").unwrap().info(&s);
|
||||
assert!(!info.enabled && info.disabled_reason.as_deref() == Some("exit isolation mode first"), "{info:?}");
|
||||
let e = s.execute("object.hide.unselected", &json!({})).unwrap_err();
|
||||
assert!(matches!(e, EngineError::Disabled(..)) && e.to_string().contains("isolation mode"), "{e}");
|
||||
assert_eq!(shown(&s, &[m, n, out, guide]), [true; 4]);
|
||||
assert_eq!((history(&s), selected(&s)), (before, vec![m]));
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
sel(&mut s, &[m]);
|
||||
assert!(find_command("object.hide.unselected").unwrap().info(&s).enabled);
|
||||
assert_eq!(hide(&mut s), json!({"count": 2}));
|
||||
assert_eq!(shown(&s, &[n, out, guide]), [false, false, true]);
|
||||
select_with_a_step_to_redo(&mut s, &[m]);
|
||||
let before = history(&s);
|
||||
assert_eq!(hide(&mut s), json!({"count": 0}));
|
||||
assert_eq!(history(&s), before);
|
||||
// With Lock Guides off, the guide path is hidden like other art.
|
||||
s.execute("view.guides.lock", &json!({"locked": false})).unwrap();
|
||||
sel(&mut s, &[m]);
|
||||
assert_eq!(hide(&mut s), json!({"count": 1}));
|
||||
assert_eq!(shown(&s, &[guide]), [false]);
|
||||
// A compound path that holds the selected path stays as it is with its paths.
|
||||
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 c = id_of(&s.execute("object.compoundPath.make", &json!({})).unwrap());
|
||||
sel(&mut s, &[hole]);
|
||||
assert_eq!(hide(&mut s), json!({"count": 1}));
|
||||
assert_eq!(shown(&s, &[g, c, outer, hole]), [false, true, true, true]);
|
||||
}
|
||||
|
||||
// ---------- Transform Each ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Opacity-mask editing and the Transparency panel's command reach (M3.25): commands act on the
|
||||
//! masked object while its mask is edited, accept ids without a selection, take opacity in percent,
|
||||
//! report mixed values, and saves and exports never carry the editing layer.
|
||||
//! report mixed values, and saves and exports never carry the editing layer. Mask editing is not
|
||||
//! entered in isolation mode, and isolation mode is not entered while a mask is edited.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_geom::Affine;
|
||||
@@ -228,6 +229,103 @@ fn one_object_gets_an_empty_mask_drawn_in_editing_mode() {
|
||||
assert_eq!((s.doc().unwrap().isolation, s.doc().unwrap().active_layer), (Some(layer), Some(layer)));
|
||||
}
|
||||
|
||||
/// The command `cmd` with `p` is off, for `why`.
|
||||
fn off(s: &mut Session, cmd: &str, p: Value, why: &str) {
|
||||
let err = s.execute(cmd, &p).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(id, w) if id == cmd && w == why), "{cmd} {p}: {err}");
|
||||
}
|
||||
|
||||
/// Isolation mode is not entered while an opacity mask is edited: Enter Isolation Mode is off for
|
||||
/// a group in the mask art and for one outside it, and mask editing goes on. After Stop Editing
|
||||
/// Opacity Mask it isolates again.
|
||||
#[test]
|
||||
fn isolation_mode_is_not_entered_while_an_opacity_mask_is_edited() {
|
||||
let mut s = session();
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let (a, b) = (rect(s, x, 20.0), rect(s, x + 30.0, 20.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let outside = group(&mut s, 220.0);
|
||||
editing_mask(&mut s);
|
||||
let layer = s.doc().unwrap().isolation;
|
||||
let inside = group(&mut s, 0.0);
|
||||
for id in [inside, outside] {
|
||||
off(&mut s, "object.isolate", json!({"id": id.0}), "stop editing the opacity mask first");
|
||||
assert_eq!(s.doc().unwrap().isolation, layer);
|
||||
assert!(doc(&s).mask_edit.is_some(), "still editing");
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [inside.0]})).unwrap();
|
||||
off(&mut s, "object.isolate", json!({}), "stop editing the opacity mask first");
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
s.execute("object.isolate", &json!({"id": outside.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(outside));
|
||||
}
|
||||
|
||||
/// Opacity mask editing is not entered in isolation mode: Edit Opacity Mask and View Opacity Mask
|
||||
/// are off, and so is Make Opacity Mask for one object, which would edit its new empty mask. Make
|
||||
/// Opacity Mask with two objects still masks one with the other. After Exit Isolation Mode the mask
|
||||
/// is edited again.
|
||||
#[test]
|
||||
fn opacity_mask_editing_is_not_entered_in_isolation_mode() {
|
||||
let mut s = session();
|
||||
let (obj, m) = (rect(&mut s, 0.0, 200.0), rect(&mut s, 0.0, 100.0));
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [obj.0, m.0]})).unwrap();
|
||||
let other = rect(&mut s, 220.0, 30.0);
|
||||
s.execute("select.set", &json!({"ids": [obj.0, other.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [obj.0]})).unwrap();
|
||||
let why = "exit isolation mode first";
|
||||
off(&mut s, "transparency.editOpacityMask", json!({}), why);
|
||||
off(&mut s, "transparency.editOpacityMask", json!({"id": obj.0}), why);
|
||||
off(&mut s, "transparency.viewOpacityMask", json!({}), why);
|
||||
off(&mut s, "transparency.viewOpacityMask", json!({"on": true, "id": obj.0}), why);
|
||||
s.execute("select.set", &json!({"ids": [other.0]})).unwrap();
|
||||
off(&mut s, "transparency.makeOpacityMask", json!({}), why);
|
||||
off(&mut s, "transparency.makeOpacityMask", json!({"ids": [other.0]}), why);
|
||||
// A layer given as the one target is refused too.
|
||||
let layer = doc(&s).layers[0].id;
|
||||
let err = s.execute("transparency.makeOpacityMask", &json!({"ids": [layer.0]})).unwrap_err();
|
||||
assert!(err.to_string().ends_with(why), "{err}");
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.mask_edit.is_none() && st.mask_view.is_none() && mask(&s, other).is_none());
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(g));
|
||||
// Two objects: the top one masks the other, without mask editing.
|
||||
let top = rect(&mut s, 220.0, 10.0);
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [other.0, top.0]})).unwrap();
|
||||
assert_eq!(r, json!({"id": other.0, "editing": false}));
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(g));
|
||||
// Two members of a compound path given in `ids` are two objects: one masks the other.
|
||||
let (c1, c2) = (rect(&mut s, 250.0, 10.0), rect(&mut s, 265.0, 10.0));
|
||||
s.execute("select.set", &json!({"ids": [c1.0, c2.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [c1.0, c2.0]})).unwrap();
|
||||
assert_eq!(r["editing"], json!(false));
|
||||
assert!(doc(&s).mask_edit.is_none());
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({"id": obj.0})).unwrap();
|
||||
assert_eq!(doc(&s).mask_edit.map(|e| e.object), Some(obj));
|
||||
}
|
||||
|
||||
/// In isolation mode, Make Opacity Mask is off for a targeted sublayer that holds two objects:
|
||||
/// the sublayer is its one target, which would get an empty mask and enter mask editing.
|
||||
#[test]
|
||||
fn make_opacity_mask_is_off_for_a_targeted_sublayer_in_isolation_mode() {
|
||||
let mut s = session();
|
||||
let layer = doc(&s).layers[0].id;
|
||||
let sub = NodeId(s.execute("layer.newSublayer", &json!({"parent": layer.0})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("layer.setCurrent", &json!({"id": sub.0})).unwrap();
|
||||
let (a, b) = (rect(&mut s, 0.0, 20.0), rect(&mut s, 40.0, 20.0));
|
||||
s.execute("object.isolate", &json!({"id": layer.0})).unwrap();
|
||||
s.execute("layer.target", &json!({"id": sub.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Some(sub));
|
||||
off(&mut s, "transparency.makeOpacityMask", json!({}), "exit isolation mode first");
|
||||
// Two objects given in `ids` still mask one with the other.
|
||||
let r = s.execute("transparency.makeOpacityMask", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
assert_eq!(r["editing"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_and_transparency_commands_take_ids_without_a_selection() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -141,6 +141,23 @@ fn edit_text_shows_it_flat_in_isolation_then_projects_it_again() {
|
||||
assert!(s2.execute("perspective.editText", &json!({"id": rect.0})).is_err());
|
||||
}
|
||||
|
||||
/// Edit Text, which shows the type in isolation mode, is off while an opacity mask is edited.
|
||||
#[test]
|
||||
fn edit_text_is_off_while_an_opacity_mask_is_edited() {
|
||||
let (mut s, rect) = persp_session();
|
||||
let id = persp_text(&mut s);
|
||||
s.execute("transparency.makeOpacityMask", &json!({"ids": [rect.0]})).unwrap();
|
||||
let layer = s.doc().unwrap().isolation;
|
||||
assert!(layer.is_some(), "editing the new empty mask");
|
||||
let err = s.execute("perspective.editText", &json!({"id": id.0})).unwrap_err();
|
||||
assert!(matches!(&err, EngineError::Disabled(_, why) if why == "stop editing the opacity mask first"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().isolation, layer);
|
||||
assert!(node(&s, id).projection().is_some(), "still projected");
|
||||
s.execute("transparency.stopEditingOpacityMask", &json!({})).unwrap();
|
||||
s.execute("perspective.editText", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().isolation, Some(id));
|
||||
}
|
||||
|
||||
/// Edit Text ending while a background save writes the file leaves the document saved once the
|
||||
/// file is written: the flat view is never saved. Saved while editing and edited after: undoing
|
||||
/// the edit once Edit Text has ended is back at what the file holds.
|
||||
|
||||
@@ -998,6 +998,114 @@ fn whole_objects_moved_with_selected_anchors_follow_transform_pattern_tiles() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Direct Selection or Group Selection drag that starts on the fill of a path selected whole,
|
||||
/// while another path has an anchor selected, moves the path whole and only that anchor of the
|
||||
/// other path, in one undo step, as a drag of the anchor does. With Transform Pattern Tiles on,
|
||||
/// the tiles of the path selected whole move with it. Alt pressed during the drag copies the
|
||||
/// selected objects instead.
|
||||
#[test]
|
||||
fn a_drag_from_a_fill_moves_only_the_selected_anchors_of_partly_selected_paths() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
let filled = patterned_square(&mut s);
|
||||
let other = square(&mut s, 300.0, 100.0);
|
||||
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
|
||||
set_pref(&mut s, "transformPatternTiles", json!(true));
|
||||
let xs = |s: &Session| -> Vec<f64> {
|
||||
s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect()
|
||||
};
|
||||
let before = xs(&s);
|
||||
for tool in ["directSelection", "groupSelection"] {
|
||||
s.select_tool(tool, v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
|
||||
assert_eq!(top_left(&s, filled), (110.0, 100.0), "{tool}: the square moves whole");
|
||||
let mut moved = before.clone();
|
||||
moved[0] += 10.0;
|
||||
assert_eq!(xs(&s), moved, "{tool}: the selected anchor moves alone");
|
||||
assert_eq!(s.journal.last().unwrap().0, "path.moveAnchors", "{tool}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Move"), "{tool}");
|
||||
assert_eq!(pattern_tiles(&s, filled), [1.0, 0.0, 0.0, 1.0, 10.0, 0.0], "{tool}: the tiles move with the square");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((top_left(&s, filled), xs(&s)), ((100.0, 100.0), before.clone()), "{tool}: one undo step");
|
||||
// Alt pressed during the drag copies the selected objects and leaves the originals.
|
||||
let count = |s: &Session| s.doc().unwrap().doc.layers[0].children().map_or(0, |c| c.len());
|
||||
let objects = count(&s);
|
||||
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
let alt = vectorcraft_tools::Mods { alt: true, ..vectorcraft_tools::Mods::default() };
|
||||
s.pointer(&PointerEvent::new(Down, 125.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(Drag, 130.0, 125.0), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(Drag, 135.0, 125.0).with_mods(alt), v).unwrap();
|
||||
s.pointer(&PointerEvent::new(Up, 135.0, 125.0).with_mods(alt), v).unwrap();
|
||||
assert_eq!((top_left(&s, filled), xs(&s), count(&s)), ((100.0, 100.0), before.clone(), objects + 2), "{tool}: copied");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Copy"), "{tool}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(count(&s), objects, "{tool}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A path selected whole inside a selected group moves once, with the group, when the selection
|
||||
/// also holds a direct-selected anchor: a Group Selection drag from the path's fill (which adds
|
||||
/// the group) and an arrow key move both by 10 pt.
|
||||
#[test]
|
||||
fn a_path_in_a_selected_group_moves_once_with_selected_anchors() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
let (a, b) = (square(&mut s, 100.0, 100.0), square(&mut s, 160.0, 100.0));
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let other = square(&mut s, 300.0, 100.0);
|
||||
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
|
||||
let first_x = |s: &Session| s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors[0].p.x;
|
||||
s.select_tool("groupSelection", v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
|
||||
assert!(s.doc().unwrap().selection.contains(g), "the press added the group");
|
||||
assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the drag");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [g.0, a.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
s.execute("object.nudge", &json!({"dx": 1, "dy": 0, "big": true})).unwrap();
|
||||
assert_eq!((top_left(&s, a), top_left(&s, b), first_x(&s)), ((110.0, 100.0), (170.0, 100.0), 310.0), "the arrow key");
|
||||
}
|
||||
|
||||
/// Transform Again after a Direct Selection drag from a fill with an anchor of another path
|
||||
/// selected moves the path selected whole and only that anchor again, by the same distance. A
|
||||
/// drag back to where it began leaves that move to repeat, and `path.moveAnchors` run as a
|
||||
/// command is repeated too.
|
||||
#[test]
|
||||
fn transform_again_repeats_a_drag_from_a_fill_with_anchors_selected() {
|
||||
use vectorcraft_tools::PointerKind::{Down, Drag, Up};
|
||||
let mut s = new_doc();
|
||||
let filled = square(&mut s, 100.0, 100.0);
|
||||
let other = square(&mut s, 300.0, 100.0);
|
||||
let v = ViewInfo { smart_guides: false, snap_to_point: false, corner_widgets: false, ..ViewInfo::default() };
|
||||
let xs = |s: &Session| -> Vec<f64> {
|
||||
s.doc().unwrap().doc.node(other).unwrap().path_data().unwrap().subpaths[0].anchors.iter().map(|a| a.p.x).collect()
|
||||
};
|
||||
let mut moved = xs(&s);
|
||||
s.select_tool("directSelection", v).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [filled.0]})).unwrap();
|
||||
s.execute("select.anchors", &json!({"id": other.0, "anchors": [[0, 0]], "mode": "add"})).unwrap();
|
||||
gesture(&mut s, v, &[(Down, 125.0, 125.0), (Drag, 130.0, 125.0), (Drag, 135.0, 125.0), (Up, 135.0, 125.0)]);
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
moved[0] += 20.0;
|
||||
assert_eq!((top_left(&s, filled), xs(&s)), ((120.0, 100.0), moved.clone()));
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().map(|e| e.label.as_str()), Some("Transform Again"));
|
||||
// A drag that ends where it began leaves the move to repeat.
|
||||
gesture(&mut s, v, &[(Down, 145.0, 125.0), (Drag, 155.0, 125.0), (Drag, 145.0, 125.0), (Up, 145.0, 125.0)]);
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
moved[0] += 10.0;
|
||||
assert_eq!((top_left(&s, filled), xs(&s)), ((130.0, 100.0), moved), "after a drag back to its start");
|
||||
// The command, as an action plays it back, is repeated the same way.
|
||||
s.execute("path.moveAnchors", &json!({"dx": 0, "dy": 5})).unwrap();
|
||||
s.execute("object.transformAgain", &json!({})).unwrap();
|
||||
assert_eq!(top_left(&s, filled), (130.0, 110.0), "path.moveAnchors");
|
||||
}
|
||||
|
||||
/// General › Select Same Tint % (#394): off, Select › Same › Fill Color takes every tint of the
|
||||
/// swatch; on, only the same tint.
|
||||
#[test]
|
||||
|
||||
@@ -84,7 +84,7 @@ fn save_as_takes_on_path_format_and_options() {
|
||||
let svg = s.execute("file.formatOptions", &json!({"format": "svg"})).unwrap();
|
||||
assert_eq!(svg["options"]["outlineText"]["value"], false, "another format shows its defaults");
|
||||
let ids: Vec<&str> = o["saveFormats"].as_array().unwrap().iter().map(|f| f["id"].as_str().unwrap()).collect();
|
||||
assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai"]);
|
||||
assert_eq!(ids, ["vectorcraft", "template", "pdf", "svg", "svgz", "ai", "eps"]);
|
||||
// SVGZ is gzip-compressed SVG and reopens as SVGZ.
|
||||
let svgz = path(&d, "doc.svgz");
|
||||
s.execute("file.saveAs", &json!({"path": svgz})).unwrap();
|
||||
@@ -405,3 +405,141 @@ fn transparency_grid_round_trips_without_dirtying() {
|
||||
assert!(!s.doc().unwrap().transparency_grid);
|
||||
let _ = std::fs::remove_dir_all(d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opened_eps_saves_to_its_own_file_with_editable_data_and_page_margins() {
|
||||
let d = dir("eps-original-format");
|
||||
let eps_path = path(&d, "Artwork.eps");
|
||||
// Original, contributor-authored PostScript: the artwork occupies only part of the page.
|
||||
let source = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nshowpage\n%%EOF\n";
|
||||
std::fs::write(&eps_path, source).unwrap();
|
||||
let mut s = Session::new();
|
||||
s.execute("document.open", &json!({"path": eps_path})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().format, "eps");
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str()));
|
||||
let board = s.doc().unwrap().doc.artboards[0].rect;
|
||||
assert_eq!((board.width(), board.height()), (200.0, 100.0));
|
||||
rect(&mut s);
|
||||
let edited_layers = s.doc().unwrap().doc.layers.clone();
|
||||
let r = s.execute("document.save", &json!({"options": {"previewFormat": "none", "thumbnails": false}})).unwrap();
|
||||
assert_eq!((r["path"].as_str(), r["format"].as_str()), (Some(eps_path.as_str()), Some("eps")));
|
||||
let bytes = std::fs::read(&eps_path).unwrap();
|
||||
assert!(bytes.starts_with(b"%!PS-Adobe-3.0 EPSF-3.0"));
|
||||
assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100"));
|
||||
let editing = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
|
||||
assert_eq!(editing.artboards[0].rect, board);
|
||||
assert_eq!(editing.layers, edited_layers);
|
||||
assert!(!s.doc().unwrap().is_dirty());
|
||||
let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap();
|
||||
assert_eq!(opened["restored"], true);
|
||||
assert_eq!(s.doc().unwrap().doc.layers, edited_layers);
|
||||
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, board);
|
||||
rect(&mut s);
|
||||
s.execute("document.save", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().path.as_deref(), Some(eps_path.as_str()));
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_keeps_all_editing_artboards_while_printing_one_page() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100, "artboards": 2})).unwrap();
|
||||
rect(&mut s);
|
||||
let r = s.execute("document.save", &json!({"format": "eps", "artboard": 1, "previewFormat": "none", "thumbnails": false})).unwrap();
|
||||
let bytes = b64(&r);
|
||||
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
|
||||
assert_eq!(native.artboards.len(), 2);
|
||||
assert_eq!(native.layers, s.doc().unwrap().doc.layers);
|
||||
assert!(s.execute("document.save", &json!({"format": "eps", "range": "all"})).is_err(), "EPS is one printed page");
|
||||
for options in [json!({"useArtboards": false}), json!({"selectedOnly": true})] {
|
||||
assert!(s.execute("document.save", &json!({"format": "eps", "options": options})).is_err(), "cropping is Export's job");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_retains_lossy_import_overwrite_protection_and_transparency_warning() {
|
||||
let d = dir("eps-import-warning");
|
||||
let eps_path = path(&d, "Artwork.eps");
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
|
||||
let r = s.execute("document.save", &json!({"path": eps_path, "previewFormat": "none", "thumbnails": false})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("flatten")), "{r}");
|
||||
let original = std::fs::read(&eps_path).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
st.imported_from = Some(eps_path.clone());
|
||||
st.import_losses.push("test import omitted artwork".into());
|
||||
rect(&mut s);
|
||||
assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss"));
|
||||
assert_eq!(std::fs::read(&eps_path).unwrap(), original);
|
||||
s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap();
|
||||
assert_ne!(std::fs::read(&eps_path).unwrap(), original);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_supports_an_empty_artboard_without_cropping_its_page() {
|
||||
let mut s = session();
|
||||
let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "none", "thumbnails": false})).unwrap();
|
||||
let bytes = b64(&r);
|
||||
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
|
||||
assert_eq!(native.artboards[0].rect, s.doc().unwrap().doc.artboards[0].rect);
|
||||
assert_eq!(native.node_count(), s.doc().unwrap().doc.node_count());
|
||||
assert!(std::str::from_utf8(vectorcraft_eps::sections(&bytes).unwrap().0).unwrap().contains("%%BoundingBox: 0 0 200 100"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partially_read_eps_requires_confirmation_before_save_back() {
|
||||
let d = dir("eps-partial-import");
|
||||
let eps_path = path(&d, "partial.eps");
|
||||
let original = b"%!PS-Adobe-3.0 EPSF-3.0\n%%BoundingBox: 0 0 200 100\n0 0 1 setrgbcolor 30 20 40 30 rectfill\nunsupportedMockyOperator\n1 0 0 setrgbcolor 80 20 40 30 rectfill\nshowpage\n%%EOF\n";
|
||||
std::fs::write(&eps_path, original).unwrap();
|
||||
let mut s = Session::new();
|
||||
let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap();
|
||||
assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().starts_with("the file was read up to an error")), "{opened}");
|
||||
rect(&mut s);
|
||||
assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss"));
|
||||
assert_eq!(std::fs::read(&eps_path).unwrap(), original);
|
||||
s.execute("document.save", &json!({"acknowledgeLoss": true})).unwrap();
|
||||
assert!(vectorcraft_eps::native(&std::fs::read(&eps_path).unwrap()).is_some());
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_with_damaged_editing_data_requires_confirmation_before_save_back() {
|
||||
let d = dir("eps-damaged-editing");
|
||||
let eps_path = path(&d, "damaged.eps");
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
let r = s.execute("document.exportEps", &json!({"previewFormat": "none", "thumbnails": false, "cmykPostScript": false})).unwrap();
|
||||
let mut bytes = b64(&r);
|
||||
let at = bytes.windows(9).position(|w| w == b"native\n% ").unwrap() + 12;
|
||||
bytes[at] = b'z';
|
||||
std::fs::write(&eps_path, &bytes).unwrap();
|
||||
let opened = s.execute("document.open", &json!({"path": eps_path})).unwrap();
|
||||
assert_eq!(opened["restored"], false);
|
||||
assert!(opened["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("editing data can't be read")), "{opened}");
|
||||
rect(&mut s);
|
||||
assert!(s.execute("document.save", &json!({})).unwrap_err().to_string().contains("acknowledgeLoss"));
|
||||
assert_eq!(std::fs::read(&eps_path).unwrap(), bytes);
|
||||
std::fs::remove_dir_all(d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_save_bounds_large_page_preview_without_cropping_vector_or_editing_data() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 4000, "height": 4000})).unwrap();
|
||||
rect(&mut s);
|
||||
// Explicit colour preview remains a colour preview; Save bounds its resolution, not the art.
|
||||
let r = s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap();
|
||||
let bytes = b64(&r);
|
||||
let (ps, tiff) = vectorcraft_eps::sections(&bytes).unwrap();
|
||||
let tiff = tiff.unwrap();
|
||||
assert!(tiff.len() < 4 * 1024 * 1024 + 64 * 1024, "compatibility preview grew to {} bytes", tiff.len());
|
||||
let preview = image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap();
|
||||
assert_eq!((preview.width(), preview.height()), (1024, 1024));
|
||||
assert!(std::str::from_utf8(ps).unwrap().contains("%%BoundingBox: 0 0 4000 4000"));
|
||||
let native = vectorcraft_format::load(&vectorcraft_eps::native(&bytes).unwrap()).unwrap();
|
||||
assert_eq!(native.artboards, s.doc().unwrap().doc.artboards);
|
||||
assert_eq!(native.layers, s.doc().unwrap().doc.layers);
|
||||
}
|
||||
|
||||
@@ -356,6 +356,50 @@ fn shaper_isolate_refuses_a_locked_or_hidden_source_group() {
|
||||
}
|
||||
}
|
||||
|
||||
/// While an opacity mask or a pattern is edited, its editing layer stays isolated: a Shaper source
|
||||
/// pick selects the original shape without isolating it, and a scribble, Release and Expand keep the
|
||||
/// layer isolated. Leaving the mode then ends isolation.
|
||||
#[test]
|
||||
fn shaper_keeps_the_mask_and_pattern_editing_layers_isolated() {
|
||||
for mode in ["mask", "pattern"] {
|
||||
let mut s = session();
|
||||
let object = rectangle(&mut s, 300.0, "#000000");
|
||||
let layer = if mode == "mask" {
|
||||
let m = rectangle(&mut s, 300.0, "#ffffff");
|
||||
s.execute("select.set", &json!({"ids": [object.0, m.0]})).unwrap();
|
||||
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
|
||||
s.execute("transparency.editOpacityMask", &json!({})).unwrap()["layer"].as_u64().map(NodeId)
|
||||
} else {
|
||||
s.execute("object.pattern.make", &json!({"name": "P"})).unwrap();
|
||||
s.doc().unwrap().doc.pattern_edit.as_ref().map(|e| e.layer)
|
||||
};
|
||||
let isolation = |s: &Session| s.doc().unwrap().isolation;
|
||||
assert!(layer.is_some() && isolation(&s) == layer, "{mode}");
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let make = |s: &mut Session| {
|
||||
let pts = [[25, 20], [75, 35], [30, 50], [125, 65]];
|
||||
NodeId(s.execute("shaper.scribble", &json!({"points": pts, "ids": [a.0, b.0]})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let g = make(&mut s);
|
||||
assert_eq!(s.doc().unwrap().doc.parent_of(g), layer, "{mode}");
|
||||
assert_eq!(isolation(&s), layer, "{mode}: scribble");
|
||||
let picked = s.execute("shaper.select", &json!({"point": [25, 50], "source": true})).unwrap();
|
||||
assert_eq!(picked["id"], json!(a.0), "{mode}");
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![a], "{mode}");
|
||||
assert_eq!(isolation(&s), layer, "{mode}: source pick");
|
||||
s.execute("shaper.release", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(isolation(&s), layer, "{mode}: release");
|
||||
let g = make(&mut s);
|
||||
s.execute("shaper.expand", &json!({"id": g.0})).unwrap();
|
||||
assert_eq!(isolation(&s), layer, "{mode}: expand");
|
||||
let leave = if mode == "mask" { "transparency.stopEditingOpacityMask" } else { "object.pattern.done" };
|
||||
s.execute(leave, &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.doc.mask_edit.is_none() && st.doc.pattern_edit.is_none() && st.isolation.is_none(), "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shaper_lines_keep_free_angles_with_or_without_shift() {
|
||||
for shift in [false, true] {
|
||||
@@ -567,3 +611,162 @@ fn shaper_joined_outline_keeps_its_stroke_paint_after_source_edits() {
|
||||
assert_ne!(node(&s, id).appearance.stroke_paint().label(), "#00ff00");
|
||||
}
|
||||
}
|
||||
|
||||
fn source_ids(s: &Session, g: NodeId) -> Vec<NodeId> {
|
||||
node(s, g).children().unwrap()[0].children().unwrap().iter().map(|n| n.id).collect()
|
||||
}
|
||||
/// A Selection-tool drag from `from` to `to`.
|
||||
fn drag(s: &mut Session, v: ViewInfo, from: [f64; 2], to: [f64; 2]) {
|
||||
s.pointer(&PointerEvent::new(PointerKind::Down, from[0], from[1]), v).unwrap();
|
||||
for t in [0.25, 0.5, 1.0] {
|
||||
let p = [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t];
|
||||
s.pointer(&PointerEvent::new(PointerKind::Drag, p[0], p[1]), v).unwrap();
|
||||
}
|
||||
s.pointer(&PointerEvent::new(PointerKind::Up, to[0], to[1]), v).unwrap();
|
||||
}
|
||||
/// Two overlapping rectangles in a Shaper Group, in Construction mode with the left one selected
|
||||
/// and the Selection tool chosen → (left, right, group).
|
||||
fn construction(s: &mut Session, v: ViewInfo) -> (NodeId, NodeId, NodeId) {
|
||||
let a = rectangle(s, 0.0, "#ff0000");
|
||||
let b = rectangle(s, 50.0, "#0000ff");
|
||||
let g = scribble(s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.select_tool("selection", v).unwrap();
|
||||
(a, b, g)
|
||||
}
|
||||
|
||||
/// In Construction mode, a Selection-tool drag that drops an original shape wholly outside the
|
||||
/// bounds of the group's original shapes takes it out of the group, in one undo step: it becomes a
|
||||
/// path just above the group with its own look, and stays selected. Undo puts it back. With
|
||||
/// Real-time Drawing off the drop does the same.
|
||||
#[test]
|
||||
fn shaper_dragging_an_original_shape_out_of_the_bounds_takes_it_out_of_the_group() {
|
||||
for real_time in [true, false] {
|
||||
let mut s = session();
|
||||
s.prefs.real_time_drawing = real_time;
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let (a, b, g) = construction(&mut s, v);
|
||||
let look = node(&s, a).appearance.clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
drag(&mut s, v, [25.0, 50.0], [25.0, 200.0]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![b], "real-time {real_time}");
|
||||
let (parent, index, _) = st.doc.position(a).unwrap();
|
||||
assert_eq!((parent, index), { st.doc.position(g).map(|(p, i, _)| (p, i + 1)).unwrap() });
|
||||
assert_eq!(node(&s, a).appearance, look);
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 150.0);
|
||||
assert_eq!(st.selection.objects, vec![a]);
|
||||
assert_eq!(st.isolation, None);
|
||||
assert_eq!(st.history.undo.len(), steps + 1);
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Remove from Shaper Group");
|
||||
// The group's result is the right rectangle alone.
|
||||
assert_eq!(fill(&s, g, 25.0, 50.0), None);
|
||||
assert_eq!(fill(&s, g, 125.0, 50.0).as_deref(), Some("#0000ff"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0);
|
||||
assert_eq!(fill(&s, g, 25.0, 50.0).as_deref(), Some("#ff0000"));
|
||||
}
|
||||
}
|
||||
|
||||
/// A drop that leaves the original shape across the bounds of the group's original shapes only
|
||||
/// moves it, as before.
|
||||
#[test]
|
||||
fn shaper_dragging_an_original_shape_across_the_bounds_only_moves_it() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let (a, b, g) = construction(&mut s, v);
|
||||
let sources = node(&s, g).children().unwrap()[0].id;
|
||||
drag(&mut s, v, [25.0, 50.0], [25.0, 100.0]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 50.0);
|
||||
assert_eq!(st.isolation, Some(sources));
|
||||
assert_eq!(st.history.undo.last().unwrap().label, "Move");
|
||||
// Outside Construction mode the same drop moves the group as usual.
|
||||
s.execute("object.exitIsolation", &json!({})).unwrap();
|
||||
drag(&mut s, v, [25.0, 100.0], [25.0, 400.0]);
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
}
|
||||
|
||||
/// Taking the last original shape out removes the group, and the native file keeps the result.
|
||||
#[test]
|
||||
fn shaper_taking_the_last_original_shape_out_removes_the_group_and_saves() {
|
||||
let mut s = session();
|
||||
let v = ViewInfo { smart_guides: false, ..Default::default() };
|
||||
let (a, b, g) = construction(&mut s, v);
|
||||
drag(&mut s, v, [25.0, 50.0], [25.0, 200.0]);
|
||||
s.execute("object.isolate", &json!({"id": g.0})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [b.0]})).unwrap();
|
||||
drag(&mut s, v, [125.0, 50.0], [400.0, 50.0]);
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(g).is_none());
|
||||
assert_eq!(doc.parent_of(a), doc.parent_of(b));
|
||||
assert_eq!(node(&s, b).geometric_bounds().unwrap().x0, 325.0);
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(back.layers, doc.layers);
|
||||
}
|
||||
|
||||
/// Flat bounds of horizontal or vertical lines on the line of a group's flat bounds, and bounds that
|
||||
/// touch the group's bounds, are not outside them.
|
||||
#[test]
|
||||
fn shaper_outside_test_handles_flat_and_touching_bounds() {
|
||||
use crate::cmd::shaper::wholly_outside;
|
||||
use vectorcraft_geom::Rect;
|
||||
let row = Rect::new(0.0, 150.0, 200.0, 150.0);
|
||||
assert!(!wholly_outside(Rect::new(50.0, 150.0, 120.0, 150.0), row));
|
||||
assert!(!wholly_outside(Rect::new(150.0, 150.0, 300.0, 150.0), row));
|
||||
assert!(wholly_outside(Rect::new(250.0, 150.0, 300.0, 150.0), row));
|
||||
assert!(wholly_outside(Rect::new(50.0, 160.0, 120.0, 160.0), row));
|
||||
let column = Rect::new(80.0, 0.0, 80.0, 100.0);
|
||||
assert!(!wholly_outside(Rect::new(80.0, 50.0, 80.0, 150.0), column));
|
||||
assert!(wholly_outside(Rect::new(90.0, 50.0, 90.0, 150.0), column));
|
||||
let square = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
assert!(!wholly_outside(Rect::new(100.0, 0.0, 200.0, 100.0), square));
|
||||
assert!(wholly_outside(Rect::new(100.5, 0.0, 200.0, 100.0), square));
|
||||
}
|
||||
|
||||
/// `shaper.releaseSource` takes the named original shape out, refuses a shape of another group or
|
||||
/// none, and `object.transform`'s `releaseOutside` makes one undo step outside a drag too.
|
||||
#[test]
|
||||
fn shaper_release_source_command_and_transform_flag() {
|
||||
let mut s = session();
|
||||
let a = rectangle(&mut s, 0.0, "#ff0000");
|
||||
let b = rectangle(&mut s, 50.0, "#0000ff");
|
||||
let g = scribble(&mut s, &[[25.0, 20.0], [75.0, 35.0], [30.0, 50.0], [125.0, 65.0]]);
|
||||
let other = rectangle(&mut s, 300.0, "#00ff00");
|
||||
let untouched = s.doc().unwrap().doc.layers.clone();
|
||||
for p in [json!({"source": other.0}), json!({"source": a.0, "group": other.0}), json!({"source": "x"}), json!({})] {
|
||||
assert!(s.execute("shaper.releaseSource", &p).is_err(), "{p}");
|
||||
assert_eq!(s.doc().unwrap().doc.layers, untouched);
|
||||
}
|
||||
s.execute("shaper.releaseSource", &json!({"source": b.0, "group": g.0})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a]);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, vec![b]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove from Shaper Group");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": [a.0], "releaseOutside": true})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![b]);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Remove from Shaper Group");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0);
|
||||
// With a history of one step, that step still goes back to before the move.
|
||||
s.doc_mut().unwrap().history.limit = 1;
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": [a.0], "releaseOutside": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
assert_eq!(node(&s, a).geometric_bounds().unwrap().y0, 0.0);
|
||||
// Shapes taken out together keep their stacking order, whatever order they are named in.
|
||||
for ids in [[a.0, b.0], [b.0, a.0]] {
|
||||
s.execute("object.transform", &json!({"matrix": [1, 0, 0, 1, 0, 150], "ids": ids, "releaseOutside": true})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.node(g).is_none());
|
||||
assert!(doc.position(a).unwrap().1 < doc.position(b).unwrap().1, "{ids:?}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(source_ids(&s, g), vec![a, b]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Object › Path › Smooth (`object.path.smooth`): the slider from minimum to maximum smoothing and
|
||||
//! Auto-Smooth, as the Smooth slider help page describes
|
||||
//! (https://helpx.adobe.com/illustrator/desktop/draw-shapes-and-paths/modify-paths/refine-path-segments-with-the-smooth-slider.html).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
use vectorcraft_geom::{ParamCurve, PathData, Point};
|
||||
|
||||
use crate::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn path_of(s: &Session, id: NodeId) -> PathData {
|
||||
match &s.doc().unwrap().doc.node(id).unwrap().kind {
|
||||
NodeKind::Path { path, .. } => path.clone(),
|
||||
_ => panic!("not a path"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A path through `points`, selected: its id.
|
||||
fn polyline(s: &mut Session, points: &[(f64, f64)], closed: bool) -> NodeId {
|
||||
let anchors: Vec<Value> = points.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())
|
||||
}
|
||||
|
||||
/// A sine wave 300 pt long with an anchor every 3 pt, its anchors 1.5 pt above and below it in turn.
|
||||
fn jagged_wave(s: &mut Session) -> NodeId {
|
||||
let pts: Vec<(f64, f64)> = (0..=100u32)
|
||||
.map(|i| (f64::from(i) * 3.0, 200.0 + 40.0 * (f64::from(i) * 3.0 / 50.0).sin() + if i.is_multiple_of(2) { 1.5 } else { -1.5 }))
|
||||
.collect();
|
||||
polyline(s, &pts, false)
|
||||
}
|
||||
|
||||
/// A closed ring of radius `r` around (`cx`, 300) with 120 anchors, `jag` pt in and out in turn.
|
||||
fn jagged_ring(s: &mut Session, cx: f64, r: f64, jag: f64) -> NodeId {
|
||||
let pts: Vec<(f64, f64)> = (0..120u32)
|
||||
.map(|i| {
|
||||
let a = f64::from(i) * std::f64::consts::TAU / 120.0;
|
||||
let rr = r + if i.is_multiple_of(2) { jag } else { -jag };
|
||||
(cx + rr * a.cos(), 300.0 + rr * a.sin())
|
||||
})
|
||||
.collect();
|
||||
polyline(s, &pts, true)
|
||||
}
|
||||
|
||||
/// The farthest that 11 points on each segment of `from` lie from the nearest point of `to`.
|
||||
fn farthest(from: &PathData, to: &PathData) -> f64 {
|
||||
let off = |p: Point| {
|
||||
to.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i).nearest(p, 1e-9).distance_sq.sqrt()))
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
};
|
||||
from.subpaths
|
||||
.iter()
|
||||
.flat_map(|sp| (0..sp.segment_count()).flat_map(move |i| (0..=10).map(move |k| sp.segment(i).eval(f64::from(k) / 10.0))))
|
||||
.map(off)
|
||||
.fold(0.0, f64::max)
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_smoothing_leaves_the_path_where_it_was() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let out = s.execute("object.path.smooth", &json!({"level": 0})).unwrap();
|
||||
assert_eq!(out["level"], json!(0.0));
|
||||
let after = path_of(&s, id);
|
||||
// 0.1 % of the longer side (300 pt), both ways.
|
||||
assert!(farthest(&before, &after) <= 0.3 + 1e-6 && farthest(&after, &before) <= 0.3 + 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_smoothing_removes_the_jags_and_keeps_the_shape() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let mut counts = vec![];
|
||||
for level in [25, 50, 100] {
|
||||
let out = s.execute("object.path.smooth", &json!({ "level": level })).unwrap();
|
||||
let after = path_of(&s, id);
|
||||
assert_eq!(out["after"].as_u64().unwrap() as usize, after.anchor_count());
|
||||
counts.push(after.anchor_count());
|
||||
// Within the tolerance (2.5 % of 300 pt at most) plus the jag the refit smooths away.
|
||||
assert!(farthest(&before, &after) <= 7.5 + 1.5, "level {level}: {}", farthest(&before, &after));
|
||||
// The end points of an open path stay put.
|
||||
let (a, b) = (&before.subpaths[0].anchors, &after.subpaths[0].anchors);
|
||||
assert_eq!((a[0].p, a[a.len() - 1].p), (b[0].p, b[b.len() - 1].p));
|
||||
assert!(!after.subpaths[0].closed);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
}
|
||||
assert!(counts[0] < 101 / 2 && counts[2] <= 10, "{counts:?}");
|
||||
assert!(counts.windows(2).all(|w| w[0] >= w[1]), "{counts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_smooth_uses_the_middle_level() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let out = s.execute("object.path.smooth", &json!({"auto": true, "level": 0})).unwrap();
|
||||
assert_eq!(out["level"], json!(50.0));
|
||||
let auto = path_of(&s, id);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("object.path.smooth", &json!({"level": 50})).unwrap();
|
||||
assert_eq!(path_of(&s, id), auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_paths_and_compound_path_members_smooth_in_one_undo_step() {
|
||||
let mut s = session();
|
||||
let outer = jagged_ring(&mut s, 300.0, 100.0, 2.0);
|
||||
let inner = jagged_ring(&mut s, 300.0, 40.0, 1.0);
|
||||
s.execute("select.set", &json!({"ids": [outer.0, inner.0]})).unwrap();
|
||||
s.execute("object.compoundPath.make", &json!({})).unwrap();
|
||||
let (o, i) = (path_of(&s, outer), path_of(&s, inner));
|
||||
let steps = undo_len(&s);
|
||||
let out = s.execute("object.path.smooth", &json!({"level": 100})).unwrap();
|
||||
assert_eq!(out["ids"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(undo_len(&s), steps + 1);
|
||||
for id in [outer, inner] {
|
||||
let now = path_of(&s, id);
|
||||
assert!(now.subpaths[0].closed);
|
||||
assert!(now.anchor_count() < 120 / 4, "{}", now.anchor_count());
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!((path_of(&s, outer), path_of(&s, inner)), (o, i));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooths_the_objects_given() {
|
||||
let mut s = session();
|
||||
let a = jagged_wave(&mut s);
|
||||
let b = jagged_ring(&mut s, 300.0, 50.0, 1.0);
|
||||
let ring = path_of(&s, b);
|
||||
// `b` is selected; `ids` names `a` instead.
|
||||
let out = s.execute("object.path.smooth", &json!({"ids": [a.0], "level": 100})).unwrap();
|
||||
assert_eq!(out["ids"], json!([a.0]));
|
||||
assert_eq!(path_of(&s, b), ring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_params_are_refused_and_change_nothing() {
|
||||
let mut s = session();
|
||||
let id = jagged_wave(&mut s);
|
||||
let before = path_of(&s, id);
|
||||
let steps = undo_len(&s);
|
||||
for p in [json!({"level": "50"}), json!({"auto": true, "level": "max"}), json!({"auto": "yes"}), json!({"ids": "all"}), json!({"ids": [999_999]})]
|
||||
{
|
||||
assert!(s.execute("object.path.smooth", &p).is_err(), "{p}");
|
||||
}
|
||||
assert_eq!((path_of(&s, id), undo_len(&s)), (before, steps));
|
||||
// Levels past the ends count as the ends.
|
||||
assert_eq!(s.execute("object.path.smooth", &json!({"level": 250})).unwrap()["level"], json!(100.0));
|
||||
}
|
||||
@@ -47,6 +47,151 @@ fn document_color_mode_converts_grouped_swatches() {
|
||||
assert!(matches!(doc(&s).swatch("Bright Red").unwrap().paint.color(), Some(Color::Cmyk { .. })));
|
||||
}
|
||||
|
||||
/// The first solid fill color in the subtree of `n`.
|
||||
fn first_fill(n: &vectorcraft_doc::Node) -> Color {
|
||||
let mut out = None;
|
||||
n.walk(&mut |m| {
|
||||
if out.is_none() {
|
||||
out = m.appearance.fill_paint().color();
|
||||
}
|
||||
});
|
||||
out.unwrap_or_else(|| panic!("no fill in {n:?}"))
|
||||
}
|
||||
|
||||
/// The fill of graph design "Bar", the fill of brush "Holder"'s art and its Hue Shift key color,
|
||||
/// the fill of graphic style "Red" and the fill of the art of `masked`'s opacity mask.
|
||||
fn colors_outside_the_layers(s: &Session, masked: NodeId) -> Vec<Color> {
|
||||
let d = doc(s);
|
||||
let brush = vectorcraft_brush::find(d, "Holder").unwrap();
|
||||
let vectorcraft_brush::Colorization::HueShift { key } = brush.kind.colorization() else { panic!("not Hue Shift") };
|
||||
vec![
|
||||
first_fill(&d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art),
|
||||
first_fill(brush.art()[0]),
|
||||
key,
|
||||
d.graphic_style("Red").unwrap().appearance.fill_paint().color().unwrap(),
|
||||
first_fill(&d.node(masked).unwrap().extras().mask.as_deref().unwrap().art),
|
||||
]
|
||||
}
|
||||
|
||||
/// Document Color Mode converts the colors of graph designs, of the art of brushes (with a Hue
|
||||
/// Shift brush's key color), of graphic styles and of opacity-mask art as it converts the
|
||||
/// layers' art, tints of global swatches included, in one undo step.
|
||||
#[test]
|
||||
fn document_color_mode_converts_art_outside_the_layers() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let hue_shift = json!({"colorization": {"method": "hueShift"}});
|
||||
for (cmd, p, paint) in [
|
||||
("graph.design", json!({"new": "Bar"}), json!({"swatch": "Tone", "tint": 40})),
|
||||
("brush.new", json!({"type": "art", "name": "Holder", "params": hue_shift}), json!({"color": "#ff0000"})),
|
||||
("graphicStyle.new", json!({"name": "Red"}), json!({"color": "#ff0000"})),
|
||||
] {
|
||||
let id = rect(&mut s);
|
||||
let mut paint = paint;
|
||||
paint["ids"] = json!([id.0]);
|
||||
run(&mut s, "paint.setFill", paint);
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, cmd, p);
|
||||
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
|
||||
}
|
||||
let masked = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [masked.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#808080", "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [masked.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
let before = colors_outside_the_layers(&s, masked);
|
||||
assert!(before.iter().all(|c| matches!(c, Color::Rgb { .. })), "{before:?}");
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
|
||||
run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
let after = colors_outside_the_layers(&s, masked);
|
||||
assert!(after.iter().all(|c| matches!(c, Color::Cmyk { .. })), "{after:?}");
|
||||
// The same red converts to the same inks wherever it is.
|
||||
let red = first_fill(doc(&s).node(masked).unwrap());
|
||||
assert_eq!(after[1..4], [red; 3]);
|
||||
// A tint of a global swatch is the converted swatch's color at that tint.
|
||||
let tone = doc(&s).swatch("Tone").unwrap().paint.color().unwrap();
|
||||
assert_eq!(after[0], tone.tinted(0.4));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(colors_outside_the_layers(&s, masked), before);
|
||||
}
|
||||
|
||||
/// A Hue Shift brush whose art is painted with a tint of a global swatch keeps its key color equal
|
||||
/// to that art color after Document Color Mode.
|
||||
#[test]
|
||||
fn document_color_mode_keeps_a_hue_shift_key_on_its_tinted_art() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "tint": 40, "ids": [id.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "params": {"colorization": {"method": "hueShift"}}}));
|
||||
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
|
||||
let key_and_art = |s: &Session| {
|
||||
let brush = vectorcraft_brush::find(doc(s), "Holder").unwrap();
|
||||
let vectorcraft_brush::Colorization::HueShift { key } = brush.kind.colorization() else { panic!("not Hue Shift") };
|
||||
(key, first_fill(brush.art()[0]))
|
||||
};
|
||||
let (key, art) = key_and_art(&s);
|
||||
assert_eq!(key, art);
|
||||
run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
let (key, art) = key_and_art(&s);
|
||||
assert!(matches!(art, Color::Cmyk { .. }), "{art:?}");
|
||||
assert_eq!(key, art);
|
||||
}
|
||||
|
||||
/// The reply's `changed` counts the colors of the document's art; the colors of brush art and
|
||||
/// graphic styles convert without being counted.
|
||||
#[test]
|
||||
fn document_color_mode_counts_the_colors_of_the_art_only() {
|
||||
let changed = |library: bool| {
|
||||
let mut s = session();
|
||||
if library {
|
||||
for (cmd, p) in [("brush.new", json!({"type": "art", "name": "Holder"})), ("graphicStyle.new", json!({"name": "Red"}))] {
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, cmd, p);
|
||||
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
|
||||
}
|
||||
}
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]}));
|
||||
let r = run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
if library {
|
||||
let brush = vectorcraft_brush::find(doc(&s), "Holder").unwrap();
|
||||
assert!(matches!(first_fill(brush.art()[0]), Color::Cmyk { .. }));
|
||||
}
|
||||
r["changed"].as_u64().unwrap()
|
||||
};
|
||||
let in_art = changed(false);
|
||||
assert!(in_art > 0);
|
||||
assert_eq!(changed(true), in_art);
|
||||
}
|
||||
|
||||
/// Document Color Mode chosen while a pattern is being edited also converts the tile that pattern
|
||||
/// editing's Cancel puts back.
|
||||
#[test]
|
||||
fn document_color_mode_converts_the_tile_that_pattern_cancel_puts_back() {
|
||||
let mut s = session();
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [id.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string();
|
||||
run(&mut s, "object.pattern.edit", json!({"name": pattern}));
|
||||
run(&mut s, "file.documentColorMode", json!({"mode": "cmyk"}));
|
||||
run(&mut s, "object.pattern.cancel", json!({}));
|
||||
let red = first_fill(doc(&s).node(id).unwrap());
|
||||
assert!(matches!(red, Color::Cmyk { .. }), "{red:?}");
|
||||
let tile: Vec<Color> = doc(&s).pattern(&pattern).unwrap().art.iter().map(|n| first_fill(n)).collect();
|
||||
assert_eq!(tile, [red]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_spot_swatches_print_on_their_own_plate() {
|
||||
let mut s = session();
|
||||
@@ -383,3 +528,203 @@ fn global_swatch_edits_reach_opacity_mask_art() {
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(mask_fill(&s).color(), Some(Color::rgb(1.0, 1.0, 1.0)));
|
||||
}
|
||||
|
||||
/// The fills of graph design "Bar" and of the art of brush "Holder", as `place: color, link`.
|
||||
fn fills_outside_the_layers(s: &Session) -> Vec<String> {
|
||||
let d = doc(s);
|
||||
let mut out = vec![];
|
||||
let mut tree = |place: &str, n: &vectorcraft_doc::Node| {
|
||||
n.walk(&mut |m| {
|
||||
if let Paint::Solid { color, swatch, .. } = m.appearance.fill_paint() {
|
||||
out.push(format!("{place}: {}, {}", color.to_hex(), swatch.as_deref().unwrap_or("-")));
|
||||
}
|
||||
})
|
||||
};
|
||||
tree("design", &d.graph_designs.iter().find(|g| g.name == "Bar").unwrap().art);
|
||||
vectorcraft_brush::find(d, "Holder").unwrap().art().into_iter().for_each(|n| tree("brush art", n));
|
||||
out
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors a Hue Shift brush's key color in its art moves the key to the
|
||||
/// new color, so strokes still paint that art with the stroke color.
|
||||
#[test]
|
||||
fn global_swatch_edits_move_a_hue_shift_key() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Key", "ids": [id.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
let key = |s: &mut Session| run(s, "brush.get", json!({"name": "Key"}))["colorization"]["key"].clone();
|
||||
let (red, blue) = (json!(Color::rgb(1.0, 0.0, 0.0)), json!(Color::rgb(0.0, 0.0, 1.0)));
|
||||
assert_eq!(key(&mut s), red);
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
assert_eq!(key(&mut s), blue);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(key(&mut s), red);
|
||||
|
||||
// A paint in the key color that only an opacity mask uses leaves the key where it is.
|
||||
run(&mut s, "swatch.new", json!({"name": "MaskTone", "color": "#ff0000", "global": true}));
|
||||
let obj = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000", "ids": [obj.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "MaskTone", "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Masked", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
let masked = |s: &mut Session| run(s, "brush.get", json!({"name": "Masked"}))["colorization"]["key"].clone();
|
||||
assert_eq!(masked(&mut s), red);
|
||||
run(&mut s, "swatch.edit", json!({"name": "MaskTone", "color": "#0000ff"}));
|
||||
assert_eq!(masked(&mut s), red);
|
||||
|
||||
// With the art and its mask on one swatch, the key takes the art's new color, not the mask's.
|
||||
run(&mut s, "swatch.new", json!({"name": "Both", "color": "#ffffff", "global": true}));
|
||||
let obj = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Both", "ids": [obj.0]}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true, "ids": [obj.0]}));
|
||||
let m = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Both", "tint": 50, "ids": [m.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [obj.0, m.0]}));
|
||||
run(&mut s, "transparency.makeOpacityMask", json!({"clip": true, "invert": false}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Both", "ids": [obj.0], "params": {"colorization": {"method": "hueShift"}}}));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Both", "color": "#0000ff"}));
|
||||
assert_eq!(run(&mut s, "brush.get", json!({"name": "Both"}))["colorization"]["key"], blue);
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors brush art keeps the stored library entries that do not parse
|
||||
/// as brushes.
|
||||
#[test]
|
||||
fn global_swatch_edits_keep_unreadable_brush_entries() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
|
||||
let junk = json!({"name": "Unreadable", "kind": 7});
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
|
||||
entries.push(junk.clone());
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
assert!(stored.as_array().is_some_and(|a| a.contains(&junk)), "{stored}");
|
||||
assert!(vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").is_some());
|
||||
}
|
||||
|
||||
/// A global swatch edit that recolors brush art keeps the fields of stored brush entries that the
|
||||
/// brush definition does not read, both on the brush it recolors and on the brushes it leaves alone,
|
||||
/// and inside the art too. A swatch delete that unlinks the art drops the link and keeps those fields.
|
||||
#[test]
|
||||
fn global_swatch_edits_keep_unread_brush_fields() {
|
||||
// Adds `"extra": 8` to every object under `v` that links a swatch.
|
||||
fn tag_links(v: &mut Value) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if o.contains_key("swatch") {
|
||||
o.insert("extra".into(), json!(8));
|
||||
}
|
||||
o.values_mut().for_each(tag_links);
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(tag_links),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The objects under `v` that carry `"extra": 8`.
|
||||
fn tagged(v: &Value) -> Vec<&Value> {
|
||||
match v {
|
||||
Value::Object(o) => o.get("extra").filter(|e| **e == json!(8)).map(|_| v).into_iter().chain(o.values().flat_map(tagged)).collect(),
|
||||
Value::Array(a) => a.iter().flat_map(tagged).collect(),
|
||||
_ => vec![],
|
||||
}
|
||||
}
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "brush.new", json!({"type": "art", "name": "Holder", "ids": [id.0]}));
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let Some(Value::Array(entries)) = doc.unknown.get_mut(vectorcraft_brush::DOC_KEY) else { panic!("no stored brushes") };
|
||||
for e in entries.iter_mut() {
|
||||
if let Some(o) = e.as_object_mut() {
|
||||
o.insert("extra".into(), json!(7));
|
||||
}
|
||||
tag_links(e);
|
||||
}
|
||||
let links = tagged(&doc.unknown[vectorcraft_brush::DOC_KEY]).len();
|
||||
assert!(links > 0, "{}", doc.unknown[vectorcraft_brush::DOC_KEY]);
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
let entries = stored.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
assert!(entries.len() > 1, "{stored}");
|
||||
assert!(entries.iter().all(|e| e["extra"] == json!(7)), "{stored}");
|
||||
assert_eq!(tagged(stored).len(), links, "{stored}");
|
||||
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
|
||||
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
|
||||
assert_eq!(fill, Some(Some("#0000ff".to_string())));
|
||||
|
||||
run(&mut s, "swatch.delete", json!({"name": "Tone"}));
|
||||
let stored = &s.doc().unwrap().doc.unknown[vectorcraft_brush::DOC_KEY];
|
||||
let unlinked = tagged(stored);
|
||||
assert_eq!(unlinked.len(), links, "{stored}");
|
||||
assert!(unlinked.iter().all(|o| o.get("swatch").is_none()), "{stored}");
|
||||
let holder = vectorcraft_brush::find(&s.doc().unwrap().doc, "Holder").unwrap();
|
||||
let fill = holder.art().first().map(|n| n.appearance.fill_paint().color().map(|c| c.to_hex()));
|
||||
assert_eq!(fill, Some(Some("#0000ff".to_string())));
|
||||
}
|
||||
|
||||
/// A global swatch edit, rename, delete and Default Swatches with replace change the art of graph
|
||||
/// designs and brushes: the art follows the swatch's color and name, or keeps its color unlinked,
|
||||
/// each in one undo step.
|
||||
#[test]
|
||||
fn global_swatch_edits_reach_graph_designs_and_brush_art() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
for (cmd, p) in [("graph.design", json!({"new": "Bar"})), ("brush.new", json!({"type": "art", "name": "Holder"}))] {
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
run(&mut s, cmd, p);
|
||||
run(&mut s, "edit.clear", json!({"ids": [id.0]}));
|
||||
}
|
||||
let both = |fill: &str| vec![format!("design: {fill}"), format!("brush art: {fill}")];
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
|
||||
let r = run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
assert_eq!(r["relinked"], 2, "{r}");
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#0000ff, Tone"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Tone"));
|
||||
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "newName": "Hue"}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, Hue"));
|
||||
// Default Swatches with replace drops the links to the swatches it removes.
|
||||
run(&mut s, "swatch.resetDefaults", json!({"replace": true}));
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
let r = run(&mut s, "swatch.delete", json!({"name": "Hue"}));
|
||||
assert_eq!(r["unlinked"], 2, "{r}");
|
||||
assert_eq!(fills_outside_the_layers(&s), both("#ff0000, -"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 2);
|
||||
}
|
||||
|
||||
/// A global swatch edited while a pattern is being edited also changes the tile that pattern
|
||||
/// editing's Cancel puts back.
|
||||
#[test]
|
||||
fn global_swatch_edits_reach_the_tile_that_pattern_cancel_puts_back() {
|
||||
let mut s = session();
|
||||
run(&mut s, "swatch.new", json!({"name": "Tone", "color": "#ff0000", "global": true}));
|
||||
let id = rect(&mut s);
|
||||
run(&mut s, "paint.setFill", json!({"swatch": "Tone", "ids": [id.0]}));
|
||||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||||
let pattern = run(&mut s, "object.pattern.make", json!({"edit": false}))["name"].as_str().unwrap().to_string();
|
||||
run(&mut s, "object.pattern.edit", json!({"name": pattern}));
|
||||
run(&mut s, "swatch.edit", json!({"name": "Tone", "color": "#0000ff"}));
|
||||
run(&mut s, "object.pattern.cancel", json!({}));
|
||||
let mut fills = vec![];
|
||||
for n in &doc(&s).pattern(&pattern).unwrap().art {
|
||||
n.walk(&mut |m| fills.push(m.appearance.fill_paint()));
|
||||
}
|
||||
assert_eq!(fills, [Paint::Solid { color: Color::rgb(0.0, 0.0, 1.0), swatch: Some("Tone".into()), tint: 1.0 }]);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,8 @@ pub struct ViewInfo {
|
||||
pub snap_to_pixel: bool,
|
||||
/// View → Snap to Point: picked points (a transform's reference point) land on anchors.
|
||||
pub snap_to_point: bool,
|
||||
/// View → Snap to Glyph: drawing snaps to live-text baselines and glyph bounds (needs Smart Guides).
|
||||
/// View → Snap to Glyph: drawing snaps to live-text glyph bounds, and on a single line of type to
|
||||
/// its baseline, x-height, character widths and glyph anchor points (needs Smart Guides).
|
||||
pub snap_to_glyph: bool,
|
||||
/// View → Show Corner Widget.
|
||||
pub corner_widgets: bool,
|
||||
|
||||
Reference in New Issue
Block a user