mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 08:45:25 +00:00
Merge main into fix/graph-series-group-opacity
This commit is contained in:
@@ -17,7 +17,8 @@
|
||||
//! that copied to send its data (a bitmap 10 s), and until that app answers, later reads fail at
|
||||
//! once; a paste then waits for that read's answer off the UI thread.
|
||||
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
|
||||
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text). A
|
||||
//! file of more than 64 MiB is skipped before it is read.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::PathBuf;
|
||||
@@ -144,9 +145,18 @@ fn dib_image(dib: &[u8]) -> Option<image::RgbaImage> {
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// The most bytes a paste takes from another app (untrusted data), VectorCraft's own limit: a copied
|
||||
/// file of more is skipped, and on Wayland a read of more fails.
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
|
||||
/// The first of `paths` (files copied in a file manager) that pastes as one of `mimes`, read
|
||||
/// whole. Only its first bytes are read to tell, so other files cost little.
|
||||
/// whole, at most [`MAX_BYTES`]. Only its first bytes are read to tell, so other files cost little.
|
||||
fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
copied_file_within(paths, mimes, MAX_BYTES)
|
||||
}
|
||||
|
||||
/// [`copied_file`] with a file of more than `limit` bytes skipped.
|
||||
fn copied_file_within(paths: Vec<PathBuf>, mimes: &[&'static str], limit: usize) -> Option<Flavour> {
|
||||
paths.into_iter().find_map(|path| {
|
||||
// text/uri-list lines end in CRLF (RFC 2483), but arboard splits them on LF only.
|
||||
let path = match path.to_str() {
|
||||
@@ -164,14 +174,29 @@ fn copied_file(paths: Vec<PathBuf>, mimes: &[&'static str]) -> Option<Flavour> {
|
||||
if !std::fs::metadata(&path).ok()?.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut file = std::fs::File::open(&path).ok()?;
|
||||
// The file opened may not be the one looked at: its size is checked before any read.
|
||||
let meta = file.metadata().ok()?;
|
||||
if !meta.is_file() || meta.len() > limit as u64 {
|
||||
return None;
|
||||
}
|
||||
let mut head = Vec::with_capacity(FILE_HEAD);
|
||||
std::fs::File::open(&path).ok()?.take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
|
||||
(&mut file).take(FILE_HEAD as u64).read_to_end(&mut head).ok()?;
|
||||
let mime = file_flavour(&path.to_string_lossy(), &head, mimes)?;
|
||||
let data = std::fs::read(&path).ok()?;
|
||||
let data = read_rest(file, head, limit)?;
|
||||
(!data.is_empty()).then_some(Flavour { mime, data })
|
||||
})
|
||||
}
|
||||
|
||||
/// `data` (the first bytes read of `file`) followed by the rest of `file`, or `None` when they come
|
||||
/// to more than `limit` bytes, as when the file grew after its size was checked. At most one byte
|
||||
/// past `limit` is read.
|
||||
fn read_rest(file: impl Read, mut data: Vec<u8>, limit: usize) -> Option<Vec<u8>> {
|
||||
let more = (limit + 1).saturating_sub(data.len());
|
||||
file.take(more as u64).read_to_end(&mut data).ok()?;
|
||||
(data.len() <= limit).then_some(data)
|
||||
}
|
||||
|
||||
/// The most URIs a copied file list may hold, VectorCraft's own limit: a longer list (untrusted
|
||||
/// data) names no files.
|
||||
const MAX_URIS: usize = 1024;
|
||||
@@ -1900,7 +1925,7 @@ mod wayland {
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle, WEnum};
|
||||
|
||||
use super::loads::{Asker, GONE, LOAD_WAIT, Loads, wait_for_answer};
|
||||
use super::{BITMAP, Flavour, PNG, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths};
|
||||
use super::{BITMAP, Flavour, MAX_BYTES, PNG, PathBuf, SVG, TEXT, copied_file, gnome_copied_paths, uri_list_paths};
|
||||
|
||||
/// The types text is offered under and read from, best first: `text/plain;charset=utf-8` (GTK
|
||||
/// and Qt apps), `UTF8_STRING` (X11 apps through XWayland) and `text/plain`.
|
||||
@@ -1911,9 +1936,6 @@ mod wayland {
|
||||
/// best first: `text/uri-list` (RFC 2483), and GNOME Files' own list, read only when the first
|
||||
/// names no file.
|
||||
const FILE_LISTS: [(&str, PathsOf); 2] = [("text/uri-list", uri_list_paths), ("x-special/gnome-copied-files", gnome_copied_paths)];
|
||||
/// The most bytes a read takes from the app that copied (untrusted data), VectorCraft's own
|
||||
/// limit: a read of more fails.
|
||||
const MAX_BYTES: usize = 64 << 20;
|
||||
/// The most pastes of a Copy's data that are sent at once; a paste beyond them gets nothing.
|
||||
const MAX_SENDS: usize = 8;
|
||||
/// The most types of an offer that are kept (untrusted data).
|
||||
@@ -3176,6 +3198,51 @@ mod tests {
|
||||
assert_eq!(copied_file(vec![with_cr], &PASTE_ORDER).map(|f| f.mime), Some(PNG));
|
||||
}
|
||||
|
||||
/// A copied file of more than 64 MiB is skipped before it is read, and the next one pastes.
|
||||
#[test]
|
||||
fn an_oversized_copied_file_is_skipped_before_it_is_read() {
|
||||
let d = dir("oversized");
|
||||
let (big, small) = (d.path().join("big.png"), d.path().join("small.png"));
|
||||
std::fs::write(&big, png(2, 2)).unwrap();
|
||||
// Sparse where the file system allows: no 64 MiB is written.
|
||||
std::fs::OpenOptions::new().write(true).open(&big).unwrap().set_len(MAX_BYTES as u64 + 1).unwrap();
|
||||
std::fs::write(&small, png(3, 2)).unwrap();
|
||||
assert!(copied_file(vec![big.clone()], &PASTE_ORDER).is_none());
|
||||
let f = copied_file(vec![big, small], &PASTE_ORDER).unwrap();
|
||||
assert_eq!((f.mime, f.data), (PNG, png(3, 2)));
|
||||
}
|
||||
|
||||
/// A copied file of exactly the limit pastes; one byte more and it is skipped.
|
||||
#[test]
|
||||
fn a_copied_file_pastes_up_to_the_limit() {
|
||||
let d = dir("limit");
|
||||
let art = d.path().join("art.svg");
|
||||
let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\"/>";
|
||||
std::fs::write(&art, svg).unwrap();
|
||||
let read = |limit| copied_file_within(vec![art.clone()], &PASTE_ORDER, limit).map(|f| f.data);
|
||||
assert_eq!(read(svg.len()), Some(svg.as_bytes().to_vec()));
|
||||
assert_eq!(read(svg.len() - 1), None);
|
||||
}
|
||||
|
||||
/// A file that grows past the limit after its size was checked is read to one byte past the
|
||||
/// limit and refused.
|
||||
#[test]
|
||||
fn the_rest_of_a_file_is_read_to_the_limit_at_most() {
|
||||
let read = |len: usize, head: usize| {
|
||||
let bytes = vec![7u8; len];
|
||||
let mut file = Cursor::new(bytes);
|
||||
let mut first = vec![0; head];
|
||||
file.read_exact(&mut first).unwrap();
|
||||
(read_rest(&mut file, first, 16).map(|d| d.len()), file.position())
|
||||
};
|
||||
assert_eq!(read(16, 4), (Some(16), 16));
|
||||
assert_eq!(read(0, 0), (Some(0), 0));
|
||||
assert_eq!(read(17, 4), (None, 17));
|
||||
assert_eq!(read(1000, 4), (None, 17));
|
||||
// First bytes already past the limit: nothing more is read.
|
||||
assert_eq!(read(1000, 20), (None, 20));
|
||||
}
|
||||
|
||||
fn uri_paths(list: &[u8]) -> Vec<String> {
|
||||
uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect()
|
||||
}
|
||||
|
||||
@@ -1024,6 +1024,26 @@ fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
||||
/// Whether a plot draws nothing of `n` wherever it is, apart from what every export leaves out
|
||||
/// (hidden objects, guides and template layers): an object at 0% opacity, a non-printing layer, a
|
||||
/// placed image, a gradient mesh whose every point is at 0% opacity, or a path or compound path
|
||||
/// without effects whose fills and strokes paint nothing. An image or a mesh in `doc` with a
|
||||
/// visible Crop Marks effect, or with a Vector Halftone effect that makes dots, is not one of them.
|
||||
/// A clipping path still clips when the plot draws nothing of it.
|
||||
pub fn plots_nothing(doc: &Document, n: &Node) -> bool {
|
||||
let unpainted = || n.appearance.effects.is_empty() && top_stroke(n).is_none() && top_fill(n).is_none();
|
||||
let dots = || vectorcraft_effects::addons::halftone::halftone_art(doc, n).is_some_and(|g| g.children().is_some_and(|c| !c.is_empty()));
|
||||
let adds_art = || vectorcraft_effects::has_crop_marks(n) || dots();
|
||||
n.opacity <= 0.0
|
||||
|| match &n.kind {
|
||||
NodeKind::Layer { printable, .. } => !printable,
|
||||
NodeKind::Image(_) => !adds_art(),
|
||||
NodeKind::Mesh(m) => !adds_art() && m.points.iter().all(|p| p.opacity <= 0.0),
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => unpainted(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `nodes` (the children of a clipping container when `clipping`) without the layers and
|
||||
/// sublayers a plot leaves out (hidden, non-printing, template or at 0% opacity) and without
|
||||
/// guides, in layers and groups at any depth, so that baking can't wrap one in art that is drawn
|
||||
|
||||
@@ -177,6 +177,90 @@ fn meshes_in_blends_are_outlines_and_clear_meshes_draw_nothing() {
|
||||
assert_eq!(t.matches("PD").count(), 3, "{t}");
|
||||
}
|
||||
|
||||
/// `plots_nothing` picks the objects and layers that a plot draws nothing of: a path that paints
|
||||
/// nothing, objects at 0% opacity, placed images, meshes with every point at 0% opacity and
|
||||
/// non-printing layers, and not a painted path or a mesh.
|
||||
#[test]
|
||||
fn plots_nothing_picks_what_a_plot_draws_nothing_of() {
|
||||
type Make = fn(&mut Document) -> Node;
|
||||
let cases: [(&str, Make, bool); 7] = [
|
||||
("stroked", |d| stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK), false),
|
||||
("unpainted", |d| Node::path(d.alloc_id(), rect(10.0, 10.0, 40.0, 40.0), Appearance::basic(Paint::None, Paint::None, 1.0)), true),
|
||||
(
|
||||
"clear",
|
||||
|d| {
|
||||
let mut n = filled(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK);
|
||||
n.opacity = 0.0;
|
||||
n
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
"image",
|
||||
|d| {
|
||||
let image = vectorcraft_doc::ImageObject {
|
||||
key: "raster-1".into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)))
|
||||
},
|
||||
true,
|
||||
),
|
||||
(
|
||||
"image with Crop Marks",
|
||||
|d| {
|
||||
let image = vectorcraft_doc::ImageObject {
|
||||
key: "raster-1".into(),
|
||||
width: 4,
|
||||
height: 2,
|
||||
xf: Affine::translate((10.0, 20.0)) * Affine::scale(5.0),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let mut n = Node::new(d.alloc_id(), NodeKind::Image(Box::new(image)));
|
||||
n.appearance.effects.push(vectorcraft_doc::Effect { id: "cropMarks".into(), params: serde_json::json!({}), visible: true });
|
||||
n
|
||||
},
|
||||
false,
|
||||
),
|
||||
("mesh", |d| Node::new(d.alloc_id(), NodeKind::Mesh(mesh_at(10.0))), false),
|
||||
(
|
||||
"clear mesh",
|
||||
|d| {
|
||||
let mut clear = mesh_at(10.0);
|
||||
clear.points.iter_mut().for_each(|p| p.opacity = 0.0);
|
||||
Node::new(d.alloc_id(), NodeKind::Mesh(clear))
|
||||
},
|
||||
true,
|
||||
),
|
||||
];
|
||||
for (name, make, nothing) in cases {
|
||||
let mut held = None;
|
||||
let d = doc_with(|d| {
|
||||
let n = make(d);
|
||||
held = Some(n.clone());
|
||||
vec![n]
|
||||
});
|
||||
let Some(n) = held else { panic!("{name}") };
|
||||
assert_eq!(plots_nothing(&d, &n), nothing, "{name}");
|
||||
assert_eq!(text(&plot(&d, &opts(&d))) == "IN;PU;SP0;", nothing, "{name}");
|
||||
}
|
||||
let mut d = doc_with(|d| vec![stroked(d, rect(10.0, 10.0, 40.0, 40.0), Color::BLACK)]);
|
||||
let layer = d.layers[0].id;
|
||||
assert!(!plots_nothing(&d, &d.layers[0]));
|
||||
if let Some(NodeKind::Layer { printable, .. }) = d.node_mut(layer).map(|n| &mut n.kind) {
|
||||
*printable = false;
|
||||
}
|
||||
assert!(plots_nothing(&d, &d.layers[0]));
|
||||
assert_eq!(text(&plot(&d, &opts(&d))), "IN;PU;SP0;");
|
||||
}
|
||||
|
||||
/// Live art that makes more objects than the plot's limit (1,000 in tests) is left out with a
|
||||
/// warning.
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,10 @@ description = "VectorCraft document model: artboards, layers, objects, appearanc
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[features]
|
||||
# `vectorcraft_doc::blend::spines_built` for other crates' tests (dev-dependencies only).
|
||||
spine-count = []
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
|
||||
+10
-2
@@ -1255,13 +1255,21 @@ pub fn blend_step_count(a: &Node, b: &Node, spacing: BlendSpacing, len: f64) ->
|
||||
// Spine
|
||||
// =====================================================================================
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "spine-count"))]
|
||||
thread_local! {
|
||||
/// How many times [`Spine::new`] has run on this thread, for tests that count how often a path
|
||||
/// is flattened.
|
||||
pub(crate) static SPINES_BUILT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// How many times [`Spine::new`] has run on this thread, for other crates' tests that count how
|
||||
/// often a path is flattened (the `spine-count` feature, dev-dependencies only).
|
||||
#[cfg(any(test, feature = "spine-count"))]
|
||||
#[doc(hidden)]
|
||||
pub fn spines_built() -> usize {
|
||||
SPINES_BUILT.with(|n| n.get())
|
||||
}
|
||||
|
||||
/// A spine (the first subpath of a path) flattened to a polyline, with cumulative lengths and the
|
||||
/// arc length at each of its anchors.
|
||||
pub struct Spine {
|
||||
@@ -1280,7 +1288,7 @@ impl Spine {
|
||||
/// arc lengths, and the arc length at each anchor. `None` when `path` has no subpath, or when
|
||||
/// the first one flattens to fewer than two points (so it has no length to walk).
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
#[cfg(test)]
|
||||
#[cfg(any(test, feature = "spine-count"))]
|
||||
SPINES_BUILT.with(|n| n.set(n.get() + 1));
|
||||
let sp = path.subpaths.first()?;
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
|
||||
@@ -150,6 +150,10 @@ pub struct LabelStyles {
|
||||
pub categories: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend: Option<crate::CharStyle>,
|
||||
/// The fill of [`Self::legend`] was set on the legend labels, so it is drawn on every pie wedge with Legends in
|
||||
/// Wedges. `false`: a black fill there is the default, and the labels on dark wedges are drawn white.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub legend_fill_set: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_para: Option<crate::ParaStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -182,6 +186,18 @@ impl LabelStyles {
|
||||
fn is_default(&self) -> bool {
|
||||
*self == Self::default()
|
||||
}
|
||||
|
||||
/// Is the kept legend fill the legend labels' own: set on them ([`Self::legend_fill_set`]), or not black (a file
|
||||
/// saved without the mark)? Otherwise it is the default black, which edits that map the kept paints leave as it is.
|
||||
pub fn legend_fill_is_own(&self) -> bool {
|
||||
self.legend.as_ref().is_some_and(|st| self.legend_fill_set || st.fill != vectorcraft_color::Paint::solid(vectorcraft_color::Color::BLACK))
|
||||
}
|
||||
|
||||
/// Mark a kept legend fill that is the labels' own ([`Self::legend_fill_is_own`]) as set on them, before an edit
|
||||
/// that maps the kept paints can turn it black.
|
||||
pub fn mark_own_legend_fill(&mut self) {
|
||||
self.legend_fill_set = self.legend_fill_is_own();
|
||||
}
|
||||
}
|
||||
|
||||
/// Graph Type › Position for pie graphs with several pies: the same size (Even), sized by their totals (Ratio), or
|
||||
@@ -548,6 +564,14 @@ impl GraphSpec {
|
||||
self.series_kinds.get(s).copied().flatten().filter(|k| k.combines_with(self.kind)).unwrap_or(self.kind)
|
||||
}
|
||||
|
||||
/// Does the graph pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie
|
||||
/// with Legends in Wedges whose legend labels keep no fill of their own ([`LabelStyles::legend_fill_is_own`])? Only
|
||||
/// then is a black legend fill it keeps the default, which edits that map the kept paints leave as it is; other
|
||||
/// graphs draw the kept fill as it is.
|
||||
pub fn picks_legend_fill(&self) -> bool {
|
||||
self.kind == GraphKind::Pie && self.legend && self.pie_legend_in_wedges && !self.label_styles.legend_fill_is_own()
|
||||
}
|
||||
|
||||
/// The data with blank cells as `None`, every row as long as the longest (at most [`MAX_GRAPH_CATEGORIES`] rows
|
||||
/// of [`MAX_GRAPH_SERIES`] cells).
|
||||
pub fn cells(&self) -> Vec<Vec<Option<f64>>> {
|
||||
|
||||
+27
-23
@@ -974,43 +974,45 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and
|
||||
/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance
|
||||
/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform.
|
||||
struct InstanceArt {
|
||||
struct InstanceArt<'a> {
|
||||
art: Node,
|
||||
map: Option<crate::NineSliceMap>,
|
||||
xf: vectorcraft_geom::Affine,
|
||||
/// Quick boxes, which measure the instances in the art as it is placed through the guides.
|
||||
boxes: SymbolBoxes<'a>,
|
||||
}
|
||||
|
||||
impl InstanceArt {
|
||||
impl<'a> InstanceArt<'a> {
|
||||
/// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that
|
||||
/// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of,
|
||||
/// which [`Document::place_symbol_art`] does not place and the canvas does not draw.
|
||||
fn of(doc: &Document, inst: &Node) -> Option<Self> {
|
||||
fn of(doc: &'a Document, inst: &Node) -> Option<Self> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = crate::symbol_overrides::dynamic_art(&sym.art, inst);
|
||||
let map = doc.symbol_slice_map(symbol, *xf);
|
||||
if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
let art = crate::symbol_overrides::dynamic_art(boxes.symbol_art(symbol)?, inst);
|
||||
let map = boxes.slice_map(symbol, *xf);
|
||||
if map.as_ref().is_some_and(|m| m.most_placed_with(&art, &boxes) > crate::MAX_SYMBOL_NODES) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { art, map, xf: *xf })
|
||||
Some(Self { art, map, xf: *xf, boxes })
|
||||
}
|
||||
|
||||
/// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the
|
||||
/// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell
|
||||
/// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides
|
||||
/// object by object.
|
||||
fn whole(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> {
|
||||
whole.or_else(|| self.map.as_ref()?.one_cell(n, doc))
|
||||
fn whole(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> {
|
||||
whole.or_else(|| self.map.as_ref()?.one_cell_with(n, &self.boxes))
|
||||
}
|
||||
|
||||
/// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by
|
||||
/// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when
|
||||
/// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as
|
||||
/// [`Document::place_symbol_art`] refuses it.
|
||||
fn place(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
fn place(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)),
|
||||
(Some(m), None) => (m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes)),
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
@@ -1018,10 +1020,13 @@ impl InstanceArt {
|
||||
/// Object `n` of the art placed on the canvas without clip groups
|
||||
/// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the
|
||||
/// same limit ([`Document::place_symbol_art_unclipped`]).
|
||||
fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
fn place_unclipped(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)),
|
||||
(Some(m), None) => {
|
||||
let m = m.unclipped();
|
||||
(m.most_placed_with(n, &self.boxes) <= crate::MAX_SYMBOL_NODES).then(|| m.place_with(n, &self.boxes))
|
||||
}
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
@@ -1046,15 +1051,14 @@ fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node {
|
||||
/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`.
|
||||
pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> {
|
||||
struct Find<'a> {
|
||||
doc: &'a Document,
|
||||
art: &'a InstanceArt,
|
||||
art: &'a InstanceArt<'a>,
|
||||
p: Point,
|
||||
opt: HitOptions,
|
||||
symbols: &'a SymbolBoxes<'a>,
|
||||
}
|
||||
impl Find<'_> {
|
||||
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> bool {
|
||||
self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some())
|
||||
self.art.place(n, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some())
|
||||
}
|
||||
fn skips_guide(&self, n: &Node) -> bool {
|
||||
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
@@ -1067,11 +1071,11 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) ->
|
||||
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
|
||||
return self.hit(n, whole);
|
||||
}
|
||||
let whole = self.art.whole(n, self.doc, whole);
|
||||
let whole = self.art.whole(n, whole);
|
||||
if n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
{
|
||||
let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false };
|
||||
let Some(clip) = self.art.place_unclipped(first, whole) else { return false };
|
||||
if self.opt.clip_outlines
|
||||
&& first.visible
|
||||
&& !first.locked
|
||||
@@ -1103,7 +1107,7 @@ pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) ->
|
||||
let art = InstanceArt::of(doc, inst)?;
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut path = vec![];
|
||||
Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path)
|
||||
Find { art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path)
|
||||
}
|
||||
|
||||
/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]),
|
||||
@@ -1123,11 +1127,11 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option<
|
||||
let mut n = &art.art;
|
||||
for &i in path {
|
||||
let children = n.children()?;
|
||||
whole = art.whole(n, doc, whole);
|
||||
whole = art.whole(n, whole);
|
||||
if i != 0
|
||||
&& n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
&& let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds()
|
||||
&& let Some(clip) = art.place_unclipped(first, whole)?.geometric_bounds()
|
||||
{
|
||||
match meet(Some(clip), limit) {
|
||||
Some(r) => limit = Some(r),
|
||||
@@ -1136,7 +1140,7 @@ pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option<
|
||||
}
|
||||
n = children.get(usize::try_from(i).ok()?)?;
|
||||
}
|
||||
outline_within(&art.place(part, doc, whole)?, limit, &mut out);
|
||||
outline_within(&art.place(part, whole)?, limit, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
|
||||
@@ -1586,6 +1586,53 @@ fn symbol_art_past_the_object_limit_picks_nothing() {
|
||||
assert!(symbol_part_outline(&d, n, &[0]).is_some());
|
||||
}
|
||||
|
||||
/// Picking and outlining an object of a 9-slice instance's art, and placing that art, read the
|
||||
/// symbol list once for each call, however many instances the art holds.
|
||||
#[test]
|
||||
fn picking_in_9_slice_art_reads_the_symbol_list_once() {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
for i in 0..2_000 {
|
||||
let dot = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art());
|
||||
d.symbols.push(crate::Symbol { dynamic: false, name: format!("Dot {i}"), art: Arc::new(dot) });
|
||||
}
|
||||
// A frame, a group of 20 squares in its top left cell, and 20 instances of the dots across
|
||||
// the guides.
|
||||
let square = |d: &mut Document, x: f64, y: f64| {
|
||||
Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, y, x + 4.0, y + 4.0)), Appearance::default_art()))
|
||||
};
|
||||
let corner: Vec<_> = (0..20).map(|i| square(&mut d, f64::from(i % 5) * 4.0, f64::from(i / 5) * 4.0)).collect();
|
||||
let dot = |d: &mut Document, i: u32| {
|
||||
let xf = vectorcraft_geom::Affine::translate((f64::from(i) * 4.5 + 5.0, 48.0)) * vectorcraft_geom::Affine::scale(4.0);
|
||||
Arc::new(Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: format!("Dot {i}"), xf }))
|
||||
};
|
||||
let across: Vec<_> = (0..20).map(|i| dot(&mut d, i)).collect();
|
||||
let frame = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art()));
|
||||
let mut kids = vec![frame, Arc::new(Node::group(d.alloc_id(), corner))];
|
||||
kids.extend(across);
|
||||
let root = Node::group(d.alloc_id(), kids);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Panel".into(), art: Arc::new(root) });
|
||||
d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default()));
|
||||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||||
let inst = Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Panel".into(), xf });
|
||||
let art = d.symbols[2_000].art.clone();
|
||||
let symbols = d.symbols.len();
|
||||
let read = || crate::symbols::SYMBOLS_READ.with(std::cell::Cell::get);
|
||||
let reads = |f: &dyn Fn()| {
|
||||
let before = read();
|
||||
f();
|
||||
read() - before
|
||||
};
|
||||
// Picking also tests the placed art with boxes of its own, as a click outside symbols does.
|
||||
let n = reads(&|| assert_eq!(symbol_part_at(&d, &inst, Point::new(2.0, 2.0), HitOptions::default()).map(|p| p[0]), Some(1)));
|
||||
assert!(n <= 2 * symbols, "picking read {n} symbols");
|
||||
let n = reads(&|| assert!(symbol_part_outline(&d, &inst, &[2]).is_some()));
|
||||
assert!(n <= symbols, "outlining read {n} symbols");
|
||||
let n = reads(&|| assert!(d.place_symbol_art("Panel", &art, xf).is_some()));
|
||||
assert!(n <= symbols, "placing read {n} symbols");
|
||||
let n = reads(&|| assert!(d.place_symbol_art_unclipped("Panel", &art, xf).is_some()));
|
||||
assert!(n <= symbols, "placing without clips read {n} symbols");
|
||||
}
|
||||
|
||||
/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it
|
||||
/// included, so an object in it is picked and outlined there even when placing that object through
|
||||
/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects.
|
||||
|
||||
@@ -234,8 +234,14 @@ pub fn is_objects_on_path(n: &Node) -> bool {
|
||||
/// The path of `n` if it is a path object an Objects on Path group can attach objects to: a path
|
||||
/// (not a guide) whose first subpath has a length ([`path_length`]).
|
||||
pub fn attachable_path(n: &Node) -> Option<&PathData> {
|
||||
attachable(n).map(|(path, _)| path)
|
||||
}
|
||||
|
||||
/// [`attachable_path`] with the path measured ([`Measured`]), from the one flattening that checks
|
||||
/// its length.
|
||||
pub fn attachable(n: &Node) -> Option<(&PathData, Measured)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, .. } if path_length(path).is_some() => Some(path),
|
||||
NodeKind::Path { path, guide: false, .. } => Some((path, Measured::new(path)?)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -301,20 +307,51 @@ pub fn path_length(path: &PathData) -> Option<(f64, bool)> {
|
||||
|
||||
/// A path's first subpath flattened ([`Spine`]), with its length and whether it is closed, so that
|
||||
/// one flattening serves both measuring the path and finding points along it.
|
||||
struct Measured {
|
||||
pub struct Measured {
|
||||
spine: Spine,
|
||||
len: f64,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Measured {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Measured").field("len", &self.len).field("closed", &self.closed).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Measured {
|
||||
/// `None` for a path without length, or with a length too large to be finite.
|
||||
fn new(path: &PathData) -> Option<Self> {
|
||||
pub fn new(path: &PathData) -> Option<Self> {
|
||||
let spine = Spine::new(path)?;
|
||||
let len = spine.length();
|
||||
let closed = path.subpaths.first().is_some_and(|s| s.closed);
|
||||
(len.is_finite() && len > 1e-9).then_some(Self { spine, len, closed })
|
||||
}
|
||||
/// The flattened first subpath, to measure along.
|
||||
pub fn spine(&self) -> &Spine {
|
||||
&self.spine
|
||||
}
|
||||
/// The length of the first subpath (points), as [`path_length`] gives it.
|
||||
pub fn length(&self) -> f64 {
|
||||
self.len
|
||||
}
|
||||
/// Is the first subpath closed?
|
||||
pub fn closed(&self) -> bool {
|
||||
self.closed
|
||||
}
|
||||
/// [`stops`] on this path.
|
||||
pub fn stops(&self, n: usize, spacing: Option<f64>, start: f64) -> Vec<(Point, f64)> {
|
||||
let (start, gap) = layout(self.len, self.closed, n, spacing, start);
|
||||
(0..n).map(|i| self.spine.at_length(nth_length(self.len, self.closed, start, gap, i))).collect()
|
||||
}
|
||||
/// [`stop`] on this path: `None` for an `i` of `n` or more.
|
||||
pub fn stop(&self, n: usize, spacing: Option<f64>, start: f64, i: usize) -> Option<(Point, f64)> {
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
let (start, gap) = layout(self.len, self.closed, n, spacing, start);
|
||||
Some(self.spine.at_length(nth_length(self.len, self.closed, start, gap, i)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the first of `n` objects attaches along a path of length `len` (closed or not), and the
|
||||
@@ -359,9 +396,7 @@ fn wrap(s: f64, len: f64) -> f64 {
|
||||
/// (radians, as `atan2` gives in the document's axes). They go along it as [`layout`] spaces them
|
||||
/// for Space `spacing` (`None`: Auto) from `start`. `None` for a path without length.
|
||||
pub fn stops(path: &PathData, n: usize, spacing: Option<f64>, start: f64) -> Option<Vec<(Point, f64)>> {
|
||||
let Measured { spine, len, closed } = Measured::new(path)?;
|
||||
let (start, gap) = layout(len, closed, n, spacing, start);
|
||||
Some((0..n).map(|i| spine.at_length(nth_length(len, closed, start, gap, i))).collect())
|
||||
Some(Measured::new(path)?.stops(n, spacing, start))
|
||||
}
|
||||
|
||||
/// Where object `i` of `n` attaches along `path`, as [`stops`] places it, without the others.
|
||||
@@ -370,9 +405,7 @@ pub fn stop(path: &PathData, n: usize, spacing: Option<f64>, start: f64, i: usiz
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
let Measured { spine, len, closed } = Measured::new(path)?;
|
||||
let (start, gap) = layout(len, closed, n, spacing, start);
|
||||
Some(spine.at_length(nth_length(len, closed, start, gap, i)))
|
||||
Measured::new(path)?.stop(n, spacing, start, i)
|
||||
}
|
||||
|
||||
/// The distance along a path of length `len` (closed or not) to object `i`, from `start` and
|
||||
|
||||
@@ -206,11 +206,13 @@ impl Document {
|
||||
impl Document {
|
||||
/// Visit every paint held by the art (opacity masks included), symbol definitions, pattern
|
||||
/// tiles and graphic styles (fills, strokes and text runs), and the colour of every
|
||||
/// gradient-mesh point as a solid paint.
|
||||
/// gradient-mesh point as a solid paint. A graph's default black legend fill is left out.
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).chain(override_paints(m)).chain(graph_paints(m)).for_each(&mut *f);
|
||||
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
|
||||
let default_legend = default_legend_fill(m);
|
||||
graph_paints(m).enumerate().filter(|(i, _)| Some(*i) != default_legend).for_each(|(_, p)| f(p));
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
}
|
||||
@@ -404,6 +406,19 @@ fn graph_paints_mut(g: &mut crate::graph::GraphSpec) -> impl Iterator<Item = &mu
|
||||
.chain(rows.flat_map(|r| [r.paint.fill.as_mut(), r.paint.stroke.as_mut()]).flatten())
|
||||
}
|
||||
|
||||
/// The place in [`graph_paints`] of the legend fill graph `n` keeps when it is the default black of a graph that picks
|
||||
/// its legend labels' fill ([`crate::graph::GraphSpec::picks_legend_fill`]). Other graphs draw the kept fill as it is,
|
||||
/// so it is visited and mapped.
|
||||
fn default_legend_fill(n: &Node) -> Option<usize> {
|
||||
let g = n.extras().graph.as_deref()?;
|
||||
let l = &g.label_styles;
|
||||
if l.legend.is_none() || !g.picks_legend_fill() {
|
||||
return None;
|
||||
}
|
||||
let series: usize = g.series_paints.iter().map(|s| usize::from(s.fill.is_some()) + usize::from(s.stroke.is_some())).sum();
|
||||
Some(series + 2 * (usize::from(l.values.is_some()) + usize::from(l.categories.is_some())))
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
@@ -446,11 +461,16 @@ fn map_node(n: &Node, f: &mut PaintFn) -> Option<(Node, usize)> {
|
||||
}
|
||||
}
|
||||
// A graph's series and label paints, which its marks and labels take when it is drawn again.
|
||||
let graph: Vec<(usize, Paint)> = graph_paints(n).enumerate().filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
|
||||
// The default black legend fill, which is not the legend labels' own, stays black.
|
||||
let default_legend = default_legend_fill(n);
|
||||
let graph: Vec<(usize, Paint)> =
|
||||
graph_paints(n).enumerate().filter(|(i, _)| Some(*i) != default_legend).filter_map(|(i, p)| f(p).map(|q| (i, q))).collect();
|
||||
if !graph.is_empty() {
|
||||
count += graph.len();
|
||||
let m = out.get_or_insert_with(|| n.clone());
|
||||
if let Some(spec) = m.extras_if_any().and_then(|e| e.graph.as_deref_mut()) {
|
||||
// A legend fill of the labels' own stays theirs when the change makes it black.
|
||||
spec.label_styles.mark_own_legend_fill();
|
||||
let mut graph = graph.into_iter().peekable();
|
||||
for (i, p) in graph_paints_mut(spec).enumerate() {
|
||||
if graph.peek().is_some_and(|c| c.0 == i)
|
||||
@@ -720,6 +740,62 @@ mod tests {
|
||||
assert!(graph_paints(d.node(a).unwrap()).all(Paint::is_none));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_legend_fill_mapped_to_black_stays_the_labels_own() {
|
||||
let (mut d, a, _) = doc();
|
||||
let red = linked(Color::rgb(1.0, 0.0, 0.0), "Brand");
|
||||
let spec = crate::graph::GraphSpec {
|
||||
label_styles: crate::graph::LabelStyles { legend: Some(crate::CharStyle { fill: red, ..Default::default() }), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
assert!(spec.label_styles.legend_fill_is_own() && !spec.label_styles.legend_fill_set, "not black, saved without the mark");
|
||||
d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec));
|
||||
let mut to_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| {
|
||||
s.as_deref() == Some("Brand") && {
|
||||
*c = Color::BLACK;
|
||||
*s = None;
|
||||
true
|
||||
}
|
||||
};
|
||||
assert_eq!(d.map_solid_paints(&mut to_black), 2, "the rectangle's fill and the legend fill");
|
||||
let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles;
|
||||
assert_eq!(styles.legend.unwrap().fill, Paint::solid(Color::BLACK));
|
||||
assert!(styles.legend_fill_set, "the black fill is still the labels' own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_black_legend_fill_is_left_as_it_is() {
|
||||
use crate::graph::GraphKind;
|
||||
// Only a pie with Legends in Wedges picks its legend labels' fill; the other graphs draw the kept black.
|
||||
for (kind, in_wedges, picks) in [(GraphKind::Pie, true, true), (GraphKind::Pie, false, false), (GraphKind::Column, true, false)] {
|
||||
let (mut d, a, _) = doc();
|
||||
let black = || crate::CharStyle { fill: Paint::solid(Color::BLACK), size: 11.0, ..Default::default() };
|
||||
let spec = crate::graph::GraphSpec {
|
||||
kind,
|
||||
legend: true,
|
||||
pie_legend_in_wedges: in_wedges,
|
||||
series_paints: vec![crate::SeriesPaint { fill: Some(Paint::solid(Color::BLACK)), ..Default::default() }],
|
||||
label_styles: crate::graph::LabelStyles { values: Some(black()), legend: Some(black()), ..Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let blacks = |d: &Document| {
|
||||
let mut seen = 0;
|
||||
d.visit_paints(&mut |p| seen += usize::from(*p == Paint::solid(Color::BLACK)));
|
||||
seen
|
||||
};
|
||||
let before = blacks(&d);
|
||||
d.node_mut(a).unwrap().extras_mut().graph = Some(Box::new(spec));
|
||||
let want = if picks { 2 } else { 3 };
|
||||
assert_eq!(blacks(&d) - before, want, "{kind:?}, in wedges {in_wedges}: the series, value labels' and legend fills visited");
|
||||
let mut link_black = |c: &mut Color, s: &mut Option<String>, _: &mut f32| *c == Color::BLACK && s.replace("Black".into()).is_none();
|
||||
assert_eq!(d.map_solid_paints_in(&[a], &mut link_black), want, "{kind:?}, in wedges {in_wedges}");
|
||||
let styles = d.node(a).unwrap().extras().graph.clone().unwrap().label_styles;
|
||||
let legend = if picks { Paint::solid(Color::BLACK) } else { linked(Color::BLACK, "Black") };
|
||||
assert_eq!(styles.legend.unwrap().fill, legend, "{kind:?}, in wedges {in_wedges}: linked unless the pie picks the fill");
|
||||
assert_eq!(styles.values.unwrap().fill, linked(Color::BLACK, "Black"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapping_within_ids_visits_each_paint_once() {
|
||||
let (mut d, a, b) = doc();
|
||||
|
||||
@@ -463,8 +463,13 @@ impl NineSliceMap {
|
||||
///
|
||||
/// Strokes keep their weight, as they do for any instance.
|
||||
pub fn place(&self, art: &Node, doc: &Document) -> Node {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
self.place_at(art, &boxes, 0)
|
||||
self.place_with(art, &doc.symbol_boxes().quick())
|
||||
}
|
||||
|
||||
/// [`Self::place`] with the instances in `art` measured by `boxes`, quick boxes or boxes
|
||||
/// measuring symbol art.
|
||||
pub(crate) fn place_with(&self, art: &Node, boxes: &SymbolBoxes) -> Node {
|
||||
self.place_at(art, boxes, 0)
|
||||
}
|
||||
|
||||
/// [`Self::place`] without clip groups, for writers that do not apply clipping (DXF, WMF):
|
||||
@@ -480,16 +485,25 @@ impl NineSliceMap {
|
||||
/// object drawn in clip groups counts one group plus a clip path and a copy for each cell its
|
||||
/// box reaches. That is never more than [`MAX_SLICE_GROWTH`] for each object of `art`.
|
||||
pub fn most_placed(&self, art: &Node, doc: &Document) -> u64 {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
self.most_at(art, &boxes, 0)
|
||||
self.most_placed_with(art, &doc.symbol_boxes().quick())
|
||||
}
|
||||
|
||||
/// [`Self::most_placed`] with the instances in `art` measured by `boxes`, quick boxes or boxes
|
||||
/// measuring symbol art.
|
||||
pub(crate) fn most_placed_with(&self, art: &Node, boxes: &SymbolBoxes) -> u64 {
|
||||
self.most_at(art, boxes, 0)
|
||||
}
|
||||
|
||||
/// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one
|
||||
/// piece, with everything in it: the map of the cell its box lies in, or of the cell at the
|
||||
/// origin when it has no box. `None` when its box crosses a guide.
|
||||
pub fn one_cell(&self, n: &Node, doc: &Document) -> Option<Affine> {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
|
||||
self.one_cell_with(n, &doc.symbol_boxes().quick())
|
||||
}
|
||||
|
||||
/// [`Self::one_cell`] with the instances in `n` measured by `boxes`.
|
||||
pub(crate) fn one_cell_with(&self, n: &Node, boxes: &SymbolBoxes) -> Option<Affine> {
|
||||
let Some(b) = n.geometric(Some(boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
|
||||
return Some(self.cell_at(Point::ZERO));
|
||||
};
|
||||
let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
|
||||
@@ -501,6 +515,11 @@ impl NineSliceMap {
|
||||
Self { clips: false, ..*self }.most_placed(art, doc)
|
||||
}
|
||||
|
||||
/// This map placing without clip groups, as [`Self::place_unclipped`] does.
|
||||
pub(crate) fn unclipped(&self) -> Self {
|
||||
Self { clips: false, ..*self }
|
||||
}
|
||||
|
||||
/// [`Self::most_placed`] for `n` at nesting depth `depth`, following [`Self::place_at`].
|
||||
fn most_at(&self, n: &Node, boxes: &SymbolBoxes, depth: u32) -> u64 {
|
||||
let whole = n.count_with_masks() as u64;
|
||||
@@ -671,7 +690,7 @@ impl NineSliceMap {
|
||||
}
|
||||
|
||||
/// `n` moved by `a`, its strokes at their own weight.
|
||||
fn transformed(n: &Node, a: Affine) -> Node {
|
||||
pub(crate) fn transformed(n: &Node, a: Affine) -> Node {
|
||||
let mut out = n.clone();
|
||||
out.transform(a, false);
|
||||
out
|
||||
@@ -765,10 +784,7 @@ impl Document {
|
||||
/// `None` when the symbol has no guides or the instance is at its natural size. The guides cut
|
||||
/// the art's box with the instances in it measured by their own art.
|
||||
pub fn symbol_slice_map(&self, name: &str, xf: Affine) -> Option<NineSliceMap> {
|
||||
let guides = self.symbol_nine_slice(name)?;
|
||||
let sym = self.symbols.iter().find(|s| s.name == name)?;
|
||||
let art = self.symbol_boxes().quick().art_bounds(name).or_else(|| sym.art.geometric_bounds())?;
|
||||
NineSliceMap::new(guides, art, self.symbol_natural_xf(name), xf)
|
||||
self.symbol_boxes().quick().slice_map(name, xf)
|
||||
}
|
||||
|
||||
/// [`Self::symbol_slice_map`] with the guides cutting `art`, the symbol's art box already
|
||||
@@ -782,9 +798,10 @@ impl Document {
|
||||
/// it has them ([`NineSliceMap::place`]), otherwise by `xf`. `None` when placing it through
|
||||
/// the guides could make more than [`MAX_SYMBOL_NODES`] objects ([`NineSliceMap::most_placed`]).
|
||||
pub fn place_symbol_art(&self, name: &str, art: &Node, xf: Affine) -> Option<Node> {
|
||||
match self.symbol_slice_map(name, xf) {
|
||||
Some(m) if m.most_placed(art, self) > MAX_SYMBOL_NODES => None,
|
||||
Some(m) => Some(m.place(art, self)),
|
||||
let boxes = self.symbol_boxes().quick();
|
||||
match boxes.slice_map(name, xf) {
|
||||
Some(m) if m.most_placed_with(art, &boxes) > MAX_SYMBOL_NODES => None,
|
||||
Some(m) => Some(m.place_with(art, &boxes)),
|
||||
None => Some(transformed(art, xf)),
|
||||
}
|
||||
}
|
||||
@@ -795,8 +812,8 @@ impl Document {
|
||||
pub fn edited_nine_slice(&self) -> Option<EditedNineSlice> {
|
||||
let se = self.symbol_edit.as_ref()?;
|
||||
let guides = self.symbol_nine_slice(&se.symbol)?;
|
||||
let sym = self.symbols.iter().find(|s| s.name == se.symbol)?;
|
||||
let art = self.symbol_boxes().quick().art_bounds(&se.symbol).or_else(|| sym.art.geometric_bounds())?;
|
||||
let boxes = self.symbol_boxes().quick();
|
||||
let art = boxes.art_bounds(&se.symbol).or_else(|| boxes.symbol_art(&se.symbol)?.geometric_bounds())?;
|
||||
let natural = self.symbol_natural_xf(&se.symbol);
|
||||
let b = natural.transform_rect_bbox(art);
|
||||
if !finite(b) || b.width() <= 1e-9 || b.height() <= 1e-9 || natural.determinant().abs() < 1e-18 {
|
||||
@@ -811,8 +828,9 @@ impl Document {
|
||||
/// [`Self::place_symbol_art`] for writers that do not apply clipping
|
||||
/// ([`NineSliceMap::place_unclipped`]).
|
||||
pub fn place_symbol_art_unclipped(&self, name: &str, art: &Node, xf: Affine) -> Option<Node> {
|
||||
match self.symbol_slice_map(name, xf) {
|
||||
Some(m) => (m.most_placed_unclipped(art, self) <= MAX_SYMBOL_NODES).then(|| m.place_unclipped(art, self)),
|
||||
let boxes = self.symbol_boxes().quick();
|
||||
match boxes.slice_map(name, xf).map(|m| m.unclipped()) {
|
||||
Some(m) => (m.most_placed_with(art, &boxes) <= MAX_SYMBOL_NODES).then(|| m.place_with(art, &boxes)),
|
||||
None => Some(transformed(art, xf)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use std::sync::Arc;
|
||||
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, Node, NodeKind, SYMBOL_HALF};
|
||||
use crate::symbol_nine_slice::transformed;
|
||||
use crate::{AppearanceItem, Document, MAX_SYMBOL_NODES, NineSliceMap, Node, NodeKind, SYMBOL_HALF};
|
||||
|
||||
/// The box a symbol instance is measured by when its symbol's art has no box
|
||||
/// ([`SymbolBoxes::art`]), and in [`Node::geometric_bounds`]: the 20 × 20 square around the origin
|
||||
@@ -128,6 +129,8 @@ impl Document {
|
||||
/// Each symbol name of this document → the index of the first symbol of that name (the one a
|
||||
/// name finds).
|
||||
pub(crate) fn symbol_names(&self) -> SymbolNames {
|
||||
#[cfg(test)]
|
||||
SYMBOLS_READ.with(|c| c.set(c.get() + self.symbols.len()));
|
||||
let mut names = HashMap::with_capacity(self.symbols.len());
|
||||
for (i, s) in self.symbols.iter().enumerate() {
|
||||
names.entry(s.name.clone()).or_insert(i);
|
||||
@@ -136,6 +139,12 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// How many symbols [`Document::symbol_names`] has read on this thread.
|
||||
pub(crate) static SYMBOLS_READ: Cell<usize> = const { Cell::new(0) };
|
||||
}
|
||||
|
||||
impl<'a> SymbolBoxes<'a> {
|
||||
/// The document whose symbols these are.
|
||||
pub fn document(&self) -> &'a Document {
|
||||
@@ -437,8 +446,8 @@ impl<'a> SymbolBoxes<'a> {
|
||||
// The most objects placing makes, the art of opacity masks included, is set aside
|
||||
// while the art is placed and measured, and what placing did not make is given back.
|
||||
let art = self.symbol_art(name)?;
|
||||
let most = match self.doc.symbol_slice_map(name, linear) {
|
||||
Some(m) => m.most_placed(art, self.doc),
|
||||
let most = match self.slice_map(name, linear) {
|
||||
Some(m) => self.in_art(|| m.most_placed_with(art, self)),
|
||||
None => art.count_with_masks() as u64,
|
||||
};
|
||||
let used = self.sliced_placed.get();
|
||||
@@ -455,6 +464,14 @@ impl<'a> SymbolBoxes<'a> {
|
||||
v.map(|v| moved.transform_rect_bbox(v))
|
||||
}
|
||||
|
||||
/// [`Document::symbol_slice_map`] with the guides cutting the art box these boxes measure for
|
||||
/// the symbol (its geometric part, which does not depend on the mode of the boxes).
|
||||
pub(crate) fn slice_map(&self, name: &str, xf: Affine) -> Option<NineSliceMap> {
|
||||
let guides = self.doc.symbol_nine_slice(name)?;
|
||||
let art = self.art_bounds(name).or_else(|| self.symbol_art(name)?.geometric_bounds())?;
|
||||
NineSliceMap::new(guides, art, self.doc.symbol_natural_xf(name), xf)
|
||||
}
|
||||
|
||||
/// The box [`Self::placed_visual_within`] returns outside quick boxes.
|
||||
fn place_and_measure(&self, name: &str, xf: Affine, most: u64) -> Option<Rect> {
|
||||
self.place_and_count(name, xf, most).and_then(|(v, _)| v)
|
||||
@@ -464,10 +481,10 @@ impl<'a> SymbolBoxes<'a> {
|
||||
/// opacity masks included. `None` when the art is not placed.
|
||||
fn place_and_count(&self, name: &str, xf: Affine, most: u64) -> Option<(Option<Rect>, u64)> {
|
||||
self.measure_art(name, |art| {
|
||||
let art = match self.doc.symbol_slice_map(name, xf) {
|
||||
Some(m) if m.most_placed(art, self.doc) > most => return None,
|
||||
Some(m) => m.place(art, self.doc),
|
||||
None => self.doc.place_symbol_art(name, art, xf)?,
|
||||
let art = match self.slice_map(name, xf) {
|
||||
Some(m) if self.in_art(|| m.most_placed_with(art, self)) > most => return None,
|
||||
Some(m) => self.in_art(|| m.place_with(art, self)),
|
||||
None => transformed(art, xf),
|
||||
};
|
||||
Some((self.in_art(|| art.visual(Some(self))), art.count_with_masks() as u64))
|
||||
})
|
||||
@@ -689,6 +706,39 @@ mod tests {
|
||||
assert_eq!(s.sliced_placed.get(), 13);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measuring_9_slice_instances_reads_the_symbol_list_once() {
|
||||
// 2,000 symbols, then a frame with guides and a symbol with guides holding the frame
|
||||
// scaled 2 times across, each shown by 200 instances scaled by different amounts.
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
for i in 0..2000 {
|
||||
let dot = rect(&mut d, Rect::new(0.0, 0.0, 1.0, 1.0), 0.0);
|
||||
symbol(&mut d, &format!("Dot {i}"), vec![dot]);
|
||||
}
|
||||
let frame = rect(&mut d, Rect::new(0.0, 0.0, 100.0, 100.0), 1.0);
|
||||
symbol(&mut d, "Panel", vec![frame]);
|
||||
d.set_symbol_nine_slice("Panel", Some(crate::SymbolNineSlice::default()));
|
||||
let wide = instance(&mut d, "Panel", Affine::scale_non_uniform(2.0, 1.0));
|
||||
symbol(&mut d, "Holder", vec![wide]);
|
||||
d.set_symbol_nine_slice("Holder", Some(crate::SymbolNineSlice::default()));
|
||||
let scaled = |d: &mut Document, name: &str| -> Vec<Node> {
|
||||
(1..=200).map(|i| instance(d, name, Affine::scale_non_uniform(1.0 + f64::from(i) / 100.0, 1.5))).collect()
|
||||
};
|
||||
let (panels, holders) = (scaled(&mut d, "Panel"), scaled(&mut d, "Holder"));
|
||||
// What each instance measures in boxes of its own.
|
||||
let exact: Vec<_> = panels.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes())).collect();
|
||||
let quick: Vec<_> = holders.iter().map(|n| n.visual_bounds_with(&d.symbol_boxes().quick())).collect();
|
||||
let read = || SYMBOLS_READ.with(Cell::get);
|
||||
let before = read();
|
||||
let s = d.symbol_boxes();
|
||||
assert_eq!(panels.iter().map(|n| n.visual_bounds_with(&s)).collect::<Vec<_>>(), exact);
|
||||
let s = d.symbol_boxes().quick();
|
||||
assert_eq!(holders.iter().map(|n| n.visual_bounds_with(&s)).collect::<Vec<_>>(), quick);
|
||||
// Each set of boxes reads the list once to find symbols by name.
|
||||
let symbols = d.symbols.len();
|
||||
assert!(read() - before <= 2 * symbols, "{} symbols read", read() - before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_symbols_and_symbols_inside_themselves_measure_as_the_square() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_geom::{BezPath, FillRule, PathData};
|
||||
/// fills and strokes: the region is then the union of the paths they give, as an export bakes them.
|
||||
/// `None` when there is nothing to clip by (the clipped art is then hidden).
|
||||
pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
|
||||
match clip_effects_applied(clip, true) {
|
||||
match clip_effects_applied(clip, true, || true) {
|
||||
Some(m) => m.clip_outline(Some(&outline_text), &unite),
|
||||
None => clip.clip_outline(Some(&outline_text), &unite),
|
||||
}
|
||||
@@ -31,14 +31,26 @@ pub fn clip_outline(clip: &Node) -> Option<(BezPath, FillRule)> {
|
||||
/// geometry effects of its own fills and strokes live instead. `None` when nothing in it has a
|
||||
/// geometry effect. Effects on the clip group or around it then reshape the result, after its own.
|
||||
pub(crate) fn with_clip_effects_applied(n: &Node) -> Option<Node> {
|
||||
let paint = n.clip_paint();
|
||||
clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none())
|
||||
with_clip_effects_applied_while(n, || true)
|
||||
}
|
||||
|
||||
/// [`with_clip_effects_applied`]; with `items` unset (a clipping path that paints, and the paths of
|
||||
/// a compound path, which draw with the compound path's fills and strokes), the geometry effects of
|
||||
/// `n`'s own fills and strokes are left out.
|
||||
fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> {
|
||||
/// [`with_clip_effects_applied`] that calls `take` before it visits each member of a group, layer
|
||||
/// or compound path in `n`, at every depth: once `take` returns `false`, that member and the
|
||||
/// members after it are not visited. The members of a group or layer that are not visited stay as
|
||||
/// they are, with their effects. A compound path with geometry effects of its own still becomes a
|
||||
/// path of its reshaped geometry. In a clipping path that does not paint, one whose fills or
|
||||
/// strokes have geometry effects still becomes a group of one path per fill and stroke. In both
|
||||
/// cases the paths in it that are not visited go
|
||||
/// into that geometry as they are, and their own effects are not applied.
|
||||
pub(crate) fn with_clip_effects_applied_while(n: &Node, take: fn() -> bool) -> Option<Node> {
|
||||
let paint = n.clip_paint();
|
||||
clip_effects_applied(n, paint.fill.is_none() && paint.stroke.is_none(), take)
|
||||
}
|
||||
|
||||
/// [`with_clip_effects_applied_while`]; with `items` unset (a clipping path that paints, and the
|
||||
/// paths of a compound path, which draw with the compound path's fills and strokes), the geometry
|
||||
/// effects of `n`'s own fills and strokes are left out.
|
||||
fn clip_effects_applied(n: &Node, items: bool, take: fn() -> bool) -> Option<Node> {
|
||||
let mut m: Option<Node> = None;
|
||||
let members: &[std::sync::Arc<Node>] = match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => children.get(..1).unwrap_or_default(),
|
||||
@@ -53,7 +65,10 @@ fn clip_effects_applied(n: &Node, items: bool) -> Option<Node> {
|
||||
// The paths of a compound path draw with the compound path's fills and strokes, and stay paths.
|
||||
let compound = matches!(n.kind, NodeKind::Compound { .. });
|
||||
for (i, c) in members.iter().enumerate().skip(skip).filter(|(_, c)| all || c.visible) {
|
||||
if let Some(r) = clip_effects_applied(c, items && !compound)
|
||||
if !take() {
|
||||
break;
|
||||
}
|
||||
if let Some(r) = clip_effects_applied(c, items && !compound, take)
|
||||
&& let Some(slot) = m.get_or_insert_with(|| n.clone()).children_mut().and_then(|ch| ch.get_mut(i))
|
||||
{
|
||||
*slot = std::sync::Arc::new(r);
|
||||
|
||||
@@ -904,7 +904,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
/// depth of its groups and layers, by the art it draws ([`evaluate_container`],
|
||||
/// [`crate::reshape::own_effects_art`]), so the geometry effects of `n` reshape what its members
|
||||
/// draw. A clip group's clipping path only takes the geometry effects in it into its geometry
|
||||
/// ([`crate::clip::with_clip_effects_applied`]). Images, symbol instances and the other objects
|
||||
/// ([`crate::clip::with_clip_effects_applied_while`]), and the members of a clipping group or
|
||||
/// compound path count toward the limit below. A group or layer in a clipping path that has
|
||||
/// geometry effects of its own is walked twice, once for its members' effects and once for its
|
||||
/// own, and its members count each time. Images, symbol instances and the other objects
|
||||
/// that `own_effects_art` leaves alone stay as they are, and so does a group or layer with a 3D
|
||||
/// effect with everything in it. Calls nested in one another, through the
|
||||
/// groups and layers it walks and the art it evaluates (symbol art and Pathfinder members
|
||||
@@ -917,7 +920,7 @@ pub(crate) fn members_as_drawn(doc: Option<&Document>, n: &mut Node) {
|
||||
// The clipping path's own geometry effects shape it before the effects around it do.
|
||||
if clip == 1
|
||||
&& let Some(first) = ch.first_mut()
|
||||
&& let Some(applied) = crate::clip::with_clip_effects_applied(first)
|
||||
&& let Some(applied) = crate::clip::with_clip_effects_applied_while(first, DrawnFrame::take_member)
|
||||
{
|
||||
*first = Arc::new(applied);
|
||||
}
|
||||
|
||||
@@ -849,6 +849,53 @@ fn nested_groups_with_effects_count_toward_the_depth_limit() {
|
||||
assert!(deepest.is_some_and(|d| crate::reshape::geometry_effects(&d).len() == 1), "the deepest member keeps its effect");
|
||||
}
|
||||
|
||||
/// The members of a clipping path count toward the member limit: with room for two more members,
|
||||
/// the third member of a clipping group keeps its own effect as it is.
|
||||
#[test]
|
||||
fn clipping_path_members_past_the_member_limit_stay_as_they_are() {
|
||||
let member = |id: u64| {
|
||||
let mut m = with_effect(square_at_100(), offset_10());
|
||||
m.id = NodeId(id);
|
||||
Arc::new(m)
|
||||
};
|
||||
let clipping = Node::group(NodeId(20), vec![member(2), member(3), member(4)]);
|
||||
let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true });
|
||||
// An enclosing call that has visited all the members it may but two.
|
||||
let _outer = DrawnFrame::enter().unwrap();
|
||||
DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 2)));
|
||||
members_as_drawn(None, &mut clipped);
|
||||
let clipping = clipped.children().unwrap()[0].clone();
|
||||
let effects: Vec<usize> = clipping.children().unwrap().iter().map(|m| crate::reshape::geometry_effects(m).len()).collect();
|
||||
assert_eq!(effects, [0, 0, 1], "the first two members take their Offset Path, the third keeps it");
|
||||
let widths: Vec<f64> = clipping.children().unwrap().iter().map(|m| m.geometric_bounds().unwrap().width()).collect();
|
||||
assert!((widths[0] - 120.0).abs() < 0.5 && (widths[1] - 120.0).abs() < 0.5 && (widths[2] - 100.0).abs() < 0.5, "{widths:?}");
|
||||
}
|
||||
|
||||
/// A compound clipping path with an effect of its own takes that effect when the member limit is
|
||||
/// reached among its paths: with room for one more member, the first path (at 100 to 200) takes
|
||||
/// its Offset Path, the second (at 300 to 400) goes in as it is, and the compound path's Offset
|
||||
/// Path reshapes both, to 80 to 220 and 290 to 410.
|
||||
#[test]
|
||||
fn a_compound_clipping_path_takes_its_own_effect_at_the_member_limit() {
|
||||
let member = |id: u64, x: f64| {
|
||||
let path = Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 100.0, x + 100.0, 200.0)), black());
|
||||
Arc::new(with_effect(path, offset_10()))
|
||||
};
|
||||
let kind = NodeKind::Compound { children: vec![member(2, 100.0), member(3, 300.0)], rule: FillRule::NonZero };
|
||||
let clipping = with_effect(Node::new(NodeId(20), kind), offset_10());
|
||||
let mut clipped = Node::new(NodeId(5), NodeKind::Group { children: vec![Arc::new(clipping), Arc::new(square_at_100())], clip: true });
|
||||
// An enclosing call that has visited all the members it may but one.
|
||||
let _outer = DrawnFrame::enter().unwrap();
|
||||
DRAWN.with(|d| d.set((1, crate::SYMBOL_ART_MAX_NODES - 1)));
|
||||
members_as_drawn(None, &mut clipped);
|
||||
let clipping = clipped.children().unwrap()[0].clone();
|
||||
assert!(crate::reshape::geometry_effects(&clipping).is_empty(), "the compound path's Offset Path is in its geometry");
|
||||
let subpaths = clipping.path_data().unwrap().subpaths.clone();
|
||||
let bounds: Vec<(f64, f64)> = subpaths.into_iter().map(|sp| PathData::single(sp).bounds().unwrap()).map(|b| (b.x0, b.x1)).collect();
|
||||
let near = |(a, b): (f64, f64), (x0, x1): (f64, f64)| (a - x0).abs() < 0.5 && (b - x1).abs() < 0.5;
|
||||
assert!(bounds.len() == 2 && near(bounds[0], (80.0, 220.0)) && near(bounds[1], (290.0, 410.0)), "{bounds:?}");
|
||||
}
|
||||
|
||||
/// An image or a symbol instance that a group's Transform effect moves takes the placed gradients
|
||||
/// of its own fills along, as a path does, and each copy takes them as many times as it takes the
|
||||
/// transform.
|
||||
|
||||
@@ -84,8 +84,8 @@ pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, follow_items, item_effects_in, outset, raster_effects, without_geometry};
|
||||
pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, follows_image_pixels, gives_image_shape, has_gradient_copies, has_instance_row_raster, has_item_geometry,
|
||||
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text,
|
||||
reshape,
|
||||
has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outer_effects, outline_art,
|
||||
outline_text, reshape,
|
||||
};
|
||||
pub use revolve::{
|
||||
CLASSIC_REVOLVE, REVOLVE, has_revolve, is_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in,
|
||||
|
||||
@@ -107,11 +107,44 @@ fn reaches_appearance(e: &Effect) -> bool {
|
||||
}
|
||||
|
||||
/// Is `n` a path or compound path that its Transform effects make into art of separate objects
|
||||
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`]), or whose
|
||||
/// live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn, baked and
|
||||
/// expanded through that art ([`reshape`]).
|
||||
/// ([`has_gradient_copies`], [`has_layered_copies`], [`has_transform_options`], [`copied_raster_at`]),
|
||||
/// or whose live-effect plug-ins paint what they return ([`has_painted_effects`])? It is drawn,
|
||||
/// baked and expanded through that art ([`reshape`]).
|
||||
pub fn has_transform_art(n: &Node) -> bool {
|
||||
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n)
|
||||
has_gradient_copies(n) || has_layered_copies(n) || has_transform_options(n) || has_painted_effects(n) || copied_raster_at(n).is_some()
|
||||
}
|
||||
|
||||
/// Where the own effects of `n`, a path or compound path, split so that each copy of a Transform
|
||||
/// effect carries the raster effects listed before that effect ([`copied_raster_art`]): the index
|
||||
/// of the last visible Transform effect that makes copies, when a visible raster effect that
|
||||
/// follows a Transform effect ([`crate::raster::follows`]) is listed before it and every other
|
||||
/// visible effect before it is a geometry effect or such a raster effect. `None` otherwise, and for
|
||||
/// a path with a linked opacity mask, a 3D effect, Crop Marks, Vector Halftone, a live-effect
|
||||
/// plug-in that paints what it returns, or a fill or stroke that paints with geometry effects of
|
||||
/// its own.
|
||||
pub(crate) fn copied_raster_at(n: &Node) -> Option<usize> {
|
||||
if !matches!(n.kind, NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
let effects = &n.appearance.effects;
|
||||
// Checked first: this runs for every object drawn.
|
||||
if !effects.iter().any(|e| e.visible && crate::raster::follows(&e.id)) || !effects.iter().any(|e| e.visible && e.id == "distort.transform") {
|
||||
return None;
|
||||
}
|
||||
let t = effects.iter().rposition(|e| {
|
||||
e.visible && e.id == "distort.transform" && crate::distort::transform_map(Rect::ZERO, &merged_params(&e.id, &e.params), 0).1 > 0
|
||||
})?;
|
||||
let before = effects.get(..t)?;
|
||||
let carried = before.iter().any(|e| e.visible && crate::raster::follows(&e.id))
|
||||
&& before.iter().all(|e| !e.visible || is_geometry(&e.id) || crate::raster::follows(&e.id));
|
||||
let excluded = n.extras().mask.as_ref().is_some_and(|m| m.linked)
|
||||
|| crate::has_solid(n)
|
||||
|| crate::has_revolve(n)
|
||||
|| crate::has_crop_marks(n)
|
||||
|| crate::addons::halftone::has_halftone(n)
|
||||
|| has_painted_effects(n)
|
||||
|| n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects()));
|
||||
(carried && !excluded).then_some(t)
|
||||
}
|
||||
|
||||
/// Is `e` a visible live-effect plug-in whose manifest declares `paintOutput`: its id's plug-in id,
|
||||
@@ -797,6 +830,9 @@ fn as_art_opts(
|
||||
nodes: &SymbolNodes,
|
||||
apart: bool,
|
||||
) -> Option<Node> {
|
||||
if let Some(t) = copied_raster_at(n) {
|
||||
return Some(dressed_keeping_effects(copied_raster_art(paint_doc, n, t, apart), n));
|
||||
}
|
||||
let fx = geometry_effects(n);
|
||||
let mut art = match &n.kind {
|
||||
NodeKind::Image(im) => {
|
||||
@@ -882,6 +918,89 @@ pub(crate) fn dressed(mut art: Node, n: &Node) -> Node {
|
||||
art
|
||||
}
|
||||
|
||||
/// [`dressed`] that keeps the effects of `art`.
|
||||
fn dressed_keeping_effects(mut art: Node, n: &Node) -> Node {
|
||||
let effects = std::mem::take(&mut art.appearance.effects);
|
||||
let mut art = dressed(art, n);
|
||||
art.appearance.effects = effects;
|
||||
art
|
||||
}
|
||||
|
||||
/// The art of path or compound path `n` whose own effects split at `t` ([`copied_raster_at`]): the
|
||||
/// art the effects before the Transform effect at `t` make of `n`, with their raster effects, then
|
||||
/// copied by that Transform effect, each copy an object of its own carrying those raster effects
|
||||
/// moved, turned, reflected and scaled with it ([`follow_art`]), and then reshaped by the geometry
|
||||
/// effects after it. The art keeps `n`'s id and carries the raster effects from `t` on, without
|
||||
/// `n`'s name, visibility, transparency and opacity mask. The copies stop where [`copy_art`]'s do,
|
||||
/// each with its raster effects. With `apart`, the Transform effects before `t` make each copy an
|
||||
/// object of its own ([`copy_leaf`]).
|
||||
fn copied_raster_art(doc: Option<&Document>, n: &Node, t: usize, apart: bool) -> Node {
|
||||
let (before, rest) = n.appearance.effects.split_at(t);
|
||||
let mut source = n.clone();
|
||||
source.appearance.effects = before.to_vec();
|
||||
let piece = match copied_raster_at(&source) {
|
||||
Some(at) => copied_raster_art(doc, &source, at, apart),
|
||||
None => {
|
||||
let mut art = one_piece(&source);
|
||||
let carry = reshape_leaves_apart(&mut art, &geometry_effects(&source), &source.appearance.effects, Budget::EFFECTS, apart, doc);
|
||||
if apart {
|
||||
hoist_copies(&mut art);
|
||||
}
|
||||
transform_items(&mut art, doc);
|
||||
let mut ran = source.appearance.effects.clone();
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(&mut ran);
|
||||
}
|
||||
art.appearance.effects = crate::raster::without_geometry(&ran);
|
||||
art
|
||||
}
|
||||
};
|
||||
let mut art = Node::group(n.id, vec![Arc::new(piece)]);
|
||||
if let (Some(e), Some(bounds)) = (rest.first(), art.geometric_bounds()) {
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, n.id.0);
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
// The raster effects follow the copies below; Scale Strokes & Effects scales the others.
|
||||
let mut scaling = copy_scaling(options);
|
||||
scaling.effects = options.strokes.then_some(scale_unfollowed as fn(&mut Effect, f64));
|
||||
let max = Budget::EFFECTS.at_least(art_cost(&art));
|
||||
copy_art(&mut art, m, copies, scaling, max);
|
||||
if let Some(children) = art.children_mut() {
|
||||
// The original is last, and each copy above it is mapped once more by `m`.
|
||||
let step = m.with_translation(vectorcraft_geom::Vec2::ZERO);
|
||||
let mut a = Affine::IDENTITY;
|
||||
let none = HashSet::new();
|
||||
for (k, c) in children.iter_mut().rev().enumerate() {
|
||||
if k > 0 {
|
||||
a *= step;
|
||||
follow_art(Arc::make_mut(c), a, &none, Some(&none), None, 0);
|
||||
}
|
||||
// Each copy is the piece in a group of its own: the piece takes its place.
|
||||
if let Some([one]) = c.children().map(Vec::as_slice) {
|
||||
*c = one.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let later: Vec<&Effect> = rest.iter().skip(1).filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
let carry = reshape_leaves_apart(&mut art, &later, &n.appearance.effects, Budget::EFFECTS, apart, doc);
|
||||
let mut ran = n.appearance.effects.clone();
|
||||
if carry.skipped() {
|
||||
carry.hide_skipped(&mut ran);
|
||||
}
|
||||
art.appearance.effects = crate::raster::without_geometry(ran.get(t..).unwrap_or_default());
|
||||
art
|
||||
}
|
||||
|
||||
/// The effects of `n` that paint around its whole art: all of its own, or from the Transform effect
|
||||
/// on for a path whose copies each carry the raster effects listed before it ([`copied_raster_at`]).
|
||||
pub fn outer_effects(n: &Node) -> &[Effect] {
|
||||
match copied_raster_at(n) {
|
||||
Some(t) => n.appearance.effects.get(t..).unwrap_or_default(),
|
||||
None => &n.appearance.effects,
|
||||
}
|
||||
}
|
||||
|
||||
/// A group with `n`'s id holding `n` without its transparency, opacity mask and effects: the one
|
||||
/// piece that `n`'s own geometry effects reshape.
|
||||
fn one_piece(n: &Node) -> Node {
|
||||
@@ -909,6 +1028,9 @@ pub(crate) fn own_effects_art(doc: Option<&Document>, n: &Node) -> Option<Node>
|
||||
{
|
||||
return None;
|
||||
}
|
||||
if let Some(t) = copied_raster_at(n) {
|
||||
return Some(dressed_keeping_effects(copied_raster_art(doc, n, t, false), n));
|
||||
}
|
||||
if n.appearance.items.iter().any(|i| crate::paints(i) && crate::has_geometry(i.effects())) {
|
||||
// One element: its pieces never knock each other out.
|
||||
return crate::bake_type_appearance(doc, n).map(|art| Node { knockout: vectorcraft_doc::Knockout::Off, ..art });
|
||||
|
||||
@@ -663,10 +663,11 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul
|
||||
/// instances, blends and clipping groups already resolved), in document coordinates: with the
|
||||
/// effect on the object, the surface in its top visible fill (else its stroke); with it on fills and strokes, one surface for each of
|
||||
/// them, reshaped by the geometry effects after each one's Revolve; on a group or layer, one for
|
||||
/// each member, about one shared axis. The swatches and patterns named by a live-effect plug-in
|
||||
/// after a fill's or stroke's Revolve are looked up in `doc`. Each face carries the width of the
|
||||
/// stroke the canvas draws around it ([`surface`]).
|
||||
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, String> {
|
||||
/// each member, about one shared axis. With the effect on the object, group or layer, also the
|
||||
/// shadow drawn behind them. The swatches and patterns named by a live-effect plug-in after a fill's
|
||||
/// or stroke's Revolve are looked up in `doc`. Each face carries the width of the stroke the canvas
|
||||
/// draws around it ([`surface`]).
|
||||
pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<crate::solids::wireframe::FacesAndShadow, String> {
|
||||
let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result<Vec<WireFace>, String> {
|
||||
if fx.id == CLASSIC_REVOLVE {
|
||||
let p = merged_params(&fx.id, &fx.params);
|
||||
@@ -682,7 +683,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
|
||||
.iter()
|
||||
.position(|e| e.visible && is_revolve(&e.id))
|
||||
.ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok((vec![], vec![])) };
|
||||
let mut shapes = members(n, "Revolve")?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?;
|
||||
@@ -691,18 +692,19 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
|
||||
for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) {
|
||||
out.extend(faces(&s.path, b, fx, options)?);
|
||||
}
|
||||
return Ok(out);
|
||||
return Ok((out, group_shadow(n.id, fx, &shapes, b, options)?));
|
||||
}
|
||||
let (path, _) = object_profile(n, "Revolve")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) {
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok((vec![], vec![])) };
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
if surface_item(&solid_paints(n)).is_none() {
|
||||
return Err("the revolved object has no fill or stroke".into());
|
||||
}
|
||||
let paints = solid_paints(n);
|
||||
let item = surface_item(&paints).ok_or("the revolved object has no fill or stroke")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
|
||||
return faces(&path, b, fx, fx_options(fx));
|
||||
let surface = faces(&path, b, fx, fx_options(fx))?;
|
||||
let shadow = shadow_of(n.id, fx, &path, item, b, fx_options(fx))?;
|
||||
return Ok((surface, shadow.into_iter().map(Arc::new).collect()));
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
@@ -716,7 +718,7 @@ pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec
|
||||
out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, vec![]))
|
||||
}
|
||||
|
||||
/// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis.
|
||||
|
||||
@@ -1369,13 +1369,19 @@ fn art_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile]) ->
|
||||
let art = surfaces_for(doc, n, fx, shapes)?;
|
||||
// A shadow past the limits draws the source path, as a solid past them does; commands report
|
||||
// the error ([`validate_solid_in`]).
|
||||
let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? {
|
||||
Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)),
|
||||
None => vec![],
|
||||
};
|
||||
let shadow = shadow_for(n.id, fx, shapes).ok()?;
|
||||
Some((shadow, art))
|
||||
}
|
||||
|
||||
/// The shadow [`art_for`] draws for effect `fx` on `shapes` behind the surfaces of object `id`:
|
||||
/// none with the shadow off, and an error when it is past the limits.
|
||||
pub(crate) fn shadow_for(id: NodeId, fx: &Effect, shapes: &[Profile]) -> Result<Vec<Arc<Node>>, String> {
|
||||
Ok(match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes)? {
|
||||
Some((caster, settings, cast)) => shadow::scoped_art(id, &caster, settings, cast, has_masks(shapes)),
|
||||
None => vec![],
|
||||
})
|
||||
}
|
||||
|
||||
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
|
||||
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
|
||||
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
|
||||
|
||||
@@ -53,8 +53,8 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path
|
||||
// Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas
|
||||
// cuts it ([`crate::revolve_art_in`]).
|
||||
let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? };
|
||||
let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? };
|
||||
let faces: Vec<PathData> = after_geometry(doc, n, faces, revolve).into_iter().map(|f| f.path).collect();
|
||||
let (faces, shadow) = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? };
|
||||
let faces: Vec<PathData> = after_geometry(doc, n, faces, shadow, revolve).into_iter().map(|f| f.path).collect();
|
||||
let wire = edges(&faces)?;
|
||||
if wire.is_empty() {
|
||||
return Err(format!("{label} draws no faces for this object"));
|
||||
@@ -63,13 +63,14 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result<Vec<Path
|
||||
}
|
||||
|
||||
/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them
|
||||
/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude &
|
||||
/// Bevel, Inflate or Rotate.
|
||||
fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<WireFace>, revolve: bool) -> Vec<WireFace> {
|
||||
/// ([`reshaped_beside`]): its first visible Revolve when `revolve` is set, else its first visible
|
||||
/// Extrude & Bevel, Inflate or Rotate. The effects measure the faces together with `shadow`, the
|
||||
/// shadow the canvas draws behind them, as they measure the drawn art.
|
||||
fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec<WireFace>, shadow: Vec<Arc<Node>>, revolve: bool) -> Vec<WireFace> {
|
||||
let fx = &n.appearance.effects;
|
||||
let drawn = if revolve { fx.iter().position(|e| e.visible && crate::revolve::is_revolve(&e.id)) } else { solid(fx).map(|(i, _)| i) };
|
||||
let Some(index) = drawn else { return faces };
|
||||
reshaped(faces, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) })
|
||||
reshaped_beside(faces, shadow, fx.get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..GeomContext::of(n) })
|
||||
}
|
||||
|
||||
/// One face of a drawn 3D surface: its outline and the width of the stroke the canvas draws around
|
||||
@@ -86,6 +87,15 @@ pub(crate) struct WireFace {
|
||||
/// the canvas, so an Outline Stroke outlines that stroke. `ctx` gives the object's id, the seed of a
|
||||
/// random Transform, and the document.
|
||||
pub(crate) fn reshaped(faces: Vec<WireFace>, later: &[Effect], ctx: &GeomContext) -> Vec<WireFace> {
|
||||
reshaped_beside(faces, vec![], later, ctx)
|
||||
}
|
||||
|
||||
/// The name the faces carry through [`reshaped_beside`], which tells them from the shadow.
|
||||
const FACE: &str = "face";
|
||||
|
||||
/// [`reshaped`] with `shadow`, the shadow drawn behind the faces, in the art the effects reshape:
|
||||
/// each effect measures the faces and the shadow together, and only the faces are returned.
|
||||
fn reshaped_beside(faces: Vec<WireFace>, shadow: Vec<Arc<Node>>, later: &[Effect], ctx: &GeomContext) -> Vec<WireFace> {
|
||||
let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect();
|
||||
if after.is_empty() {
|
||||
return faces;
|
||||
@@ -95,29 +105,44 @@ pub(crate) fn reshaped(faces: Vec<WireFace>, later: &[Effect], ctx: &GeomContext
|
||||
let face = |f: WireFace| {
|
||||
let items =
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(crate::lighting::seam_stroke(paint.clone(), f.stroke))];
|
||||
Arc::new(Node::path(id, f.path, Appearance { items, ..Default::default() }))
|
||||
let mut face = Node::path(id, f.path, Appearance { items, ..Default::default() });
|
||||
face.name = Some(FACE.into());
|
||||
Arc::new(face)
|
||||
};
|
||||
let mut art = Node::group(id, faces.into_iter().map(face).collect());
|
||||
let mut art = Node::group(id, shadow.into_iter().chain(faces.into_iter().map(face)).collect());
|
||||
crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc);
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c| {
|
||||
if let Some(path) = c.path_data() {
|
||||
out.push(WireFace { path: path.clone(), stroke: c.appearance.stroke().map_or(0.0, |s| s.width) });
|
||||
}
|
||||
});
|
||||
face_paths(&art, false, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The faces in `n` ([`FACE`]), `inside` one or not, each with the width of its stroke: a face that
|
||||
/// a Transform effect copies or a live-effect plug-in cuts into pieces becomes a group with its name.
|
||||
fn face_paths(n: &Node, inside: bool, out: &mut Vec<WireFace>) {
|
||||
let inside = inside || n.name.as_deref() == Some(FACE);
|
||||
if inside && let Some(path) = n.path_data() {
|
||||
out.push(WireFace { path: path.clone(), stroke: n.appearance.stroke().map_or(0.0, |s| s.width) });
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
face_paths(c, inside, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces of a 3D effect on an object, group or layer itself, and the shadow the canvas draws
|
||||
/// behind them (none with the shadow off, or with the effect on fills and strokes).
|
||||
pub(crate) type FacesAndShadow = (Vec<WireFace>, Vec<Arc<Node>>);
|
||||
|
||||
/// The outline of every face [`super::solid_art`] draws for `n` (already resolved): with the effect
|
||||
/// on the object, its solids; with it on fills and strokes, the solid of each; on a group or layer,
|
||||
/// the solids of its members, about one shared axis. The swatches and patterns named by a live-effect
|
||||
/// plug-in after a fill's or stroke's 3D effect are looked up in `doc`.
|
||||
fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, String> {
|
||||
/// the solids of its members, about one shared axis. With the effect on the object, group or layer,
|
||||
/// also the shadow drawn behind them. The swatches and patterns named by a live-effect plug-in after
|
||||
/// a fill's or stroke's 3D effect are looked up in `doc`.
|
||||
fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<FacesAndShadow, String> {
|
||||
if is_container(n) {
|
||||
let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing")?;
|
||||
let mut shapes = members(n, label_of(&fx.id))?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
return facet_paths(fx, &shapes);
|
||||
return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?));
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
@@ -125,7 +150,8 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, S
|
||||
let (sources, paints) = solid_paint_sources(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
return facet_paths(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }]);
|
||||
let shapes = [Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }];
|
||||
return Ok((facet_paths(fx, &shapes)?, super::shadow_for(n.id, fx, &shapes)?));
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
@@ -136,7 +162,7 @@ fn solid_face_paths(doc: Option<&Document>, n: &Node) -> Result<Vec<WireFace>, S
|
||||
out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx }));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
Ok((out, vec![]))
|
||||
}
|
||||
|
||||
/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and
|
||||
|
||||
@@ -222,6 +222,32 @@ fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect after the 3D effect scales the faces about the center of the drawn art with
|
||||
/// its shadow, as it scales the surfaces on the canvas.
|
||||
#[test]
|
||||
fn a_later_transform_measures_the_faces_with_their_shadow() {
|
||||
for id in [super::super::EXTRUDE, crate::REVOLVE] {
|
||||
let scale = crate::new_effect("distort.transform", &json!({"scaleH": 50.0, "scaleV": 50.0})).unwrap();
|
||||
let mut n = solid_square(id, json!({"shadow": true, "shadowDistance": 100}));
|
||||
n.appearance.effects.push(scale.clone());
|
||||
let wire = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}")));
|
||||
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { super::super::solid_art(&n) }.unwrap();
|
||||
let mut drawn = art.clone();
|
||||
let fx: Vec<&Effect> = art.appearance.effects.iter().collect();
|
||||
crate::reshape::reshape_leaves(&mut drawn, &fx, &art.appearance.effects, None);
|
||||
// The shadow is the art's first member; the surfaces follow it.
|
||||
let surfaces: Vec<PathData> = drawn.children().unwrap().iter().skip(1).flat_map(|c| face_paths(c)).collect();
|
||||
let a = bounds(&surfaces);
|
||||
for (x, y) in [(wire.x0, a.x0), (wire.y0, a.y0), (wire.x1, a.x1), (wire.y1, a.y1)] {
|
||||
assert!((x - y).abs() < 1e-6, "{id}: {wire:?} {a:?}");
|
||||
}
|
||||
// Without the shadow, the faces are scaled about their own center.
|
||||
let mut alone = solid_square(id, json!({}));
|
||||
alone.appearance.effects.push(scale);
|
||||
assert!((bounds(&three_d_wireframe_in(None, &alone).unwrap()).x0 - wire.x0).abs() > 1.0, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With two 3D effects, a geometry effect between them reshapes the faces of the one drawn only
|
||||
/// once: Revolve is drawn, so a Transform before it moves its profile and not its faces again.
|
||||
#[test]
|
||||
|
||||
@@ -584,8 +584,9 @@ fn image(id: u64, effects: Vec<Effect>) -> Node {
|
||||
/// A group's Transform effect transforms, with Scale Strokes & Effects off or on, the raster
|
||||
/// effects that a member's own Transform effect with copies holds back: they keep their value
|
||||
/// through the member's effect and transform with the group's. That holds for a shadow listed
|
||||
/// before the member's copying effect, one listed after it, a fill's shadow under it, the shadow
|
||||
/// of a member of a nested group with such an effect, and an image's shadow.
|
||||
/// before the member's copying effect (which each copy carries), one listed after it, a fill's
|
||||
/// shadow under it, the shadow of a member of a nested group with such an effect, and an image's
|
||||
/// shadow.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() {
|
||||
let copies = || transform(json!({"moveH": 60, "copies": 1}));
|
||||
@@ -605,7 +606,8 @@ fn a_groups_transform_moves_the_shadows_its_members_copies_hold_back() {
|
||||
let art = crate::evaluate_container(None, &g).unwrap();
|
||||
for id in [71, 72, 73, 75, 76] {
|
||||
let drawn = shadows_in(&node_in(&art, id));
|
||||
assert!(near(&drawn, &[want]), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
|
||||
let wanted = if id == 71 { vec![want, want] } else { vec![want] };
|
||||
assert!(near(&drawn, &wanted), "Scale Strokes & Effects {strokes}: {id} {drawn:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -841,3 +843,93 @@ fn copying_and_object_disabled_transforms_keep_their_existing_shadow_rules() {
|
||||
assert!(close(drawn_shadow(&effects), (10.0, 5.0, 4.0)), "{t:?}: {:?}", drawn_shadow(&effects));
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur of every Drop Shadow drawn on `n` and the nodes under it, in the order of
|
||||
/// [`shadows_in`].
|
||||
fn shadow_blurs_in(n: &Node) -> Vec<f64> {
|
||||
let mut out = vec![];
|
||||
n.walk(&mut |m| {
|
||||
let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects()));
|
||||
for list in lists {
|
||||
for r in raster_effects(list) {
|
||||
if let RasterFx::DropShadow { blur, .. } = r {
|
||||
out.push(blur);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// How many paths `n` holds.
|
||||
fn paths_in(n: &Node) -> usize {
|
||||
let mut count = 0;
|
||||
n.walk(&mut |m| count += usize::from(matches!(m.kind, vectorcraft_doc::NodeKind::Path { .. })));
|
||||
count
|
||||
}
|
||||
|
||||
/// A Transform effect that makes copies gives each copy the shadow listed before it, moved, turned,
|
||||
/// reflected and scaled with that copy, the last copy lowest: drawn, expanded and baked. The object
|
||||
/// itself keeps the shadow as it is, and paints none around all of them. With Scale Strokes &
|
||||
/// Effects on, the shadow is not scaled a second time.
|
||||
#[test]
|
||||
fn each_copy_of_a_transform_effect_carries_the_shadow_listed_before_it() {
|
||||
let cases = [
|
||||
(json!({"moveH": 100, "copies": 2}), vec![(10.0, 0.0), (10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]),
|
||||
// A turn of 90° counterclockwise on screen takes an offset to the right to one upward.
|
||||
(json!({"rotate": 90, "copies": 2}), vec![(-10.0, 0.0), (0.0, -10.0), (10.0, 0.0)], vec![4.0, 4.0, 4.0]),
|
||||
(json!({"reflectX": true, "moveH": 100, "copies": 1}), vec![(-10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), vec![(20.0, 0.0), (10.0, 0.0)], vec![8.0, 4.0]),
|
||||
(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "random": true}), vec![(10.0, 0.0), (10.0, 0.0)], vec![4.0, 4.0]),
|
||||
];
|
||||
for (params, want, blurs) in cases {
|
||||
let n = square(91, vec![shadow(10.0, 0.0, 4.0), transform(params.clone())]);
|
||||
assert!(crate::has_transform_art(&n), "{params}");
|
||||
assert!(crate::outer_effects(&n).iter().all(|e| e.id != "stylize.dropShadow"), "{params}");
|
||||
let random = params["random"].as_bool().unwrap_or(false);
|
||||
for (how, art) in
|
||||
[("drawn", crate::reshape(None, &n, None).unwrap()), ("expanded", expand_outlined(None, &n, None).unwrap()), ("baked", baked(n.clone()))]
|
||||
{
|
||||
let drawn = shadows_in(&art);
|
||||
assert_eq!(paths_in(&art), want.len(), "{params}: {how} {art:?}");
|
||||
if random {
|
||||
// Random scales the copy by a share of the 200 %, and its shadow with it; the object
|
||||
// keeps its own.
|
||||
assert_eq!(drawn.len(), want.len(), "{params}: {how} {drawn:?}");
|
||||
assert!(near(&drawn[1..], &want[1..]), "{params}: {how} {drawn:?}");
|
||||
continue;
|
||||
}
|
||||
assert!(near(&drawn, &want), "{params}: {how} {drawn:?}");
|
||||
let b = shadow_blurs_in(&art);
|
||||
assert!(b.len() == blurs.len() && b.iter().zip(&blurs).all(|(x, y)| (x - y).abs() < 1e-9), "{params}: {how} blurs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shadow listed after a Transform effect that makes copies paints once around all of them, and
|
||||
/// one listed before it on each copy. A Transform effect that makes no copies, or has Transform
|
||||
/// Objects off, leaves the object's shadows on the object.
|
||||
#[test]
|
||||
fn a_shadow_after_a_copying_transform_paints_once_around_the_copies() {
|
||||
let n = square(92, vec![shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90, "copies": 1})), shadow(0.0, 5.0, 0.0)]);
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
assert!(near(&shadows_in(&art), &[(0.0, 5.0), (0.0, -10.0), (10.0, 0.0)]), "{:?}", shadows_in(&art));
|
||||
assert_eq!(crate::outer_effects(&n).len(), 2);
|
||||
for params in [json!({"rotate": 90}), json!({"rotate": 90, "copies": 1, "objects": false})] {
|
||||
let n = square(93, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]);
|
||||
assert!(!crate::has_transform_art(&n), "{params}");
|
||||
assert_eq!(crate::outer_effects(&n).len(), 2, "{params}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies that stop at the effect budget each keep their shadow: no copy goes without one, and no
|
||||
/// shadow is left without its copy.
|
||||
#[test]
|
||||
fn copies_past_the_budget_each_keep_their_shadow() {
|
||||
let n = path_node(94, wavy(30_000), vec![shadow(1.0, 0.5, 0.0), transform(json!({"moveV": 50, "copies": 1000}))]);
|
||||
let art = crate::reshape(None, &n, None).unwrap();
|
||||
let paths = paths_in(&art);
|
||||
assert!(paths > 1 && paths < 1001, "{paths}");
|
||||
assert_eq!(shadows_in(&art).len(), paths);
|
||||
}
|
||||
|
||||
@@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let named: std::collections::HashSet<NodeId> = ids.iter().copied().collect();
|
||||
let threads = thread_index(d);
|
||||
let mut done = std::collections::HashSet::new();
|
||||
// The threads already found not to be a story whose frames are all among them: their
|
||||
// other frames are read one at a time without going through the thread again.
|
||||
let mut partial = std::collections::HashSet::new();
|
||||
let mut out: Vec<(NodeId, Vec<(usize, bool)>)> = vec![];
|
||||
for &id in &ids {
|
||||
if !done.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let story = match threads.get(&id).and_then(|&i| d.text_threads.get(i)) {
|
||||
Some(thread) => crate::cmd::threads::story_in(d, thread, id),
|
||||
let thread = threads.get(&id).copied();
|
||||
let story = match thread.filter(|i| !partial.contains(i)).and_then(|i| d.text_threads.get(i)) {
|
||||
Some(frames) => crate::cmd::threads::story_in(d, frames, id),
|
||||
None => vec![],
|
||||
};
|
||||
if !story.is_empty() && story.iter().all(|f| named.contains(f)) {
|
||||
@@ -885,6 +889,8 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Every frame is among them, so each one keeps its breaks.
|
||||
out.extend(story_cuts(d, &story, brk, true));
|
||||
} else if let Some(t) = text_of(d, id) {
|
||||
// `id` is type, so the story of its thread is the same for each of its frames.
|
||||
partial.extend(thread);
|
||||
out.push((id, break_cuts(&t.plain_text(), 0, usize::MAX, brk)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,6 +379,38 @@ fn remove_breaks_reads_the_threads_once() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove Breaks reads each named frame of a story that is not all named on its own, in the order
|
||||
/// named and each once, as one undo step, and goes through that story's frames once for all of them.
|
||||
#[test]
|
||||
fn remove_breaks_goes_through_a_partly_named_story_once() {
|
||||
for (kind, brk) in [("line", '\u{2028}'), ("paragraph", '\n')] {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, json!({"x": 10, "y": 20, "text": "a\u{2028}b\n".repeat(40), "size": 12, "area": {"width": 150, "height": 60}}));
|
||||
let mut roots = vec![a];
|
||||
for i in 0..4 {
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 10, "y": 100 + 70 * i, "width": 150, "height": 60})).unwrap();
|
||||
roots.push(NodeId(r["id"].as_u64().unwrap()));
|
||||
}
|
||||
select(&mut s, &roots);
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let f = s.doc().unwrap().doc.text_threads[0].clone();
|
||||
assert_eq!(f.len(), 5);
|
||||
let named = [f[4], f[1], f[3]];
|
||||
let breaks: Vec<usize> = named.iter().map(|id| obj(&s, *id).plain_text().matches(brk).count()).collect();
|
||||
assert!(breaks.iter().all(|n| *n > 0), "{kind}: each named frame holds breaks: {breaks:?}");
|
||||
let before = doc_json(&s);
|
||||
let steps = undo_steps(&s);
|
||||
let builds = crate::cmd::threads::story_builds();
|
||||
let r = s.execute("addon.cleanup.removeBreaks", &json!({"kind": kind, "ids": [f[4].0, f[1].0, f[4].0, f[3].0]})).unwrap();
|
||||
assert_eq!(crate::cmd::threads::story_builds() - builds, 1, "{kind}: the story's frames are gone through once");
|
||||
assert_eq!(r["removed"].as_u64(), Some(breaks.iter().sum::<usize>() as u64), "{kind}");
|
||||
assert_eq!(ids(&r), named, "{kind}");
|
||||
assert_eq!(undo_steps(&s), steps + 1, "{kind}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc_json(&s), before, "{kind}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lock `id` (`locked`), else hide it, as an undo step of its own.
|
||||
fn lock_or_hide(s: &mut Session, id: NodeId, locked: bool) {
|
||||
s.edit("Lock or Hide", |d, _| {
|
||||
|
||||
@@ -6,8 +6,8 @@ use vectorcraft_doc::Scaling;
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::object::choice;
|
||||
use super::panelcmds::{artboard_index, artboards_art, locked_and_hidden, named_or_selected, note_artboards};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, switch_or};
|
||||
use super::panelcmds::{artboard_at, artboards_art, locked_and_hidden, named_or_selected, note_artboards, on_board};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, check_switches, cmd, has_doc, index, switch_or};
|
||||
use crate::EngineError;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
|
||||
check_switches(p, &["patterns"], cmd)?;
|
||||
let to_artboard = choice(p, cmd, "to", &["selection", "artboard"])? == Some("artboard");
|
||||
let indices = named_or_selected(s, p, cmd)?;
|
||||
let given = |key: &str| -> Result<Option<usize>> {
|
||||
match p.get(key).filter(|v| !v.is_null()) {
|
||||
Some(v) => artboard_index(&s.doc()?.doc, &json!({ "index": v }), cmd).map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
};
|
||||
let (named, recorded) = (given("artboard")?, given("activeArtboard")?);
|
||||
let (named, recorded) = (artboard_at(&s.doc()?.doc, p, "artboard", cmd)?, artboard_at(&s.doc()?.doc, p, "activeArtboard", cmd)?);
|
||||
let move_art = switch_or(p, "moveArt", s.tool_options_of("artboard")["moveArt"].as_bool().unwrap_or(true), cmd)?;
|
||||
let locked_art = locked_and_hidden(s, p, move_art, cmd)?;
|
||||
note_artboards(s, p, &indices);
|
||||
@@ -78,8 +72,8 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result<Boards> {
|
||||
(false, _) => None,
|
||||
(true, Some(i)) => Some(i),
|
||||
(true, None) => {
|
||||
let active = s.tool_options_of("artboard")["active"].clone();
|
||||
Some(artboard_index(&s.doc()?.doc, &json!({ "index": active }), cmd)?)
|
||||
let active = s.tool_options_of("artboard")["active"].as_u64().map(index);
|
||||
Some(active.map_or_else(|| Err(EngineError::Other("no such artboard".into())), |i| on_board(&s.doc()?.doc, i))?)
|
||||
}
|
||||
};
|
||||
if let Some(i) = reference.filter(|_| named.is_none()) {
|
||||
|
||||
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Live Paint Info",
|
||||
[],
|
||||
None,
|
||||
"{group, id?: object id (default: the selection)} → {faces: [{id, fill, bounds}], edges: [{id, stroke, width}], sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
"{group?|id?: Live Paint group id (one required; id is read when group is left out)} → {faces: [{id, fill (top fill), bounds, appearance: [row…]}], edges: [{id, stroke (top stroke), width (top stroke's), appearance: [row…]}], where a row is {index (in the part's fill and stroke stack, bottom first), kind: \"fill\"|\"stroke\", paint, opacity: 0..1, visible, width?: pt (strokes only), drawn (the part is shown with opacity above 0, and the row is shown, has paint and opacity above 0, a stroke also width above 0)}, sources, gapOptions: {detect, size: \"small\"|\"medium\"|\"large\"|pt, points, color: #rrggbb} (as livePaint.gapOptions sets them), gaps: [[x0,y0,x1,y1]…] (as livePaint.gapOptions returns them)}",
|
||||
has_doc,
|
||||
lp_info
|
||||
),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{
|
||||
Appearance, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId, PathData, Point, Rect,
|
||||
Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json, is_live_paint, json,
|
||||
number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes, switch_or,
|
||||
Appearance, AppearanceItem, Arc, Beside, Document, EDGE_NAME, EngineError, FACE_NAME, GapOptions, GapSize, LIVE_PAINT_NAME, Node, NodeId,
|
||||
PathData, Point, Rect, Result, SOURCES_NAME, Selection, Session, SubPath, Value, b, bad, build_children, edge_near, face_at, finite, ids_json,
|
||||
is_live_paint, json, number_or, object_param, paint_from, po, point_param, repaint_from, replace_roots, root_ids, source_nodes, source_shapes,
|
||||
switch_or,
|
||||
};
|
||||
|
||||
/// `options` as stored on a Live Paint group: none for the defaults.
|
||||
@@ -524,6 +525,37 @@ pub(super) fn lp_stroke_edge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "group": g.0, "edge": edge.0 }))
|
||||
}
|
||||
|
||||
/// Every fill and stroke row of face or edge `n`, bottom first, as `livePaint.info` lists them. A
|
||||
/// row is `drawn` when the part is shown with an opacity above 0 and the row is shown, has paint
|
||||
/// and has an opacity above 0 (a stroke also a weight above 0): the rule of [`draws`], row by row.
|
||||
fn appearance_rows(n: &Node) -> Vec<Value> {
|
||||
let part_shown = n.visible && n.opacity > 0.0;
|
||||
n.appearance
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, item)| {
|
||||
let shown = part_shown && item.visible() && !item.paint().is_none() && item.opacity() > 0.0;
|
||||
let mut row = json!({
|
||||
"index": i,
|
||||
"kind": item.kind_name(),
|
||||
"paint": item.paint().label(),
|
||||
"opacity": item.opacity(),
|
||||
"visible": item.visible(),
|
||||
});
|
||||
let drawn = match item {
|
||||
AppearanceItem::Fill(_) => shown,
|
||||
AppearanceItem::Stroke(st) => {
|
||||
row["width"] = json!(st.width);
|
||||
shown && st.width > 0.0
|
||||
}
|
||||
};
|
||||
row["drawn"] = json!(drawn);
|
||||
row
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let g = match object_param(p, "group", "livePaint.info")? {
|
||||
Some(g) => g,
|
||||
@@ -535,12 +567,24 @@ pub(super) fn lp_info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let bb = f.geometric_bounds().unwrap_or(Rect::ZERO);
|
||||
json!({ "id": f.id.0, "fill": f.appearance.fill_paint().label(), "bounds": [bb.x0, bb.y0, bb.x1, bb.y1] })
|
||||
json!({
|
||||
"id": f.id.0,
|
||||
"fill": f.appearance.fill_paint().label(),
|
||||
"bounds": [bb.x0, bb.y0, bb.x1, bb.y1],
|
||||
"appearance": appearance_rows(f),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let edges: Vec<Value> = b::edges(n)
|
||||
.iter()
|
||||
.map(|e| json!({ "id": e.id.0, "stroke": e.appearance.stroke_paint().label(), "width": e.appearance.stroke_width() }))
|
||||
.map(|e| {
|
||||
json!({
|
||||
"id": e.id.0,
|
||||
"stroke": e.appearance.stroke_paint().label(),
|
||||
"width": e.appearance.stroke_width(),
|
||||
"appearance": appearance_rows(e),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let sources = b::sources(n).and_then(|g| g.children()).map(|c| c.len()).unwrap_or(0);
|
||||
let options = n.gap_options();
|
||||
|
||||
@@ -686,8 +686,15 @@ fn map_graph_colors(g: &mut vectorcraft_doc::GraphSpec, f: &mut dyn FnMut(&Color
|
||||
}
|
||||
}
|
||||
let styles = &mut g.label_styles;
|
||||
for st in [&mut styles.values, &mut styles.categories, &mut styles.legend].into_iter().flatten() {
|
||||
map_paint_colors(&mut st.fill, f);
|
||||
// A legend fill of the labels' own stays theirs when `f` makes it black. The default black legend fill stays black,
|
||||
// so the labels on dark pie wedges are still drawn white.
|
||||
styles.mark_own_legend_fill();
|
||||
let own = styles.legend_fill_is_own();
|
||||
for (st, fill) in [(&mut styles.values, true), (&mut styles.categories, true), (&mut styles.legend, own)] {
|
||||
let Some(st) = st else { continue };
|
||||
if fill {
|
||||
map_paint_colors(&mut st.fill, f);
|
||||
}
|
||||
map_paint_colors(&mut st.stroke, f);
|
||||
}
|
||||
for r in [&mut styles.values_rows, &mut styles.categories_rows, &mut styles.legend_rows].into_iter().flatten() {
|
||||
|
||||
@@ -671,10 +671,11 @@ fn a_document_replaced_in_the_back_is_measured_before_it_comes_to_the_front() {
|
||||
}
|
||||
|
||||
/// Type a document replaced in the back sets in a composite font that only the front document
|
||||
/// defines is measured in that font while the front one is registered, and again, as missing, when
|
||||
/// the replaced document comes to the front.
|
||||
/// defines is measured as missing, though the front one is registered, also when the replaced
|
||||
/// document defines no composite font, and still as missing when it comes to the front.
|
||||
#[test]
|
||||
fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() {
|
||||
fn a_document_replaced_in_the_back_measures_a_font_it_lacks_as_missing() {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let mut s = session();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Ab12", "size": 20, "font": "CF Lacked"})).unwrap()["id"].as_u64().unwrap();
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
@@ -682,8 +683,16 @@ fn a_document_replaced_in_the_back_measures_a_font_it_lacks_again_in_front() {
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Lacked")]})).unwrap();
|
||||
assert!(s.replace_document(0, vectorcraft_format::load(&bytes).unwrap()));
|
||||
let back = text_in(&s, 0, id);
|
||||
let expected = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &back).bounds;
|
||||
assert_eq!(back.cached_bounds, Some(expected));
|
||||
assert!(s.documents()[0].doc.composite_fonts.is_empty());
|
||||
let in_front = vectorcraft_text::layout(db, &back).bounds;
|
||||
// The same type set in a family nothing defines.
|
||||
let mut nowhere = back.clone();
|
||||
for r in &mut nowhere.runs {
|
||||
r.style.font_family = "No Such Family CF Lacked".into();
|
||||
}
|
||||
let missing = vectorcraft_text::layout(db, &nowhere).bounds;
|
||||
assert!((in_front.width() - missing.width()).abs() > 1e-6, "the front document's font draws otherwise");
|
||||
assert_eq!(back.cached_bounds, Some(missing));
|
||||
assert!(s.set_active(0));
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("CF Lacked").is_none());
|
||||
let actual = text(&s, id);
|
||||
|
||||
@@ -110,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Smooth",
|
||||
[],
|
||||
None,
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag → {ids, before, after}",
|
||||
"{points: [[x,y]…], radius?: pt (12), fidelity?: pt (2.5; kept within 0.000078125 and about 3195, the Smooth tool's 0.05 to 100 px at the highest and the lowest zoom: the tool sends its Fidelity in screen pixels divided by the zoom)} smooth selected paths near the drag, keeping a corner where the path turns by more than 57.5° at the anchor and over radius and twice radius on either side → {ids, before, after}",
|
||||
has_selection,
|
||||
smooth_region
|
||||
),
|
||||
|
||||
@@ -13,9 +13,237 @@ use super::{
|
||||
/// of path within the reach on either side of it. VectorCraft's own value.
|
||||
const SMOOTH_SPREAD: f64 = 1.0 / 3.0;
|
||||
|
||||
/// The degrees by which a path must turn at an anchor, and over the tool's reach and twice its reach
|
||||
/// on either side of it, for the Smooth tool to keep the anchor as a corner ([`smooth_corners`]): a
|
||||
/// turn of more than 57.5°, an angle inside the corner of less than 122.5°. VectorCraft's own value,
|
||||
/// the same on every drag and at any Fidelity.
|
||||
const SMOOTH_CORNER_DEG: f64 = 57.5;
|
||||
|
||||
/// Most pieces a curved segment is cut into evenly, and most more pieces near each of its ends, to
|
||||
/// measure lengths along it in [`smooth_corners`].
|
||||
const CORNER_PIECES: usize = 64;
|
||||
|
||||
/// The control polygon's length of `c`, which is at least its length.
|
||||
fn polygon_len(c: CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// The places (0 to 1, in order, without 0 and with 1) on the curved segment `c` that
|
||||
/// [`smooth_corners`] measures it at: up to [`CORNER_PIECES`] evenly spaced, and near each end, over
|
||||
/// a stretch at least three times `reach` long where the segment is that long, up to as many more
|
||||
/// that are no more than `reach` / 8 apart along the control polygon, and as many again in each
|
||||
/// stretch twice as long after it until the pieces reach three times `reach`. The turns over `reach` and
|
||||
/// twice `reach` from an anchor are then measured along the curve however long the segment is.
|
||||
fn corner_places(c: CubicBez, reach: f64) -> Vec<f64> {
|
||||
let piece = reach / 8.0;
|
||||
let pieces = |len: f64| if piece > 0.0 { (len / piece).ceil().clamp(1.0, CORNER_PIECES as f64) as usize } else { 1 };
|
||||
let k = pieces(polygon_len(c)).max(4);
|
||||
let mut places: Vec<f64> = (1..=k).map(|j| j as f64 / k as f64).collect();
|
||||
if piece > 0.0 {
|
||||
for from_start in [true, false] {
|
||||
// The stretch from the end: halved until its control polygon is at most six reaches long.
|
||||
let first = 1.0 / k as f64;
|
||||
let mut t = first;
|
||||
let part = |t: f64| if from_start { c.subsegment(0.0..t) } else { c.subsegment(1.0 - t..1.0) };
|
||||
for _ in 0..60 {
|
||||
if polygon_len(part(t)) <= 6.0 * reach {
|
||||
break;
|
||||
}
|
||||
t /= 2.0;
|
||||
}
|
||||
let side = |u: f64| if from_start { u } else { 1.0 - u };
|
||||
let m = pieces(polygon_len(part(t)));
|
||||
let mut fine: Vec<f64> = (1..=m).map(|j| t * j as f64 / m as f64).collect();
|
||||
// Then the stretches twice as long in turn, cut as finely, up to the first even place, until
|
||||
// the pieces reach three reaches from the end, so twice the reach is measured along the curve
|
||||
// where a handle that does not reach out makes the curve start more slowly than its polygon.
|
||||
let mut covered = 0.0;
|
||||
let mut at = c.eval(side(0.0));
|
||||
for u in &fine {
|
||||
let q = c.eval(side(*u));
|
||||
covered += at.distance(q);
|
||||
at = q;
|
||||
}
|
||||
let (mut from, mut levels) = (t, 0);
|
||||
while covered < 3.0 * reach && from < first && levels < 60 {
|
||||
let to = (2.0 * from).min(first);
|
||||
let piece_len = polygon_len(if from_start { c.subsegment(from..to) } else { c.subsegment(1.0 - to..1.0 - from) });
|
||||
let m = pieces(piece_len);
|
||||
for j in 1..=m {
|
||||
let u = from + (to - from) * j as f64 / m as f64;
|
||||
let q = c.eval(side(u));
|
||||
covered += at.distance(q);
|
||||
at = q;
|
||||
fine.push(u);
|
||||
}
|
||||
(from, levels) = (to, levels + 1);
|
||||
}
|
||||
places.extend(fine.into_iter().map(side));
|
||||
}
|
||||
}
|
||||
places.retain(|u| *u > 0.0 && *u <= 1.0);
|
||||
places.sort_by(f64::total_cmp);
|
||||
places.dedup();
|
||||
places
|
||||
}
|
||||
|
||||
/// The degrees between the directions `u` and `v`, `None` when either has no length or is not a
|
||||
/// number.
|
||||
fn turn_between(u: kurbo::Vec2, v: kurbo::Vec2) -> Option<f64> {
|
||||
let (lu, lv) = (u.hypot(), v.hypot());
|
||||
if !(lu.is_finite() && lv.is_finite() && lu > 1e-12 && lv > 1e-12) {
|
||||
return None;
|
||||
}
|
||||
let deg = u.cross(v).atan2(u.dot(v)).abs().to_degrees();
|
||||
deg.is_finite().then_some(deg)
|
||||
}
|
||||
|
||||
/// Which anchors of `anchors` (a closed path when `closed`) the Smooth tool keeps as corners: those
|
||||
/// where the path turns by more than [`SMOOTH_CORNER_DEG`] at the anchor itself, between the
|
||||
/// direction it arrives along and the one it leaves along, and also over `reach` and over twice
|
||||
/// `reach` on either side of it, between the direction from the point that far back along the path
|
||||
/// to the anchor and the direction from the anchor to the point that far ahead. The points are taken
|
||||
/// no farther than the ends of an open path, and no more than an eighth of the way round a closed
|
||||
/// one. A jag or a bump smaller than the reach turns sharply at its anchor but not over twice the
|
||||
/// reach, and an anchor in the middle of a tight curve turns over the reach but not at the anchor, so
|
||||
/// neither is kept. An open path's first and last anchors are never corners.
|
||||
fn smooth_corners(anchors: &[Anchor], closed: bool, reach: f64) -> Vec<bool> {
|
||||
let n = anchors.len();
|
||||
let mut corners = vec![false; n];
|
||||
if n < 2 || (n < 3 && !closed) {
|
||||
return corners;
|
||||
}
|
||||
// The distances on either side the turn is measured over: one reach and two, no more than an
|
||||
// eighth of the way round a closed path. Over that much of a circle the path turns by 45°, so a
|
||||
// loop shorter than the reach is not all corners, while a square's or a triangle's corners still are.
|
||||
let mut spans = [reach, 2.0 * reach];
|
||||
if closed {
|
||||
let mut total = 0.0;
|
||||
for i in 0..n {
|
||||
total += walk(anchors, closed, (i, true), [f64::INFINITY; 2], reach, 1).1;
|
||||
}
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
return corners;
|
||||
}
|
||||
spans = spans.map(|r| r.min(total / 8.0));
|
||||
}
|
||||
for (i, flag) in corners.iter_mut().enumerate() {
|
||||
if !closed && (i == 0 || i == n - 1) {
|
||||
continue;
|
||||
}
|
||||
let (prev, a, next) = (anchors[(i + n - 1) % n], anchors[i], anchors[(i + 1) % n]);
|
||||
let arrive = [a.p - a.h_in, a.p - prev.h_out, a.p - prev.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
|
||||
let leave = [a.h_out - a.p, next.h_in - a.p, next.p - a.p].into_iter().find(|d| d.hypot() > 1e-12).unwrap_or_default();
|
||||
if !turn_between(arrive, leave).is_some_and(|t| t > SMOOTH_CORNER_DEG) {
|
||||
continue;
|
||||
}
|
||||
let [Some(back_near), Some(back_far)] = walk(anchors, closed, (i, false), spans, reach, MAX_CORNER_WALK).0 else { continue };
|
||||
let [Some(ahead_near), Some(ahead_far)] = walk(anchors, closed, (i, true), spans, reach, MAX_CORNER_WALK).0 else { continue };
|
||||
*flag = [(back_near, ahead_near, spans[0]), (back_far, ahead_far, spans[1])]
|
||||
.into_iter()
|
||||
.all(|(back, ahead, r)| r <= 0.0 || turn_between(a.p - back, ahead - a.p).is_some_and(|t| t > SMOOTH_CORNER_DEG));
|
||||
}
|
||||
corners
|
||||
}
|
||||
|
||||
/// Most segments [`walk`] passes from an anchor before it stops, which bounds the work for each
|
||||
/// anchor on a path of very many very short segments.
|
||||
const MAX_CORNER_WALK: usize = 256;
|
||||
|
||||
/// A walk along the path `anchors` from anchor `from.0`, ahead when `from.1` and back otherwise,
|
||||
/// past no more than `segments` segments, measured along the lines between each curved segment's
|
||||
/// points at [`corner_places`]. It gives the points at the distances `to` (in order, the nearer
|
||||
/// first), an open path's end for one beyond the end, and `None` for one it did not reach within
|
||||
/// `segments` segments, and the length it walked. Both points are `None` when it meets a length that
|
||||
/// is not a number or is too large to add, and the length is then infinite.
|
||||
fn walk(anchors: &[Anchor], closed: bool, from: (usize, bool), to: [f64; 2], reach: f64, segments: usize) -> ([Option<Point>; 2], f64) {
|
||||
let (n, forward) = (anchors.len(), from.1);
|
||||
let Some(mut here) = anchors.get(from.0).map(|a| a.p) else { return ([None; 2], 0.0) };
|
||||
let (mut at, mut gone) = (from.0, 0.0);
|
||||
let mut found: [Option<Point>; 2] = [None; 2];
|
||||
let mut at_end = false;
|
||||
'segments: for _ in 0..segments {
|
||||
let next = match (forward, closed) {
|
||||
(true, _) if at + 1 < n => at + 1,
|
||||
(true, true) => 0,
|
||||
(false, _) if at > 0 => at - 1,
|
||||
(false, true) => n - 1,
|
||||
_ => {
|
||||
at_end = true;
|
||||
break;
|
||||
}
|
||||
};
|
||||
let (a, b) = (anchors[at], anchors[next]);
|
||||
let c = if forward { CubicBez::new(a.p, a.h_out, b.h_in, b.p) } else { CubicBez::new(a.p, a.h_in, b.h_out, b.p) };
|
||||
let places = if c.p1 == c.p0 && c.p2 == c.p3 { vec![1.0] } else { corner_places(c, reach) };
|
||||
for u in places {
|
||||
let q = if u >= 1.0 { c.p3 } else { c.eval(u) };
|
||||
let d = here.distance(q);
|
||||
if !(d.is_finite() && (gone + d).is_finite()) {
|
||||
return ([None; 2], f64::INFINITY);
|
||||
}
|
||||
for (slot, &dist) in found.iter_mut().zip(&to) {
|
||||
if slot.is_none() && gone + d >= dist {
|
||||
let t = if d > 0.0 { ((dist - gone) / d).clamp(0.0, 1.0) } else { 1.0 };
|
||||
*slot = Some(here.lerp(q, t));
|
||||
}
|
||||
}
|
||||
gone += d;
|
||||
here = q;
|
||||
if found.iter().all(Option::is_some) {
|
||||
break 'segments;
|
||||
}
|
||||
}
|
||||
at = next;
|
||||
}
|
||||
// An open path that ends after the last segment walked ends there too.
|
||||
if !closed && at == if forward { n - 1 } else { 0 } {
|
||||
at_end = true;
|
||||
}
|
||||
(found.map(|p| p.or(at_end.then_some(here))), gone)
|
||||
}
|
||||
|
||||
/// Whether `a` and `b` are the same anchors, to within a rounding error.
|
||||
fn same_anchors(a: &[Anchor], b: &[Anchor]) -> bool {
|
||||
let near = |p: Point, q: Point| p.distance(q) <= 1e-9;
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| near(x.p, y.p) && near(x.h_in, y.h_in) && near(x.h_out, y.h_out))
|
||||
}
|
||||
|
||||
/// [`refit_run`] of the open run `anchors` cut at its anchors marked in `corners`: each part between
|
||||
/// two corners is smoothed and refitted on its own, so each corner keeps its place and the direction
|
||||
/// the path arrives and leaves along. `free` applies to the run's first and last anchors only.
|
||||
fn refit_split(anchors: &[Anchor], corners: &[bool], tol: f64, sigma: f64, free: [bool; 2]) -> Vec<Anchor> {
|
||||
let last = anchors.len().saturating_sub(1);
|
||||
let mut cuts: Vec<usize> = (1..last).filter(|&i| corners.get(i).copied().unwrap_or(false)).collect();
|
||||
if cuts.is_empty() {
|
||||
return refit_run(anchors, tol, sigma, free);
|
||||
}
|
||||
cuts.insert(0, 0);
|
||||
cuts.push(last);
|
||||
let mut out: Vec<Anchor> = Vec::with_capacity(anchors.len());
|
||||
for w in cuts.windows(2) {
|
||||
let (a, b) = (w[0], w[1]);
|
||||
let part = refit_run(&anchors[a..=b], tol, sigma, [free[0] && a == 0, free[1] && b == last]);
|
||||
match out.pop() {
|
||||
// The corner ends one part and starts the next: it keeps its place, and takes the handle
|
||||
// in from the part before and the handle out from the part after.
|
||||
Some(end) => {
|
||||
let mut rest = part.into_iter();
|
||||
if let Some(start) = rest.next() {
|
||||
out.push(Anchor::with_handles(anchors[a].p, end.h_in, start.h_out));
|
||||
}
|
||||
out.extend(rest);
|
||||
}
|
||||
None => out.extend(part),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The options the Smooth tool refits with: the path smoothed along its length by a Gaussian of
|
||||
/// spread `sigma` (none at 0), then refitted within `tol` of that and within twice the distance the
|
||||
/// smoothing moved it, keeping a corner only where the path turns back on itself. `free` says whether
|
||||
/// smoothing moved it. The tool cuts the path at its corners first ([`smooth_corners`]), so within a
|
||||
/// part the refit keeps a corner only where the path turns back on itself. `free` says whether
|
||||
/// the start and the end of the stretch refitted are ends of an open path, whose direction then
|
||||
/// follows the smoothed path within the reach rather than the first or last segment.
|
||||
fn refit_options(tol: f64, sigma: f64, free: [bool; 2]) -> SimplifyOptions {
|
||||
@@ -321,9 +549,31 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
if sp.closed {
|
||||
let start = sp.anchors[0].p;
|
||||
let Some(k) = marked.iter().position(|m| !m) else {
|
||||
// Everything is in range: smooth the whole closed path, across its start too.
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
|
||||
let corners = smooth_corners(&sp.anchors, true, radius);
|
||||
let Some(c) = corners.iter().position(|c| *c) else {
|
||||
// Everything is in range and nothing is a corner: smooth the whole closed path,
|
||||
// across its start too.
|
||||
let f = po::simplify_with(&PathData::single(sp.clone()), &refit_options(tol, sigma, [false; 2]));
|
||||
return f.subpaths.into_iter().next().map(|r| start_nearest(r, start, sp));
|
||||
};
|
||||
// Cut the path open at a corner, which then ends the last part as well as starting the first.
|
||||
let mut open = sp.anchors.clone();
|
||||
open.rotate_left(c);
|
||||
let mut cut = corners;
|
||||
cut.rotate_left(c);
|
||||
open.push(open[0]);
|
||||
cut.push(true);
|
||||
let mut res = refit_split(&open, &cut, tol, sigma, [false; 2]);
|
||||
if same_anchors(&res, &open) {
|
||||
return None;
|
||||
}
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
*r0 = Anchor::with_handles(r0.p, l.h_in, r0.h_out);
|
||||
}
|
||||
// The path starts where it did when the refit keeps an anchor there, and otherwise at its
|
||||
// point nearest there.
|
||||
return Some(start_nearest(SubPath::new(res, true), start, sp));
|
||||
};
|
||||
// Rotate so an untouched anchor comes first, duplicate it at the end and treat as open.
|
||||
let mut open = sp.clone();
|
||||
@@ -333,7 +583,10 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
open.anchors.push(open.anchors[0]);
|
||||
m.push(false);
|
||||
open.closed = false;
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, false);
|
||||
let mut res = smooth_open(&open.anchors, &m, near, tol, sigma, radius, false);
|
||||
if same_anchors(&res, &open.anchors) {
|
||||
return None;
|
||||
}
|
||||
let l = res.pop()?;
|
||||
if let Some(r0) = res.first_mut() {
|
||||
r0.h_in = l.h_in;
|
||||
@@ -342,7 +595,8 @@ fn smooth_subpath(sp: &SubPath, pts: &[Point], radius: f64, share: usize, tol: f
|
||||
let res = SubPath::new(res, true);
|
||||
return Some(if k == 0 { res } else { start_nearest(res, start, sp) });
|
||||
}
|
||||
Some(SubPath::new(smooth_open(&sp.anchors, &marked, near, tol, sigma, true), false))
|
||||
let res = smooth_open(&sp.anchors, &marked, near, tol, sigma, radius, true);
|
||||
(!same_anchors(&res, &sp.anchors)).then(|| SubPath::new(res, false))
|
||||
}
|
||||
|
||||
/// The segment `c`, which ends in the drag's reach (`near`) and starts outside it, split where it
|
||||
@@ -376,8 +630,10 @@ fn split_at_reach(c: CubicBez, near: &dyn Fn(Point) -> bool) -> Option<(CubicBez
|
||||
/// drag's reach (`near`): a short drag smooths what is under it, and the rest of a long segment next
|
||||
/// to it stays as it was (#1512). With `open`, `anchors` are an open path, and the curve at its first
|
||||
/// or last anchor turns with the smoothing when a run's refit reaches that anchor; a run's other ends,
|
||||
/// and those of the open run a closed path is cut into, keep their direction.
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, open: bool) -> Vec<Anchor> {
|
||||
/// and those of the open run a closed path is cut into, keep their direction. Each run keeps its
|
||||
/// corners ([`smooth_corners`] within `radius`, the tool's reach, measured within the run), and a run
|
||||
/// whose refit changes nothing stays as it was.
|
||||
fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool, tol: f64, sigma: f64, radius: f64, open: bool) -> Vec<Anchor> {
|
||||
let n = anchors.len();
|
||||
let mut runs = vec![];
|
||||
let mut i = 0;
|
||||
@@ -418,7 +674,11 @@ fn smooth_open(anchors: &[Anchor], marked: &[bool], near: &dyn Fn(Point) -> bool
|
||||
*outer = Anchor::with_handles(far.p3, far.p2, reach.p1);
|
||||
}
|
||||
let free = [open && lo == 0 && head.is_none(), open && hi == n - 1 && tail.is_none()];
|
||||
let new = refit_run(&piece, tol, sigma, free);
|
||||
let corners = smooth_corners(&piece, false, radius);
|
||||
let new = refit_split(&piece, &corners, tol, sigma, free);
|
||||
if same_anchors(&new, &piece) {
|
||||
continue;
|
||||
}
|
||||
out.splice(lo..=hi, head.into_iter().chain(new).chain(tail));
|
||||
}
|
||||
out
|
||||
@@ -816,4 +1076,123 @@ mod tests {
|
||||
assert_eq!(drag_stretches(&one), vec![0]);
|
||||
assert!((dist_to_stretches(&one, &[0], Point::new(13.0, 14.0)) - 5.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
fn polyline(pts: &[(f64, f64)]) -> Vec<Anchor> {
|
||||
pts.iter().map(|&(x, y)| Anchor::corner(Point::new(x, y))).collect()
|
||||
}
|
||||
|
||||
/// Anchors on one point, anchors that are not numbers or are too far out to measure, and a
|
||||
/// zigzag of 100,001 anchors 1 pt high give no corner and do not fail; a square's four anchors
|
||||
/// are its corners, and an open path's ends never are.
|
||||
#[test]
|
||||
fn corners_are_found_only_where_the_path_turns_at_and_around_an_anchor() {
|
||||
assert_eq!(smooth_corners(&polyline(&[(5.0, 5.0); 4]), false, 12.0), vec![false; 4]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (f64::NAN, 1.0), (10.0, 0.0)]), false, 12.0), vec![false; 3]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (1e308, 0.0), (1e308, 1e308), (-1e308, 0.0)]), true, 12.0), vec![false; 4]);
|
||||
let zigzag: Vec<(f64, f64)> = (0..=100_000).map(|i| (f64::from(i), f64::from(i % 2))).collect();
|
||||
assert!(!smooth_corners(&polyline(&zigzag), false, 12.0).contains(&true));
|
||||
let square = polyline(&[(0.0, 0.0), (100.0, 0.0), (100.0, 100.0), (0.0, 100.0)]);
|
||||
assert_eq!(smooth_corners(&square, true, 12.0), vec![true; 4]);
|
||||
assert_eq!(smooth_corners(&square, false, 12.0), vec![false, true, true, false]);
|
||||
assert_eq!(smooth_corners(&square, false, 0.0), vec![false, true, true, false], "with no reach the turn at the anchor decides");
|
||||
assert_eq!(smooth_corners(&square[..2], false, 12.0), vec![false; 2]);
|
||||
}
|
||||
|
||||
/// A 90° bump 400 pt high cut into 1 pt segments, between two long straight segments: with a reach
|
||||
/// of 10 pt its top is a corner, and with a reach of 1,000 pt, where the walk passes more segments
|
||||
/// than it measures before it gets that far, its top is not one, as the path turns by about 51°
|
||||
/// over that reach.
|
||||
#[test]
|
||||
fn a_corner_is_not_kept_where_the_reach_is_not_measured() {
|
||||
let side = 400.0 * std::f64::consts::SQRT_2;
|
||||
let steps = side.ceil() as usize;
|
||||
let mut pts = vec![(-3000.0, 0.0)];
|
||||
pts.extend((0..steps).map(|j| {
|
||||
let f = j as f64 / steps as f64;
|
||||
(-400.0 + 400.0 * f, 400.0 * f)
|
||||
}));
|
||||
pts.extend((0..=steps).map(|j| {
|
||||
let f = j as f64 / steps as f64;
|
||||
(400.0 * f, 400.0 - 400.0 * f)
|
||||
}));
|
||||
pts.push((3000.0, 0.0));
|
||||
let anchors = polyline(&pts);
|
||||
let top = steps + 1;
|
||||
assert_eq!(anchors[top].p, Point::new(0.0, 400.0));
|
||||
assert!(smooth_corners(&anchors, false, 10.0)[top]);
|
||||
assert!(!smooth_corners(&anchors, false, 1000.0)[top]);
|
||||
}
|
||||
|
||||
/// A 60° corner between two curved segments 1,000 pt long is found at a reach of 2 pt as it is
|
||||
/// when each segment is cut into a thousand pieces: the turns over the reach are measured along the
|
||||
/// curve near the anchor, not along a chord much longer than the reach.
|
||||
#[test]
|
||||
fn a_corner_between_long_curves_is_measured_along_them() {
|
||||
// The curves arrive along x and leave at 60° to it, each bending away from the other.
|
||||
let d = kurbo::Vec2::from_angle(-60f64.to_radians());
|
||||
let side = kurbo::Vec2::new(-d.y, d.x) * 1000.0;
|
||||
let v = Point::new(0.0, 0.0);
|
||||
let a = Anchor::with_handles(Point::new(-1000.0, 1000.0), Point::new(-1000.0, 1000.0), Point::new(-300.0, 1000.0));
|
||||
let b = Anchor::with_handles(v, Point::new(-300.0, 0.0), v + d * 300.0);
|
||||
let end = v + d * 1000.0 + side;
|
||||
let c = Anchor::with_handles(end, v + d * 300.0 + side, end);
|
||||
let long = [a, b, c];
|
||||
let fine: Vec<Anchor> = {
|
||||
let sp = SubPath::new(long.to_vec(), false);
|
||||
let mut pts = vec![];
|
||||
for i in 0..2 {
|
||||
let s = sp.segment(i);
|
||||
for j in 0..1000 {
|
||||
let p = s.subsegment(f64::from(j) / 1000.0..f64::from(j + 1) / 1000.0);
|
||||
if j == 0 && i == 0 {
|
||||
pts.push(Anchor::with_handles(p.p0, p.p0, p.p1));
|
||||
} else if let Some(last) = pts.last_mut() {
|
||||
*last = Anchor::with_handles(last.p, last.h_in, p.p1);
|
||||
}
|
||||
pts.push(Anchor::with_handles(p.p3, p.p2, p.p3));
|
||||
}
|
||||
}
|
||||
pts
|
||||
};
|
||||
let at_v = |anchors: &[Anchor]| {
|
||||
let corners = smooth_corners(anchors, false, 2.0);
|
||||
anchors.iter().zip(corners).find(|(x, _)| x.p == v).map(|(_, c)| c)
|
||||
};
|
||||
assert_eq!(at_v(&fine), Some(true), "cut finely, the anchor is a corner");
|
||||
assert_eq!(at_v(&long), Some(true));
|
||||
}
|
||||
|
||||
/// A line that meets a curve leaving with no handle, turning by 58° at the anchor and by about
|
||||
/// 57.53° over twice a reach of 0.02 pt: the anchor is a corner, measured either way along the path,
|
||||
/// as the stretch measured finely near the anchor covers twice the reach along the curve.
|
||||
#[test]
|
||||
fn a_corner_next_to_a_curve_without_a_handle_is_kept() {
|
||||
let at = 58f64.to_radians();
|
||||
let prev = Anchor::corner(Point::new(-100.0 * at.cos(), -100.0 * at.sin()));
|
||||
let v = Point::new(0.0, 0.0);
|
||||
let a = Anchor::with_handles(v, v, v);
|
||||
let end = Point::new(600.0, 600.0);
|
||||
let next = Anchor::with_handles(end, Point::new(200.0, 0.0), end);
|
||||
assert_eq!(smooth_corners(&[prev, a, next], false, 0.02), vec![false, true, false]);
|
||||
let back = |x: Anchor| Anchor::with_handles(x.p, x.h_out, x.h_in);
|
||||
assert_eq!(smooth_corners(&[back(next), back(a), back(prev)], false, 0.02), vec![false, true, false]);
|
||||
}
|
||||
|
||||
/// A jagged closed path whose length is less than twice the reach: its jags are not corners,
|
||||
/// while a triangle that small keeps its three.
|
||||
#[test]
|
||||
fn a_small_jagged_loop_has_no_corners() {
|
||||
let ring: Vec<(f64, f64)> = (0..16)
|
||||
.map(|i| {
|
||||
let (a, r) = (f64::from(i) / 16.0 * std::f64::consts::TAU, if i % 2 == 0 { 3.0 } else { 2.0 });
|
||||
(r * a.cos(), r * a.sin())
|
||||
})
|
||||
.collect();
|
||||
let jags = polyline(&ring);
|
||||
let sp = SubPath::new(jags.clone(), true);
|
||||
assert!(subpath_len(&sp) < 24.0);
|
||||
assert!(smooth_corners(&jags, true, 1e-6).iter().all(|c| *c), "each jag turns sharply at its anchor");
|
||||
assert_eq!(smooth_corners(&jags, true, 12.0), vec![false; 16]);
|
||||
assert_eq!(smooth_corners(&polyline(&[(0.0, 0.0), (6.0, 0.0), (3.0, 5.196)]), true, 12.0), vec![true; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let created = date_param(p, "created", C)?;
|
||||
let st = s.doc()?;
|
||||
// The row as the panel lists it now: keeping an edit in progress below changes the rows.
|
||||
let chosen = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => {
|
||||
let state = v.as_u64().and_then(|row| st.history.state(&st.doc, index(row)));
|
||||
Some(state.ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
|
||||
let chosen = match super::index_opt(p, "index", C)? {
|
||||
None => None,
|
||||
Some(row) => {
|
||||
let v = &p["index"];
|
||||
Some(st.history.state(&st.doc, row).ok_or_else(|| bad(C, format!("no history state {v}")))?.clone())
|
||||
}
|
||||
};
|
||||
settle_interaction(s)?;
|
||||
@@ -1276,23 +1276,28 @@ pub(crate) fn place_clip(s: &mut Session, clip: &Clipboard, center: vectorcraft_
|
||||
/// The artboards Paste in Place, in Front or in Back puts a copy on, in artboard order: `artboard`
|
||||
/// (the active one, #693), else `artboards`, else, while the Artboard tool is chosen with several
|
||||
/// artboards selected, those (noted in the journal as `artboards`, so a replay pastes on the same
|
||||
/// ones); `None` when none is named. A named index that is not an artboard of the document, a
|
||||
/// repeated one or an empty list is an error.
|
||||
/// ones); `None` when none is named. A named value that is not a whole number (null included), a
|
||||
/// named index that is not an artboard of the document, a repeated one or an empty list is an
|
||||
/// error; every value is checked for its form before any is checked against the artboards.
|
||||
fn paste_targets(s: &mut Session, p: &Value, cmd: &str) -> Result<Option<Vec<usize>>> {
|
||||
let n = s.doc()?.doc.artboards.len();
|
||||
let one = |v: &Value| v.as_u64().and_then(|i| usize::try_from(i).ok()).filter(|i| *i < n);
|
||||
let single = match p.get("artboard") {
|
||||
Some(v) => Some(one(v).ok_or_else(|| bad(cmd, "`artboard` must be the index of an artboard of the document"))?),
|
||||
None => None,
|
||||
};
|
||||
let listed = match p.get("artboards") {
|
||||
let single = p.get("artboard").map(|v| v.as_u64().map(index).ok_or_else(|| bad(cmd, "`artboard` must be a whole number"))).transpose()?;
|
||||
let entries = match p.get("artboards") {
|
||||
Some(v) => {
|
||||
let list = v.as_array().filter(|a| !a.is_empty()).ok_or_else(|| bad(cmd, "`artboards` must be a list of artboard indices"))?;
|
||||
let mut all = list
|
||||
.iter()
|
||||
.map(one)
|
||||
.collect::<Option<Vec<usize>>>()
|
||||
.ok_or_else(|| bad(cmd, "`artboards` names an artboard the document does not have"))?;
|
||||
let all = list.iter().map(|i| i.as_u64().map(index)).collect::<Option<Vec<usize>>>();
|
||||
Some(all.ok_or_else(|| bad(cmd, "`artboards` must contain whole numbers"))?)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
if single.is_some_and(|i| i >= n) {
|
||||
return Err(bad(cmd, "`artboard` must be the index of an artboard of the document"));
|
||||
}
|
||||
let listed = match entries {
|
||||
Some(mut all) => {
|
||||
if all.iter().any(|i| *i >= n) {
|
||||
return Err(bad(cmd, "`artboards` names an artboard the document does not have"));
|
||||
}
|
||||
all.sort_unstable();
|
||||
if all.windows(2).any(|w| w[0] == w[1]) {
|
||||
return Err(bad(cmd, "`artboards` names an artboard twice"));
|
||||
|
||||
@@ -66,7 +66,10 @@ use serde_json::{Value, json};
|
||||
|
||||
pub use action_batch::batch_overwrites_one_file;
|
||||
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
|
||||
pub use encode::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, export_art_bounds,
|
||||
note_texts,
|
||||
};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{EXPORT_PARAMS, export_crop, isolated};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,11 +2,13 @@
|
||||
//! (unknown keys are ignored, so one params object can carry the options of several formats).
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::{Artboard, Document};
|
||||
use vectorcraft_doc::{Artboard, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
use vectorcraft_render::AntiAlias;
|
||||
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
|
||||
@@ -419,13 +421,15 @@ pub(super) fn use_artboards(cmd: &str, p: &Value) -> Result<Option<bool>> {
|
||||
|
||||
/// With `useArtboards: false` in `p`: `doc` without its opacity-mask or symbol editing layer and
|
||||
/// with one artboard, the bounds of its visible art, and `p` without the artboard choice. `None`
|
||||
/// when `useArtboards` is absent, null or true. A document with no visible art is refused.
|
||||
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(Document, Value)>> {
|
||||
/// when `useArtboards` is absent, null or true. The objects that `left_out` picks (layers or
|
||||
/// objects the format leaves out, see [`hidden_nodes`]) are not measured, and a document with no
|
||||
/// other visible art is refused.
|
||||
pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value, left_out: impl Fn(&Node) -> bool) -> Result<Option<(Document, Value)>> {
|
||||
if use_artboards(cmd, p)? != Some(false) {
|
||||
return Ok(None);
|
||||
}
|
||||
let doc = &*doc.without_edit_modes();
|
||||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
|
||||
let bounds = bounds_without(doc, &left_out).ok_or_else(|| bad(cmd, "nothing to export: the document has no visible art"))?;
|
||||
let mut q = super::export::without_artboards(p);
|
||||
if let Some(o) = q.as_object_mut() {
|
||||
o.remove("useArtboards");
|
||||
@@ -433,6 +437,107 @@ pub(super) fn art_page(cmd: &str, doc: &Document, p: &Value) -> Result<Option<(D
|
||||
Ok(Some((single_artboard(doc, bounds, "Art"), q)))
|
||||
}
|
||||
|
||||
/// The bounds of the visible art of `doc` that a `format` file of the art's bounds draws, as
|
||||
/// [`art_page`] measures it with that format's default options: for HP-GL without what a plot
|
||||
/// leaves out ([`vectorcraft_cad::hpgl::write::plots_nothing`]), for other formats all of it (the
|
||||
/// non-printing layers a PDF leaves out depend on options chosen after the save panel). `None`
|
||||
/// when there is none, which such an export refuses.
|
||||
pub fn export_art_bounds(doc: &Document, format: &str) -> Option<Rect> {
|
||||
let doc = &*doc.without_edit_modes();
|
||||
bounds_without(doc, &|n| format == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n))
|
||||
}
|
||||
|
||||
/// The bounds of the visible art of `doc` with the objects that `left_out` picks hidden
|
||||
/// ([`hidden_nodes`]) and the ones in the art of the symbols that instances show taken out
|
||||
/// ([`without_nodes`]: symbol art is measured with its hidden objects). An instance of a symbol
|
||||
/// whose art is all picked or hidden is hidden too. The art of a dynamic symbol is measured whole:
|
||||
/// an instance can paint its objects, and finds them by their places in the art.
|
||||
fn bounds_without(doc: &Document, left_out: &dyn Fn(&Node) -> bool) -> Option<Rect> {
|
||||
// The symbols instances show (the first of each name) that aren't dynamic.
|
||||
let mut names = HashSet::new();
|
||||
let shown: Vec<(usize, &vectorcraft_doc::Symbol)> =
|
||||
doc.symbols.iter().enumerate().filter(|(_, s)| names.insert(s.name.as_str())).filter(|(_, s)| !s.dynamic).collect();
|
||||
// The symbols whose art `left_out` leaves nothing of, those that only hold instances of such
|
||||
// symbols included.
|
||||
let mut empty: HashSet<&str> = HashSet::new();
|
||||
while let Some((_, s)) = shown.iter().find(|(_, s)| !empty.contains(s.name.as_str()) && all_left_out(&s.art, left_out, &empty)) {
|
||||
empty.insert(&s.name);
|
||||
}
|
||||
let left_out =
|
||||
&|n: &Node| left_out(n) || matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if empty.contains(symbol.as_str()) && !has_effects(n));
|
||||
let layers = hidden_nodes(&doc.layers, left_out);
|
||||
let symbols: Vec<(usize, Arc<Node>)> = shown
|
||||
.iter()
|
||||
.filter(|(_, s)| !empty.contains(s.name.as_str()))
|
||||
.filter_map(|(i, s)| without_nodes(&s.art, left_out).map(|art| (*i, art)))
|
||||
.collect();
|
||||
if layers.is_none() && symbols.is_empty() {
|
||||
return vectorcraft_render::encode::art_bounds(doc);
|
||||
}
|
||||
let mut d = doc.clone();
|
||||
if let Some(layers) = layers {
|
||||
d.layers = layers;
|
||||
}
|
||||
for (i, art) in symbols {
|
||||
if let Some(s) = d.symbols.get_mut(i) {
|
||||
s.art = art;
|
||||
}
|
||||
}
|
||||
vectorcraft_render::encode::art_bounds(&d)
|
||||
}
|
||||
|
||||
/// Whether `n` is hidden, picked by `left_out`, an instance of one of the `empty` symbols, or a
|
||||
/// layer or a group holding only such objects (its clipping path among them when it clips). A
|
||||
/// Shaper group, or a layer, group or instance with effects, fills or strokes of its own, is none
|
||||
/// of these unless it is hidden or picked.
|
||||
fn all_left_out(n: &Node, left_out: &dyn Fn(&Node) -> bool, empty: &HashSet<&str>) -> bool {
|
||||
!n.visible
|
||||
|| left_out(n)
|
||||
|| !has_effects(n)
|
||||
&& n.extras().shaper.is_none()
|
||||
&& match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } => children.iter().all(|c| all_left_out(c, left_out, empty)),
|
||||
NodeKind::SymbolInstance { symbol, .. } => empty.contains(symbol.as_str()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `n` has a visible effect, fill or stroke of its own, which the objects it holds can
|
||||
/// take part in.
|
||||
fn has_effects(n: &Node) -> bool {
|
||||
n.appearance.effects.iter().any(|e| e.visible) || n.appearance.items.iter().any(|i| i.visible())
|
||||
}
|
||||
|
||||
/// `n` without the objects that `left_out` picks in it, when it is a layer or a group, and in the
|
||||
/// layers and groups in it at any depth; `None` when it picks none of them. A clipping layer or
|
||||
/// group keeps its clipping path, its first object, and a Shaper group and a layer or group with
|
||||
/// effects, fills or strokes of its own keep all they hold.
|
||||
fn without_nodes(n: &Arc<Node>, left_out: &dyn Fn(&Node) -> bool) -> Option<Arc<Node>> {
|
||||
let (NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip }) = &n.kind else { return None };
|
||||
if has_effects(n) || n.extras().shaper.is_some() {
|
||||
return None;
|
||||
}
|
||||
let mut changed = false;
|
||||
let kept: Vec<Arc<Node>> = children
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, c)| {
|
||||
if left_out(c) && !(*clip && i == 0) {
|
||||
changed = true;
|
||||
return None;
|
||||
}
|
||||
Some(without_nodes(c, left_out).inspect(|_| changed = true).unwrap_or_else(|| c.clone()))
|
||||
})
|
||||
.collect();
|
||||
changed.then(|| {
|
||||
let mut new = (**n).clone();
|
||||
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut new.kind {
|
||||
*children = kept;
|
||||
}
|
||||
Arc::new(new)
|
||||
})
|
||||
}
|
||||
|
||||
/// The `rect` param of a raster export: `[x, y, width, height]` in points, y down. `None` when
|
||||
/// absent; an error when it is not four finite numbers with a positive size, when the format isn't
|
||||
/// a raster one, or when it comes with an artboard choice (`rect` is the area, so those would
|
||||
@@ -481,6 +586,35 @@ pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> D
|
||||
d
|
||||
}
|
||||
|
||||
/// `nodes` with the visible objects that `left_out` picks hidden, in layers, sublayers and groups
|
||||
/// that don't clip, at any depth (what an object it picks holds is not looked at); `None` when it
|
||||
/// picks none of them. Clipping layers are looked in too, as their sublayers can be picked.
|
||||
pub(super) fn hidden_nodes(nodes: &[Arc<Node>], left_out: &dyn Fn(&Node) -> bool) -> Option<Vec<Arc<Node>>> {
|
||||
let mut out: Option<Vec<Arc<Node>>> = None;
|
||||
for (i, l) in nodes.iter().enumerate() {
|
||||
let new = if left_out(l) {
|
||||
l.visible.then(|| {
|
||||
let mut n = (**l).clone();
|
||||
n.visible = false;
|
||||
Arc::new(n)
|
||||
})
|
||||
} else {
|
||||
let (NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false }) = &l.kind else { continue };
|
||||
hidden_nodes(children, left_out).map(|c| {
|
||||
let mut n = (**l).clone();
|
||||
if let NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } = &mut n.kind {
|
||||
*children = c;
|
||||
}
|
||||
Arc::new(n)
|
||||
})
|
||||
};
|
||||
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| nodes.to_vec()).get_mut(i)?, new))) {
|
||||
*slot.0 = slot.1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
||||
r.map_err(|e| bad(C, e))
|
||||
}
|
||||
@@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
{
|
||||
return Ok(enc);
|
||||
}
|
||||
let left_out = |n: &Node| f.id == "hpgl" && vectorcraft_cad::hpgl::write::plots_nothing(doc, n);
|
||||
if use_artboards == Some(false)
|
||||
&& let Some((art, q)) = art_page(C, doc, p)?
|
||||
&& let Some((art, q)) = art_page(C, doc, p, left_out)?
|
||||
{
|
||||
return encode_files(&art, f, &q);
|
||||
}
|
||||
|
||||
@@ -550,9 +550,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
// Optical Margin Alignment setting and writing direction. When that changes the direction of a
|
||||
// frame of a restored document, the story flows through that frame's thread again. Threaded
|
||||
// frames keep their size: Auto Size is off for them. The story is laid out in the document's
|
||||
// own composite fonts.
|
||||
// own composite fonts, and type naming one the document does not define flows as in a missing
|
||||
// font, also when the document defines none.
|
||||
let fonts = if doc.text_threads.is_empty() { Default::default() } else { doc.composite_fonts.clone() };
|
||||
let lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&fonts);
|
||||
let lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&fonts);
|
||||
if !doc.text_threads.is_empty() && !restored {
|
||||
crate::cmd::threads::reflow(&Document::new(1.0, 1.0), &mut doc);
|
||||
} else if !doc.text_threads.is_empty() {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -757,8 +757,9 @@ pub const AI_EDITING_NEEDS_THE_ARTBOARDS: &str =
|
||||
pub const ART_PAGE_NEEDS_PDF: &str = "pdfCompatible: false needs the artboards to keep the editable artwork when useArtboards is false";
|
||||
|
||||
/// With `useArtboards: false` in `p`: the PDF-compatible `.ai` file of one page, the bounds of
|
||||
/// `doc`'s visible art ([`super::encode::art_page`]). It carries neither the native document nor
|
||||
/// the editing copy for other apps, and its notes end with [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
|
||||
/// `doc`'s visible art on the layers its pages draw ([`super::pdf::art_page`]). It carries neither
|
||||
/// the native document nor the editing copy for other apps, and its notes end with
|
||||
/// [`AI_EDITING_NEEDS_THE_ARTBOARDS`].
|
||||
/// `pdfCompatible: false` is refused. `None` when `p` asks for the artboards or for a legacy
|
||||
/// version 7 `.ai` file.
|
||||
pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Option<Encoded>> {
|
||||
@@ -768,7 +769,7 @@ pub(super) fn encode_ai_art_page(cmd: &str, f: &Format, doc: &Document, p: &Valu
|
||||
if !bool_or(p, "pdfCompatible", true) {
|
||||
return Err(bad(cmd, ART_PAGE_NEEDS_PDF));
|
||||
}
|
||||
let Some((art, q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) };
|
||||
let Some((art, q)) = super::pdf::art_page(cmd, doc, &ai_pdf_params(p, false))? else { return Ok(None) };
|
||||
let mut enc = encode_ai_pages(cmd, f, &art, &q, false)?;
|
||||
enc.setting_notes.push(AI_EDITING_NEEDS_THE_ARTBOARDS.to_string());
|
||||
Ok(Some(enc))
|
||||
@@ -784,9 +785,10 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
encode_ai_pages(cmd, f, doc, p, true)
|
||||
}
|
||||
|
||||
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
|
||||
/// the editing copy for other apps when `editing` is true, and neither otherwise.
|
||||
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
|
||||
/// `p` with the PDF options a `.ai` file is written with: Preserve Editing when `editing` is true,
|
||||
/// layers unless a standard decides, long pages with a `UserUnit`, and `compress` as
|
||||
/// `compression.compressText`.
|
||||
fn ai_pdf_params(p: &Value, editing: bool) -> Value {
|
||||
let mut params = if p.is_object() { p.clone() } else { json!({}) };
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("preserveEditing".into(), json!(editing));
|
||||
@@ -805,6 +807,13 @@ fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bo
|
||||
}
|
||||
}
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// [`encode_ai`] of a `.ai` file with or without PDF content, carrying the native document and
|
||||
/// the editing copy for other apps when `editing` is true, and neither otherwise.
|
||||
fn encode_ai_pages(cmd: &str, f: &Format, doc: &Document, p: &Value, editing: bool) -> Result<Encoded> {
|
||||
let params = ai_pdf_params(p, editing);
|
||||
let pdf_compatible = bool_or(¶ms, "pdfCompatible", true);
|
||||
let f = &if pdf_compatible { *f } else { without_pdf(f) };
|
||||
let native_options = super::native::ai_options(cmd, f, doc, ¶ms)?;
|
||||
|
||||
@@ -170,6 +170,113 @@ fn a_plot_reads_back_as_its_art() {
|
||||
assert!((board.height() - (30.0 + pen)).abs() < 0.1, "{board:?}");
|
||||
}
|
||||
|
||||
/// A plot of the art's bounds is measured on the art it plots: art on a non-printing layer, on a
|
||||
/// layer at 0% opacity or at 0% opacity in a group, a placed image and a path that paints nothing
|
||||
/// don't move its origin, a clipping layer is measured as clipped, and a document whose only
|
||||
/// visible art is such art is refused.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_leaves_out_the_layers_it_does_not_plot() {
|
||||
let mut s = two_boards();
|
||||
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
|
||||
let alone = plot(&mut s).unwrap();
|
||||
let printing = s.doc().unwrap().doc.layers[0].id;
|
||||
for props in [json!({"printable": false}), json!({})] {
|
||||
let layer = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
|
||||
if props.as_object().unwrap().is_empty() {
|
||||
s.execute("transparency.set", &json!({"ids": [layer], "opacity": 0})).unwrap();
|
||||
} else {
|
||||
let mut p = props.clone();
|
||||
p["id"] = layer.clone();
|
||||
s.execute("layer.setProps", &p).unwrap();
|
||||
}
|
||||
s.execute("shape.rectangle", &json!({"x": -300, "y": 500, "width": 20, "height": 20})).unwrap();
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "{props}");
|
||||
}
|
||||
// An object at 0% opacity in a group, on a layer that plots.
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let far = s.execute("shape.rectangle", &json!({"x": -500, "y": 700, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
s.execute("object.group", &json!({"ids": [far]})).unwrap();
|
||||
s.execute("transparency.set", &json!({"ids": [far], "opacity": 0})).unwrap();
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "0% opacity in a group");
|
||||
// A placed image and a path whose fill and stroke paint nothing.
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap();
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "a placed image");
|
||||
let bare = s.execute("shape.rectangle", &json!({"x": 500, "y": -400, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
s.execute("paint.setStroke", &json!({"ids": [bare], "none": true})).unwrap();
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "a path that paints nothing");
|
||||
|
||||
// A clipping layer's clipping path, which loses its paint, still clips the art measured: a
|
||||
// large square clipped to the first square draws nothing more.
|
||||
let clipping = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
|
||||
s.execute("shape.rectangle", &json!({"x": -300, "y": -300, "width": 800, "height": 800})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("layer.clippingMask.toggle", &json!({"id": clipping})).unwrap();
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "a clipping layer");
|
||||
|
||||
s.execute("layer.setProps", &json!({"id": clipping, "visible": false})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
|
||||
let e = plot(&mut s).unwrap_err().to_string();
|
||||
assert!(e.contains("no visible art"), "{e}");
|
||||
}
|
||||
|
||||
/// A placed image in a symbol's art doesn't move a plot of the art's bounds either, and a
|
||||
/// document whose only other visible art is an instance of that symbol is refused.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_leaves_out_images_in_symbol_art() {
|
||||
let mut s = two_boards();
|
||||
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
|
||||
let alone = plot(&mut s).unwrap();
|
||||
let printing = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::new_rgb8(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "far.png", "dataBase64": b64(&png), "at": [-600, 800]})).unwrap();
|
||||
s.execute("symbol.new", &json!({"name": "Far"})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.symbols.iter().any(|sym| sym.name == "Far"));
|
||||
assert_eq!(plot(&mut s).unwrap(), alone, "an image in symbol art");
|
||||
|
||||
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
|
||||
let e = plot(&mut s).unwrap_err().to_string();
|
||||
assert!(e.contains("no visible art"), "{e}");
|
||||
|
||||
// A symbol of a square and an image is measured by its square.
|
||||
let bounds = |s: &Session| super::export_art_bounds(&s.doc().unwrap().doc, "hpgl").unwrap();
|
||||
let square = s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap()["id"].clone();
|
||||
let before = bounds(&s);
|
||||
let image = s.execute("file.place", &json!({"name": "near.png", "dataBase64": b64(&png), "at": [400, 600]})).unwrap()["ids"][0].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Both", "ids": [square, image]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.symbols.len(), 2);
|
||||
let after = bounds(&s);
|
||||
for (a, b) in [(before.x0, after.x0), (before.y0, after.y0), (before.x1, after.x1), (before.y1, after.y1)] {
|
||||
assert!((a - b).abs() < 1e-6, "{before:?} {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A placed image with a Vector Halftone effect is measured when the effect makes dots, and left
|
||||
/// out when it would take more cells than the effect makes.
|
||||
#[test]
|
||||
fn a_plot_of_the_arts_bounds_measures_images_by_their_halftone_dots() {
|
||||
let mut s = two_boards();
|
||||
let plot = |s: &mut Session| s.execute("document.export", &json!({"format": "hpgl", "useArtboards": false})).map(|v| bytes(&v));
|
||||
let printing = s.doc().unwrap().doc.layers[0].id;
|
||||
s.execute("layer.new", &json!({})).unwrap();
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::new_rgb8(100, 100).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": b64(&png), "at": [300, 300]})).unwrap();
|
||||
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
|
||||
|
||||
// At 300 lines per inch a 100 pt image takes more cells than the effect makes.
|
||||
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 300}})).unwrap();
|
||||
let e = plot(&mut s).unwrap_err().to_string();
|
||||
assert!(e.contains("no visible art"), "{e}");
|
||||
|
||||
s.execute("effect.apply", &json!({"effect": "addon.color.halftone", "params": {"frequency": 20}})).unwrap();
|
||||
let out = String::from_utf8(plot(&mut s).unwrap()).unwrap();
|
||||
assert!(out.contains("PD"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_options_are_checked() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -103,6 +103,48 @@ fn use_artboards_false_writes_one_page_of_the_arts_bounds() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A page of the art's bounds is measured on the layers the PDF draws: a non-printing layer that
|
||||
/// the PDF leaves out doesn't make it larger, unless `includeNonPrinting` or `createLayers` keeps
|
||||
/// the layer. A `.ai` file of the art's bounds, which keeps layers by default, measures it unless
|
||||
/// `createLayers` is off. A document whose only visible art doesn't print is refused.
|
||||
#[test]
|
||||
fn a_page_of_the_arts_bounds_leaves_out_non_printing_layers() {
|
||||
let mut s = session(1);
|
||||
let printing = s.doc().unwrap().doc.layers[0].id;
|
||||
let notes = s.execute("layer.new", &json!({})).unwrap()["id"].clone();
|
||||
s.execute("layer.setProps", &json!({"id": notes, "printable": false})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 200, "y": 200, "width": 20, "height": 20})).unwrap();
|
||||
let size = |bytes: &[u8]| vectorcraft_pdf::import(bytes).unwrap().artboards[0].rect.size();
|
||||
let printed = size(&b64(&export(&mut s, json!({"useArtboards": false}))));
|
||||
assert!(printed.width < 50.0 && printed.height < 40.0, "{printed:?}");
|
||||
let through_export = s.execute("document.export", &as_export(&json!({"useArtboards": false}))).unwrap();
|
||||
assert_eq!(size(&b64(&through_export)), printed);
|
||||
let ai = |s: &mut Session, extra: Value| {
|
||||
let mut p = json!({"format": "ai", "useArtboards": false});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
size(&b64(&s.execute("document.export", &p).unwrap_or_else(|e| panic!("{p}: {e}"))))
|
||||
};
|
||||
assert_eq!(ai(&mut s, json!({"createLayers": false})), printed);
|
||||
let kept = [
|
||||
size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))),
|
||||
size(&b64(&export(&mut s, json!({"useArtboards": false, "createLayers": true})))),
|
||||
ai(&mut s, json!({})),
|
||||
];
|
||||
for all in kept {
|
||||
assert!(all.width > 200.0 && all.height > 200.0, "{all:?}");
|
||||
}
|
||||
|
||||
// Only the non-printing layer shows: the PDF would draw nothing.
|
||||
s.execute("layer.setProps", &json!({"id": printing, "visible": false})).unwrap();
|
||||
for p in [json!({"useArtboards": false}), json!({"useArtboards": false, "includeNonPrinting": false})] {
|
||||
let e = s.execute("document.exportPdf", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("no visible art"), "{p}: {e}");
|
||||
}
|
||||
let e = s.execute("document.export", &json!({"format": "ai", "useArtboards": false, "createLayers": false})).unwrap_err().to_string();
|
||||
assert!(e.contains("no visible art"), "{e}");
|
||||
assert!(size(&b64(&export(&mut s, json!({"useArtboards": false, "includeNonPrinting": true})))).width < 25.0);
|
||||
}
|
||||
|
||||
/// In a PDF without a standard, the warning for a page longer than 14,400 pt written with a
|
||||
/// `UserUnit` above 1 names "Write pages longer than 200 in at full size in points": at PDF 1.5
|
||||
/// (written as 1.6) and later, through `document.exportPdf`, `document.export`, a page of the
|
||||
|
||||
@@ -797,22 +797,47 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option<NodeId> {
|
||||
}
|
||||
|
||||
/// The ruler guides of each of `roots` that is a sublayer (in its sublayers too), with the layer
|
||||
/// that holds it, in order. Other objects hold none.
|
||||
/// that holds it, in order. Other objects hold none. Each root, and each child of it and of its
|
||||
/// sublayers, is looked at once.
|
||||
fn held_guides(d: &Document, roots: impl IntoIterator<Item = NodeId>) -> Vec<(NodeId, Node)> {
|
||||
let mut out = vec![];
|
||||
for id in roots {
|
||||
let (Some(n), Some(parent)) = (d.node(id), d.parent_of(id)) else { continue };
|
||||
if n.holds_ruler_guides() {
|
||||
n.walk(&mut |c| {
|
||||
if c.is_ruler_guide() {
|
||||
out.push((parent, c.clone()));
|
||||
}
|
||||
});
|
||||
guide_seen(n);
|
||||
if n.is_layer() {
|
||||
guides_of(n, parent, &mut out);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Push each ruler guide of `layer` and of its sublayers onto `out` in paint order, with `parent`.
|
||||
fn guides_of(layer: &Node, parent: NodeId, out: &mut Vec<(NodeId, Node)>) {
|
||||
for c in layer.children().into_iter().flatten() {
|
||||
guide_seen(c);
|
||||
if c.is_ruler_guide() {
|
||||
out.push((parent, Node::clone(c)));
|
||||
} else if c.is_layer() {
|
||||
guides_of(c, parent, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// The nodes that [`held_guides`] has looked at on this thread, in order.
|
||||
static GUIDES_SEEN: std::cell::RefCell<Vec<NodeId>> = const { std::cell::RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
/// Note that [`held_guides`] looked at `n`.
|
||||
#[cfg(test)]
|
||||
fn guide_seen(n: &Node) {
|
||||
GUIDES_SEEN.with(|s| s.borrow_mut().push(n.id));
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn guide_seen(_: &Node) {}
|
||||
|
||||
/// An image of `r` (in a clip group when it is clipped).
|
||||
fn raster_node(d: &mut Document, r: Raster) -> Node {
|
||||
let key = unique_key(d, "flattened");
|
||||
@@ -922,6 +947,53 @@ mod tests {
|
||||
assert_eq!(flat.children(Some(layer)).and_then(|c| c.last()).map(|c| c.id), Some(g));
|
||||
}
|
||||
|
||||
/// The guides of a sublayer and of its sublayers are gathered in paint order with the layer
|
||||
/// that holds it, looking at the sublayer and each child of it and of its sublayers once.
|
||||
/// Other roots give none.
|
||||
#[test]
|
||||
fn held_guides_look_at_each_node_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
|
||||
let layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let sublayer =
|
||||
|s: &mut Session, parent: NodeId| NodeId(s.execute("layer.newSublayer", &json!({"parent": parent.0})).unwrap()["id"].as_u64().unwrap());
|
||||
let rect = |s: &mut Session, on: NodeId| {
|
||||
s.execute("layer.setCurrent", &json!({"id": on.0})).unwrap();
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let sub = sublayer(&mut s, layer);
|
||||
let deep = sublayer(&mut s, sub);
|
||||
let empty = sublayer(&mut s, layer);
|
||||
let (a, b, c) = (rect(&mut s, sub), rect(&mut s, deep), rect(&mut s, empty));
|
||||
let d = Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let mut guide = |on: NodeId, pos: f64| {
|
||||
let g = Node::new(d.alloc_id(), NodeKind::RulerGuide { vertical: true, pos, artboard: None });
|
||||
d.insert(Some(on), usize::MAX, g).unwrap()
|
||||
};
|
||||
let (g1, g2, g3) = (guide(sub, 1.0), guide(deep, 2.0), guide(sub, 3.0));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let ids = |n: &Node| {
|
||||
let mut v = vec![];
|
||||
n.walk(&mut |c| v.push(c.id));
|
||||
v
|
||||
};
|
||||
assert_eq!(ids(doc.node(sub).unwrap()), vec![sub, deep, b, g2, a, g1, g3]);
|
||||
let run = |roots: &[NodeId]| {
|
||||
GUIDES_SEEN.with(|v| v.borrow_mut().clear());
|
||||
let got: Vec<_> = held_guides(doc, roots.iter().copied()).into_iter().map(|(p, g)| (p, g.id)).collect();
|
||||
(got, GUIDES_SEEN.with(|v| v.take()))
|
||||
};
|
||||
// Every node of the sublayer is looked at once, and the guides keep their paint order.
|
||||
assert_eq!(run(&[sub]), (vec![(layer, g2), (layer, g1), (layer, g3)], vec![sub, deep, b, g2, a, g1, g3]));
|
||||
// A sublayer that holds none gives none; a path holds none; a missing root and a top-level
|
||||
// layer, which has no parent, are skipped.
|
||||
assert_eq!(run(&[empty]), (vec![], vec![empty, c]));
|
||||
assert_eq!(run(&[a]), (vec![], vec![a]));
|
||||
assert_eq!(run(&[NodeId(u64::MAX), layer]), (vec![], vec![]));
|
||||
// A deeper sublayer gives its guides to the sublayer that holds it.
|
||||
assert_eq!(run(&[deep, deep]).0, vec![(sub, g2), (sub, g2)]);
|
||||
}
|
||||
|
||||
/// In a file without transparency with Clip Complex Regions on, art under a group's opaque
|
||||
/// image within one of its pixels joins the group, also where a long image is made at a lower
|
||||
/// resolution and its pixels are wider than at the preset's.
|
||||
|
||||
@@ -6,20 +6,34 @@
|
||||
//! (<https://helpx.adobe.com/ph_fil/illustrator/how-to/global-editing-sensei.html>), in our words:
|
||||
//!
|
||||
//! - With one object selected, Start Global Edit finds the objects that repeat its shape: the
|
||||
//! same outline, wherever it lies and at whatever size. Two options narrow the match: Appearance
|
||||
//! (the same fill and stroke too) and Size (the same width and height too). The search covers
|
||||
//! every artboard and the canvas around them by default; it can be limited to a range of
|
||||
//! artboards (`1, 3-5`) and can leave out the objects that lie on no artboard. Images, type,
|
||||
//! masks, linked files and selections of more than one object are not supported.
|
||||
//! same outline, wherever it lies, at whatever size and at whatever angle. Two options narrow
|
||||
//! the match: Appearance (the same fill and stroke too) and Size (the same width and height
|
||||
//! too). The search covers every artboard and the canvas around them by default; it can be
|
||||
//! limited to a range of artboards (`1, 3-5`) and can leave out the objects that lie on no
|
||||
//! artboard. Images, type, masks, linked files and selections of more than one object are not
|
||||
//! supported.
|
||||
//! - The help page "Edit groups with similar objects"
|
||||
//! (<https://helpx.adobe.com/illustrator/desktop/manage-objects/edit-objects/edit-groups-with-similar-objects.html>):
|
||||
//! with a group selected, Start Global Edit finds the groups like it, and an edit made to an
|
||||
//! object inside one of them is made to the matching object in each of the others. Here a group
|
||||
//! is like another when it holds as many objects, in the same order, each like the one at its
|
||||
//! place (paths, compound paths, symbol instances and groups of them, clipping paths included),
|
||||
//! and they lie the same way relative to each other: the group as a whole is moved or scaled.
|
||||
//! With Appearance on, each of those objects must have the same fill and stroke too; with Size,
|
||||
//! the groups the same width and height.
|
||||
//! and they lie the same way relative to each other: the group as a whole is moved, scaled or
|
||||
//! rotated. With Appearance on, each of those objects must have the same fill and stroke too;
|
||||
//! with Size, the groups the same width and height.
|
||||
//! - The how-to "Design a modern geometric logo"
|
||||
//! (<https://helpx.adobe.com/vn_vi/illustrator/how-to/modern-logo-design.html>) makes rotated
|
||||
//! copies of a group of shapes around a point, then starts Global Edit on one object and edits
|
||||
//! every copy of it at once. Here a rotated copy is found as a moved or scaled one is, and an
|
||||
//! edit reaches it turned by its own angle: an anchor moved to the right on the selected object
|
||||
//! moves downward on a copy turned a quarter turn clockwise. The rules below for rotated copies
|
||||
//! are VectorCraft's own. The angle is the one that lays each anchor and handle of the selected
|
||||
//! object on the copy's of the same order, so a square whose anchors were turned a quarter turn
|
||||
//! is a copy turned a quarter turn. A copy is the same shape when, laid so, every anchor and
|
||||
//! handle lies within a millionth of the larger side of the copy's box of its own ([`SAME`]).
|
||||
//! A reflected copy is another shape. With Size, a rotated copy has the same size when it is the
|
||||
//! selected object rotated without scaling: its width and height are compared as they were
|
||||
//! before the rotation.
|
||||
//! - Selecting one of the repeated objects with the Selection tool highlights the similar objects
|
||||
//! at once, before Global Edit starts. Here they are the objects Start Global Edit would take
|
||||
//! with the current Global Edit Options ([`similar`]).
|
||||
@@ -29,15 +43,16 @@
|
||||
//!
|
||||
//! Here the found objects ([`GlobalEdit::members`]) stay highlighted while the selection keeps the
|
||||
//! object that was selected, so that the Transform panel and the bounding box measure that object
|
||||
//! and not the whole set. Each member keeps the transform (a move and a uniform scale) that maps
|
||||
//! the first object's outline onto its own. After every edit ([`propagate`], run inside
|
||||
//! [`Session::edit`], so the whole change is one undo step), the first selected member the edit
|
||||
//! changed is the model: what changed about it is given to every other member, and nothing else,
|
||||
//! so members found by shape alone keep their own fills and strokes where the edit didn't touch
|
||||
//! them:
|
||||
//! and not the whole set. Each member keeps the transform (a move, a uniform scale and, for a
|
||||
//! rotated copy, a turn) that maps the first object's outline onto its own. After every edit
|
||||
//! ([`propagate`], run inside [`Session::edit`], so the whole change is one undo step), the first
|
||||
//! selected member the edit changed is the model: what changed about it is given to every other
|
||||
//! member, and nothing else, so members found by shape alone keep their own fills and strokes
|
||||
//! where the edit didn't touch them:
|
||||
//!
|
||||
//! - its outline (and anchors), placed through that transform, so in proportion to each member's
|
||||
//! size. Only geometry is placed so: no stroke weight is scaled to a member's size;
|
||||
//! size and turned by its angle. Only geometry is placed so: no stroke weight is scaled to a
|
||||
//! member's size;
|
||||
//! - its opacity, blending, knockout and the object's effects, each when it changed;
|
||||
//! - of its fills and strokes, each setting that changed (paint, weight, opacity, blending,
|
||||
//! effects, caps, dashes, arrowheads …), as it is: it goes to the member's fill or stroke of the
|
||||
@@ -74,12 +89,12 @@
|
||||
//! and later edits leave it as it is.
|
||||
//! The object the edit started from can't be left out (a Shift-click on it selects it or
|
||||
//! deselects it as before), Global Edit ends when fewer than two members are left, and leaving
|
||||
//! one out is not an undo step. Rotated copies are not covered.
|
||||
//! one out is not an undo step.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, Scaling, Shared, StrokeLayer};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Rect, Shape, Vec2};
|
||||
|
||||
use super::wand::{fill_of, stroke_of};
|
||||
use super::{CommandSpec, Result, Session, bad, bool_or, checked_id, cmd, has_doc};
|
||||
@@ -98,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Start Global Edit",
|
||||
["Select"],
|
||||
None,
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
|
||||
"{appearance?: bool, size?: bool, artboards?: \"all\" | range such as \"1, 3-5\", includeCanvas?: bool} (each defaults to the Global Edit Options) while Global Edit is off: start it on the one selected object (a path, compound path, symbol instance or group of them), with the objects of the same shape, moved, scaled or rotated copies of it and not reflected ones (for a group: the groups holding objects like its own, at the same places) (and, with `appearance`, the same fill and stroke; with `size`, the same width and height, for a rotated copy before its rotation) on the artboards given (and, with `includeCanvas`, off every artboard), at most 10000 → {active: true, count, ids}; while on: stop it (Escape does the same) → {active: false}",
|
||||
can_toggle,
|
||||
toggle
|
||||
),
|
||||
@@ -181,7 +196,8 @@ pub type Outlines = Vec<(BezPath, [u8; 3])>;
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GlobalEdit {
|
||||
/// The objects edited together, the one selected at the start first, each with the transform
|
||||
/// (a move and a uniform scale) that maps that first object's outline onto its own.
|
||||
/// (a move, a uniform scale and, for a turned copy, a turn) that maps that first object's
|
||||
/// outline onto its own.
|
||||
pub members: Vec<(NodeId, Affine)>,
|
||||
/// [`highlights`] as of a document revision: the canvas asks every frame.
|
||||
outlines: std::cell::RefCell<Option<(u64, Outlines)>>,
|
||||
@@ -257,14 +273,60 @@ impl Form {
|
||||
}
|
||||
}
|
||||
|
||||
fn same(&self, o: &Form) -> bool {
|
||||
self.tag == o.tag
|
||||
&& self.symbols == o.symbols
|
||||
&& self.points.len() == o.points.len()
|
||||
&& self.points.iter().zip(&o.points).all(|(a, b)| a.distance(*b) < 1e-6)
|
||||
/// The transform that lays this form's object on `o`'s, point for point in order, and whether
|
||||
/// it turns the object: a move and a uniform scale when the two lie the same way, otherwise a
|
||||
/// turn too. `None` when `o` is another shape, a reflected copy included.
|
||||
fn onto(&self, o: &Form) -> Option<(Affine, bool)> {
|
||||
if self.tag != o.tag || self.symbols != o.symbols || self.points.len() != o.points.len() {
|
||||
return None;
|
||||
}
|
||||
let near = |xf: Affine| self.points.iter().zip(&o.points).all(|(a, b)| (xf * *a).distance(*b) < SAME);
|
||||
let back = self.frame.inverse();
|
||||
if near(Affine::IDENTITY) {
|
||||
return Some((o.frame * back, false));
|
||||
}
|
||||
let turn = self.turn_onto(o)?;
|
||||
near(turn).then(|| (o.frame * turn * back, true))
|
||||
}
|
||||
|
||||
/// The turn, uniform scale and move (both forms in their unit boxes) that best lays this
|
||||
/// form's points on `o`'s in order, by least squares. A turn within [`SQUARE`] of a quarter
|
||||
/// turn is that quarter turn exactly.
|
||||
fn turn_onto(&self, o: &Form) -> Option<Affine> {
|
||||
let n = self.points.len() as f64;
|
||||
let middle = |f: &Form| f.points.iter().fold(Vec2::ZERO, |s, p| s + p.to_vec2()) / n;
|
||||
let (from, to) = (middle(self), middle(o));
|
||||
// With the points as complex numbers about their middles, the fit is `Σ conj(p) q / Σ |p|²`.
|
||||
let (mut re, mut im, mut spread) = (0.0, 0.0, 0.0);
|
||||
for (a, b) in self.points.iter().zip(&o.points) {
|
||||
let (p, q) = (a.to_vec2() - from, b.to_vec2() - to);
|
||||
re += p.dot(q);
|
||||
im += p.cross(q);
|
||||
spread += p.hypot2();
|
||||
}
|
||||
let len = re.hypot(im);
|
||||
let scale = len / spread;
|
||||
if !(scale.is_finite() && scale > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let (mut cos, mut sin) = (re / len, im / len);
|
||||
if cos.abs() < SQUARE {
|
||||
(cos, sin) = (0.0, sin.signum());
|
||||
} else if sin.abs() < SQUARE {
|
||||
(cos, sin) = (cos.signum(), 0.0);
|
||||
}
|
||||
let (a, b) = (scale * cos, scale * sin);
|
||||
Some(Affine::translate(to) * Affine::new([a, b, -b, a, 0.0, 0.0]) * Affine::translate(-from))
|
||||
}
|
||||
}
|
||||
|
||||
/// How far apart two forms' points may lie, each form in its unit box, for the forms to be the
|
||||
/// same. For a turned copy this bounds the angle too: a copy turned and then changed by more than
|
||||
/// a millionth of its larger side is another shape.
|
||||
const SAME: f64 = 1e-6;
|
||||
/// A turn whose sine or cosine is below this is taken as a whole number of quarter turns.
|
||||
const SQUARE: f64 = 1e-9;
|
||||
|
||||
/// Global Edit supports paths, compound paths, symbol instances and groups of them; not images,
|
||||
/// type, linked files, objects with an opacity mask, clipping paths or guides (the help page lists
|
||||
/// images, text, masks and links as unsupported).
|
||||
@@ -395,8 +457,16 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
|
||||
}
|
||||
boards.as_ref().is_none_or(|rs| rs.iter().any(|r| on(*r, b)))
|
||||
};
|
||||
// A copy that lies the same way has the size of the selected object when their boxes are as
|
||||
// wide and as high; a turned copy, when the transform that lays the object on it doesn't scale.
|
||||
let same_size = |n: &Node, xf: Affine, turned: bool| {
|
||||
if turned {
|
||||
close(xf.determinant().abs().sqrt(), 1.0)
|
||||
} else {
|
||||
matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2))
|
||||
}
|
||||
};
|
||||
let mut out = vec![(*id, Affine::IDENTITY)];
|
||||
let back = form.frame.inverse();
|
||||
super::select::walk_selectable(d, st.isolation, |n| {
|
||||
if n.id == *id || out.len() >= MAX_MEMBERS {
|
||||
return false;
|
||||
@@ -413,12 +483,12 @@ fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
|
||||
};
|
||||
// The object count first: it turns most objects down before their form is made.
|
||||
if n.children().map(Vec::len) == count
|
||||
&& let Some(f) = Form::of(n).filter(|f| f.same(&form) && supported(n))
|
||||
&& (!o.size || matches!((key_size, size(n)), (Some((w, h)), Some((w2, h2))) if close(w, w2) && close(h, h2)))
|
||||
&& let Some((xf, turned)) = Form::of(n).and_then(|f| form.onto(&f)).filter(|_| supported(n))
|
||||
&& (!o.size || same_size(n, xf, turned))
|
||||
&& (!o.appearance || same_look(key, n))
|
||||
&& in_scope(n)
|
||||
{
|
||||
out.push((n.id, f.frame * back));
|
||||
out.push((n.id, xf));
|
||||
// A group taken is taken whole, as compound paths, symbol instances and the others are.
|
||||
return false;
|
||||
}
|
||||
@@ -464,7 +534,7 @@ fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let o = s.global_edit_options.with(p, &s.doc()?.doc, C)?;
|
||||
let members = matches(s, &o)?;
|
||||
if members.len() < 2 {
|
||||
return Err(bad(C, "no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again"));
|
||||
return Err(bad(C, "no similar objects found (reflected copies are not matched); change the Global Edit Options and try again"));
|
||||
}
|
||||
let ids: Vec<u64> = members.iter().map(|(id, _)| id.0).collect();
|
||||
let st = s.doc_mut()?;
|
||||
@@ -1030,6 +1100,38 @@ mod tests {
|
||||
assert!(taken > 100 && checked - taken > 100 && asked > 200, "{taken} of {checked}, {asked} selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_form_is_laid_on_a_turned_copy_and_on_no_reflected_or_unmeasurable_one() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
let mut path = |points: &[(f64, f64)]| {
|
||||
let anchors = points.iter().map(|(x, y)| Anchor::corner(Point::new(*x, *y))).collect();
|
||||
Node::path(d.alloc_id(), PathData::new(vec![SubPath::new(anchors, true)]), Appearance::default())
|
||||
};
|
||||
let form = Form::of(&path(&[(0.0, 0.0), (30.0, 0.0), (30.0, 70.0)])).unwrap();
|
||||
let mut onto = |points: &[(f64, f64)]| Form::of(&path(points)).and_then(|f| form.onto(&f));
|
||||
let near = |xf: Affine, want: [f64; 6]| xf.as_coeffs().iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9);
|
||||
// The same way at twice the size: a move and a scale, no turn.
|
||||
let (xf, turned) = onto(&[(100.0, 0.0), (160.0, 0.0), (160.0, 140.0)]).unwrap();
|
||||
assert!(!turned && near(xf, [2.0, 0.0, 0.0, 2.0, 100.0, 0.0]), "{xf:?}");
|
||||
// A quarter turn, (x, y) → (−y, x), moved by (100, 0); and a turn by 30° at half the size.
|
||||
let (xf, turned) = onto(&[(100.0, 0.0), (100.0, 30.0), (30.0, 30.0)]).unwrap();
|
||||
assert!(turned && near(xf, [0.0, 1.0, -1.0, 0.0, 100.0, 0.0]), "{xf:?}");
|
||||
let by = Affine::rotate(30f64.to_radians()) * Affine::scale(0.5);
|
||||
let at = |x: f64, y: f64| {
|
||||
let p = by * Point::new(x, y);
|
||||
(p.x, p.y)
|
||||
};
|
||||
let (xf, turned) = onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.0)]).unwrap();
|
||||
assert!(turned && near(xf, by.as_coeffs()), "{xf:?}");
|
||||
// A reflected copy, a copy with an anchor a thousandth of its size away, and points that
|
||||
// are not numbers or not finite.
|
||||
assert!(onto(&[(0.0, 0.0), (-30.0, 0.0), (-30.0, 70.0)]).is_none());
|
||||
assert!(onto(&[at(0.0, 0.0), at(30.0, 0.0), at(30.0, 70.07)]).is_none());
|
||||
assert!(onto(&[(f64::NAN, 0.0), (30.0, 0.0), (30.0, 70.0)]).is_none());
|
||||
assert!(onto(&[(0.0, 0.0), (f64::INFINITY, 0.0), (30.0, 70.0)]).is_none());
|
||||
assert!(onto(&[(0.0, 0.0), (30.0, 0.0)]).is_none(), "another count of anchors");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn support_of_empty_degenerate_and_deep_objects_is_decided_as_building_the_form_decides() {
|
||||
let mut d = Document::new(800.0, 600.0);
|
||||
|
||||
@@ -701,19 +701,33 @@ pub(crate) fn capture_label_styles(doc: &mut Document, ids: &[NodeId], change: &
|
||||
changed
|
||||
}
|
||||
|
||||
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). Returns whether
|
||||
/// the kept style changed.
|
||||
/// Apply `change` to the style graph `graph` keeps for its labels of kind `role` (or the default one). A `change` that
|
||||
/// sets the fill of legend labels marks it as set on them, so a black one stays black on dark pie wedges. Returns whether
|
||||
/// the kept style or that mark changed.
|
||||
fn store_label_style(doc: &mut Document, graph: NodeId, role: Role, change: &dyn Fn(&mut CharStyle)) -> bool {
|
||||
let Some(spec) = doc.node_mut(graph).and_then(|n| n.extras_if_any().and_then(|e| e.graph.as_deref_mut())) else { return false };
|
||||
let mut style = label_style(&spec.label_styles, role);
|
||||
// The kept fill can already be the one `change` sets (black, the default): `change` sets the fill when it gives one
|
||||
// of two different fills something else.
|
||||
let sets_fill = role == Role::Legend
|
||||
&& [Paint::None, Paint::solid(Color::BLACK)].into_iter().any(|fill| {
|
||||
let mut probe = CharStyle { fill: fill.clone(), ..style.clone() };
|
||||
change(&mut probe);
|
||||
probe.fill != fill
|
||||
});
|
||||
change(&mut style);
|
||||
let styles = &mut spec.label_styles;
|
||||
let slot = match role {
|
||||
Role::Value => &mut spec.label_styles.values,
|
||||
Role::Category => &mut spec.label_styles.categories,
|
||||
Role::Legend => &mut spec.label_styles.legend,
|
||||
Role::Value => &mut styles.values,
|
||||
Role::Category => &mut styles.categories,
|
||||
Role::Legend => &mut styles.legend,
|
||||
};
|
||||
let changed = slot.as_ref() != Some(&style);
|
||||
let mut changed = slot.as_ref() != Some(&style);
|
||||
*slot = Some(style);
|
||||
if sets_fill && !styles.legend_fill_set {
|
||||
styles.legend_fill_set = true;
|
||||
changed = true;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -942,12 +956,9 @@ fn run_paint_change(was: &Node, now: &Node, fill: bool) -> Option<(CharStyle, Ve
|
||||
}
|
||||
|
||||
/// Does graph `g` pick the fill of its legend labels itself, white on a dark wedge and black on the others: a pie with
|
||||
/// Legends in Wedges whose legend labels keep no fill of their own?
|
||||
/// Legends in Wedges whose legend labels keep no fill of their own ([`GraphSpec::picks_legend_fill`])?
|
||||
pub(super) fn picks_legend_fill(g: &GraphSpec) -> bool {
|
||||
g.kind == GraphKind::Pie
|
||||
&& g.legend
|
||||
&& g.pie_legend_in_wedges
|
||||
&& !g.label_styles.legend.as_ref().is_some_and(|st| st.fill != Paint::solid(Color::BLACK))
|
||||
g.picks_legend_fill()
|
||||
}
|
||||
|
||||
/// The graph labels among `roots` and inside them, for an edit that reaches graph labels without naming them.
|
||||
|
||||
@@ -6542,6 +6542,93 @@ fn pie_names_and_wedge_labels_keep_their_style() {
|
||||
assert_eq!(fills, ["#00ff00", "#00ff00"], "the kept fill, also on the dark wedge");
|
||||
}
|
||||
|
||||
/// On a pie with Legends in Wedges, a black fill given to the white legend label on the dark wedge is kept for every
|
||||
/// legend label: the graph draws it black on the dark wedge again, also after a native save and open.
|
||||
#[test]
|
||||
fn a_black_fill_set_on_a_wedge_legend_label_stays_black_on_dark_wedges() {
|
||||
let cases = [("paint.setFill", json!({"color": "#000000"})), ("text.setStyle", json!({"fill": "#000000"})), ("edit.colors.invert", json!({}))];
|
||||
every_case(cases, |(cmd, params)| {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
|
||||
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
|
||||
let legend_fill = |s: &Session, text: &str| {
|
||||
let NodeKind::Text(t) = label_node(s, id, "Legend", text).kind else { panic!("{text} is not type") };
|
||||
t.runs[0].style.fill.color().map(|c| c.to_hex()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(legend_fill(&s, "a"), "#ffffff", "white on the black wedge");
|
||||
let mut p = params.clone();
|
||||
p["ids"] = json!([text_id(&s, id, "Legend", "a").0]);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!spec(&s, id).label_styles.legend_fill_set, "{cmd}: undone");
|
||||
assert_eq!(legend_fill(&s, "a"), "#ffffff", "{cmd}: white again after the undo");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: redone");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
|
||||
assert_eq!((legend_fill(&s, "a"), legend_fill(&s, "b")), ("#000000".into(), "#000000".into()), "{cmd}: black on both wedges");
|
||||
let dir = vectorcraft_testkit::TempDir::new("graph-wedge-legend");
|
||||
let path = dir.join("pie.vectorcraft");
|
||||
s.execute("document.save", &json!({"path": path})).unwrap();
|
||||
s.execute("document.open", &json!({"path": path})).unwrap();
|
||||
assert!(spec(&s, id).label_styles.legend_fill_set, "{cmd}: kept through save and open");
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[2, 1]]})).unwrap();
|
||||
assert_eq!(legend_fill(&s, "a"), "#000000", "{cmd}: still black after open");
|
||||
});
|
||||
}
|
||||
|
||||
/// A size given to the legend labels of a pie with Legends in Wedges keeps the default black fill with it. A color mode
|
||||
/// change leaves that fill black, so the legend label on the dark wedge is still drawn white. A legend fill that is not
|
||||
/// black, in a file saved without the mark, is marked as set by the change.
|
||||
#[test]
|
||||
fn a_color_mode_change_keeps_the_default_wedge_legend_fill() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, "pie", ",a,b\nP1,1,2");
|
||||
s.execute("graph.setType", &json!({"id": id.0, "pieLegend": "wedges"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 11})).unwrap();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
let styles = spec(&s, id).label_styles;
|
||||
assert!(!styles.legend_fill_set && !styles.legend_fill_is_own(), "{styles:?}");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2]]})).unwrap();
|
||||
let NodeKind::Text(t) = label_node(&s, id, "Legend", "a").kind else { panic!("a is not type") };
|
||||
assert_eq!(t.runs[0].style.fill.color().map(|c| c.to_hex()), Some("#ffffff".into()), "white on the black wedge");
|
||||
let red = Paint::solid(vectorcraft_color::Color::from_hex("#ff0000").unwrap());
|
||||
let styles = vectorcraft_doc::LabelStyles { legend: Some(vectorcraft_doc::CharStyle { fill: red, ..Default::default() }), ..Default::default() };
|
||||
assert!(!styles.legend_fill_set && styles.legend_fill_is_own(), "{styles:?}");
|
||||
s.edit("legacy", |d, _| {
|
||||
d.node_mut(id).unwrap().extras_mut().graph.as_deref_mut().unwrap().label_styles = styles;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("file.documentColorMode", &json!({"mode": "rgb"})).unwrap();
|
||||
assert!(spec(&s, id).label_styles.legend_fill_set, "the converted red is still the labels' own");
|
||||
}
|
||||
|
||||
/// The default black legend fill of a graph that keeps its legend beside the marks stays the default through a color
|
||||
/// mode change, so turning on Legends in Wedges later still draws the labels on dark wedges white.
|
||||
#[test]
|
||||
fn a_color_mode_change_keeps_the_default_legend_fill_of_other_graphs() {
|
||||
// A pie made here keeps its legend beside the pies.
|
||||
for kind in ["column", "pie"] {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let id = make(&mut s, kind, ",a,b\nP1,1,2");
|
||||
s.execute("select.set", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("text.setStyle", &json!({"size": 11})).unwrap();
|
||||
let black = Paint::solid(vectorcraft_color::Color::BLACK);
|
||||
let before = spec(&s, id).label_styles;
|
||||
assert_eq!(before.legend.as_ref().map(|st| st.fill.clone()), Some(black.clone()), "{kind}: {before:?}");
|
||||
s.execute("file.documentColorMode", &json!({"mode": "cmyk"})).unwrap();
|
||||
let after = spec(&s, id).label_styles;
|
||||
assert_eq!(after.legend.as_ref().map(|st| st.fill.clone()), Some(black), "{kind}: {after:?}");
|
||||
assert!(!after.legend_fill_set && !after.legend_fill_is_own(), "{kind}: still the default: {after:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn labels_are_told_apart_by_where_they_come_not_by_their_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Composer, JustificationOptions, NodeKind, Scaling, SingleWordJustify, TextRun};
|
||||
use vectorcraft_geom::{Rect, Vec2};
|
||||
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, point_indices, switch_or};
|
||||
use super::{Affine, CommandSpec, NodeId, Result, Session, bad, bool_or, check_switches, cmd, has_doc, index, ok, switch_or};
|
||||
use crate::{Clipboard, EngineError};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result<Vec
|
||||
return artboard_indices(d, p, cmd);
|
||||
}
|
||||
if let Some(recorded) = p.get("selectedArtboards") {
|
||||
return artboard_indices(d, &json!({ "indices": recorded }), cmd);
|
||||
return listed_artboards(d, recorded, "selectedArtboards", cmd);
|
||||
}
|
||||
let mut picked: Vec<u64> = t["selected"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_u64)
|
||||
.filter(|i| usize::try_from(*i).is_ok_and(|i| i < d.artboards.len()))
|
||||
.collect();
|
||||
let mut picked: Vec<usize> =
|
||||
t["selected"].as_array().into_iter().flatten().filter_map(Value::as_u64).map(index).filter(|i| *i < d.artboards.len()).collect();
|
||||
if picked.is_empty() {
|
||||
picked.push(t["active"].as_u64().unwrap_or(0));
|
||||
picked.push(on_board(d, t["active"].as_u64().map_or(0, index))?);
|
||||
}
|
||||
artboard_indices(d, &json!({ "indices": picked }), cmd)
|
||||
picked.sort_unstable();
|
||||
picked.dedup();
|
||||
Ok(picked)
|
||||
}
|
||||
|
||||
/// Rename Artboards: one artboard takes the name, and several take it numbered in artboard order, in
|
||||
@@ -221,23 +218,51 @@ fn artboard_switch_orientation(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "switched": switched }))
|
||||
}
|
||||
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`, with the errors of
|
||||
/// [`artboard_indices`] (`missing index`, `no such artboard`).
|
||||
/// The one artboard `cmd` acts on: `index`, an artboard of `d`. Left out or null, it is
|
||||
/// `missing index`; a value that is not a whole number is "`index` must be a whole number"; a
|
||||
/// whole number past the last artboard is `no such artboard`.
|
||||
pub(crate) fn artboard_index(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<usize> {
|
||||
let i = p.get("index").ok_or_else(|| bad(cmd, "missing index"))?;
|
||||
artboard_indices(d, &json!({ "index": i }), cmd)?.into_iter().next().ok_or_else(|| EngineError::Other("no such artboard".into()))
|
||||
artboard_at(d, p, "index", cmd)?.ok_or_else(|| bad(cmd, "missing index"))
|
||||
}
|
||||
|
||||
/// The artboard of `d` that `key` of `p` names, or `None` when `p` leaves it out or gives null. A
|
||||
/// value that is not a whole number is "`{key}` must be a whole number", and a whole number past the
|
||||
/// last artboard is `no such artboard`.
|
||||
pub(crate) fn artboard_at(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
super::index_opt(p, key, cmd)?.map(|i| on_board(d, i)).transpose()
|
||||
}
|
||||
|
||||
/// `i` when it is an artboard of `d`, else `no such artboard`.
|
||||
pub(crate) fn on_board(d: &vectorcraft_doc::Document, i: usize) -> Result<usize> {
|
||||
match i < d.artboards.len() {
|
||||
true => Ok(i),
|
||||
false => Err(EngineError::Other("no such artboard".into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// The artboards `cmd` acts on: `indices` (several), else `index` (one), each an artboard of `d`,
|
||||
/// in order and once each.
|
||||
/// in order and once each. `indices` is read as [`listed_artboards`] reads it, and `index` as
|
||||
/// [`artboard_index`] reads it.
|
||||
pub(crate) fn artboard_indices(d: &vectorcraft_doc::Document, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let listed = match (p.get("indices"), p.get("index")) {
|
||||
(Some(v), _) => point_indices(v),
|
||||
(None, Some(i)) => point_indices(&json!([i])),
|
||||
(None, None) => return Err(bad(cmd, "missing index")),
|
||||
};
|
||||
let mut indices =
|
||||
listed.filter(|v| !v.is_empty() && v.iter().all(|i| *i < d.artboards.len())).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
match p.get("indices") {
|
||||
Some(v) => listed_artboards(d, v, "indices", cmd),
|
||||
None => Ok(vec![artboard_index(d, p, cmd)?]),
|
||||
}
|
||||
}
|
||||
|
||||
/// `v`, the value of `key`, as artboards of `d`, in order and once each. A value that is not a list
|
||||
/// is "`{key}` must be a list of artboard indices", an empty list is "`{key}` must be a non-empty
|
||||
/// list of artboard indices", and a list with an entry that is not a whole number is "`{key}` must
|
||||
/// contain whole numbers"; a whole number past the last artboard is `no such artboard`. Every entry
|
||||
/// is checked for its form before any is checked against the artboards.
|
||||
fn listed_artboards(d: &vectorcraft_doc::Document, v: &Value, key: &str, cmd: &str) -> Result<Vec<usize>> {
|
||||
let entries = v.as_array().ok_or_else(|| bad(cmd, format!("`{key}` must be a list of artboard indices")))?;
|
||||
if entries.is_empty() {
|
||||
return Err(bad(cmd, format!("`{key}` must be a non-empty list of artboard indices")));
|
||||
}
|
||||
let listed = entries.iter().map(Value::as_u64).collect::<Option<Vec<u64>>>();
|
||||
let listed = listed.ok_or_else(|| bad(cmd, format!("`{key}` must contain whole numbers")))?;
|
||||
let mut indices = listed.into_iter().map(|i| on_board(d, index(i))).collect::<Result<Vec<usize>>>()?;
|
||||
indices.sort_unstable();
|
||||
indices.dedup();
|
||||
Ok(indices)
|
||||
|
||||
@@ -217,6 +217,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
||||
p!("recentFontsCount", "Type", "Options", "Number of Recent Fonts", int(1, 15)),
|
||||
p!("missingGlyphProtection", "Type", "Options", "Enable Missing Glyph Protection", bool),
|
||||
p!("highlightAlternateGlyphs", "Type", "Options", "Highlight Alternate Glyphs", bool),
|
||||
p!("showCharacterAlternates", "Type", "Options", "Show Character Alternates", bool),
|
||||
p!("highlightSubstitutedFonts", "Type", "Options", "Highlight Substituted Fonts", bool),
|
||||
p!("placeholderText", "Type", "Options", "Fill New Type Objects With Placeholder Text", bool),
|
||||
p!("typedLists", "Type", "Options", "Start Lists From Typed Markers", bool),
|
||||
|
||||
@@ -400,6 +400,8 @@ thread_local! {
|
||||
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] went through the document's
|
||||
/// threads on this thread.
|
||||
static THREAD_SCANS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// Tests: how many times [`story_in`] went through a thread's frames on this thread.
|
||||
static STORY_BUILDS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Tests: how many times [`story_of`] and [`ThreadIndex::of`] have gone through the document's
|
||||
@@ -409,6 +411,12 @@ pub(crate) fn thread_scans() -> usize {
|
||||
THREAD_SCANS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// Tests: how many times [`story_in`] has gone through a thread's frames on this thread.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn story_builds() -> usize {
|
||||
STORY_BUILDS.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// The frames of the thread that `id` is a frame of, in thread order, each area type or type on a
|
||||
/// path; empty when `id` is in no thread or the thread has fewer than two such frames.
|
||||
pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
@@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec<NodeId> {
|
||||
|
||||
/// [`story_of`] for `id` in `thread`, the first of the document's threads that lists it.
|
||||
pub(crate) fn story_in(d: &Document, thread: &[NodeId], id: NodeId) -> Vec<NodeId> {
|
||||
#[cfg(test)]
|
||||
STORY_BUILDS.with(|c| c.set(c.get() + 1));
|
||||
let frames: Vec<NodeId> = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect();
|
||||
if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] }
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Text",
|
||||
[],
|
||||
None,
|
||||
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
"{id?|ids?, text} replace the contents of text objects (keeps the first run's style); no undo step when no object changes (each already has the text in one style, every paragraph with the first one's attributes, and no ruby) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits)}",
|
||||
has_doc,
|
||||
set_text
|
||||
),
|
||||
@@ -104,7 +104,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Area",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} or {items: [{id, anchors}…], dx, dy} move anchors of area type's frame (the type area) by dx, dy points, with their handles; the text reflows at its size, through its thread too (what Direct Selection does dragging a frame corner or edge, or the frame anchors its marquee selected, and the arrow keys nudging those); with items, every listed frame moves in one step; no undo step when no frame changes (dx and dy both 0)",
|
||||
has_doc,
|
||||
reshape_area
|
||||
),
|
||||
@@ -113,7 +113,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reshape Type Path",
|
||||
[],
|
||||
None,
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path)",
|
||||
"{id, anchors: [[subpath, anchor]…], dx, dy} move anchors of type on a path's path by dx, dy points, with their handles; the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging an anchor of the path); no undo step when the path does not change (dx and dy both 0)",
|
||||
has_doc,
|
||||
reshape_path
|
||||
),
|
||||
@@ -122,7 +122,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Move Type Path Handle",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path)",
|
||||
"{id, subpath?, anchor, which: \"in\"|\"out\", x, y, independent?: bool} move a direction handle of an anchor of type on a path's path to (x, y), as path.setHandle moves a path's (independent: that side only, the anchor becomes a corner); the start and end brackets keep their fractions of the path and the text reflows along it (what Direct Selection does dragging a handle of the path); no undo step when the handle is already there and the path does not change",
|
||||
has_doc,
|
||||
set_path_handle
|
||||
),
|
||||
@@ -131,7 +131,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fit Headline",
|
||||
["Type"],
|
||||
None,
|
||||
"{ids?|id?} track the first line of area type so it fills the frame width → {tracking}",
|
||||
"{ids?|id?} track the first line of area type so it fills the frame width; no undo step when the first line already has that tracking → {tracking}",
|
||||
has_selection,
|
||||
fit_headline
|
||||
),
|
||||
@@ -140,7 +140,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text; no undo step when no character, paragraph or graph label style changes) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -149,7 +149,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Ruby…",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
|
||||
"{ids?|id?, start?: byte, end?: byte, text?: reading, position?: above|below (vertical type: right|left), align?: start|center|end, size?: 1–100 (percent of the base text's size, default 50), overhang?: bool (default true), remove?: bool} set the ruby (furigana) over the selected text's bytes in `start..end` (without them: all its text): the reading over the base characters, above them in horizontal type and right of them in vertical type; `remove` takes the ruby off the range instead; no undo step when the ruby does not change (the same reading and options again, or `remove` where there is none) → {ids, ruby: [{start, end, text, position, align, sizePercent, overhang}…] (the first object's ruby)}",
|
||||
has_selection,
|
||||
ruby
|
||||
),
|
||||
@@ -265,8 +265,8 @@ fn fractional_widths(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Type › Ruby: set or remove the ruby (furigana) over a byte range of the selected type in one
|
||||
/// undo step. Without `start`/`end` the range is all the text; a new reading replaces the ruby
|
||||
/// over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
|
||||
/// undo step (none when nothing changes). Without `start`/`end` the range is all the text; a new
|
||||
/// reading replaces the ruby over the range. The reply lists the first object's ruby (start/end in bytes of its plain text).
|
||||
fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "type.ruby";
|
||||
let ids = text_targets(s, p, C)?;
|
||||
@@ -293,7 +293,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let overhang = switch_or(p, "overhang", true, C)?;
|
||||
let mut first: Option<Value> = None;
|
||||
s.edit("Ruby", |d, _| {
|
||||
// The same reading again, or a removal where there is no ruby, is no undo step.
|
||||
edit_if_nodes_changed(s, "Ruby", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
@@ -320,7 +321,7 @@ fn ruby(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
first = Some(serde_json::to_value(&t.ruby).unwrap_or(Value::Null));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "ruby": first.unwrap_or(Value::Null)}))
|
||||
}
|
||||
@@ -755,6 +756,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
}
|
||||
|
||||
/// [`Session::edit`] for a type command that may set what the type already has: `f` runs on a
|
||||
/// copy of the document, and it becomes an undo step only when one of the objects `ids` differs
|
||||
/// afterwards or `f` returns true for a change of its own elsewhere (a graph's kept label styles).
|
||||
/// When nothing changed, the document, its undo and redo steps and its saved state stay as they
|
||||
/// were. Only the objects are compared, not the whole document, whose embedded images can be large.
|
||||
fn edit_if_nodes_changed<T>(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result<T> {
|
||||
super::colorcmds::edit_if_changed(s, label, |before, d| {
|
||||
let (v, other) = f(d)?;
|
||||
let changed = other || ids.iter().any(|id| before.node(*id) != d.node(*id));
|
||||
Ok((v, changed))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
|
||||
let ids = targets(s, p)?;
|
||||
let t = text_ids(&s.doc()?.doc, &ids);
|
||||
@@ -849,7 +863,8 @@ pub(crate) fn style_chars(t: &mut TextObject, range: Option<TextRange>, mut f: i
|
||||
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string();
|
||||
let ids = text_targets(s, p, "text.setText")?;
|
||||
s.edit("Typing", |d, _| {
|
||||
// The text the type already has is no undo step.
|
||||
edit_if_nodes_changed(s, "Typing", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let style = t.first_style();
|
||||
@@ -860,7 +875,7 @@ fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
t.ruby.clear();
|
||||
refresh_bounds(t);
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })))
|
||||
}
|
||||
@@ -933,7 +948,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let protect = s.prefs.missing_glyph_protection && (font.is_some() || style.is_some());
|
||||
// The fonts the styled characters are set in, for the warning.
|
||||
let mut set = std::collections::BTreeSet::new();
|
||||
s.edit("Character", |d, _| {
|
||||
// Attributes the characters already have are no undo step.
|
||||
edit_if_nodes_changed(s, "Character", &ids, |d| {
|
||||
for id in &ids {
|
||||
let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
|
||||
let size = size.map(|v| local_type_value(t, v, (0.1, 1296.0), false, C)).transpose()?;
|
||||
@@ -1008,8 +1024,9 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Graph labels keep a change made to all of their text through the graph being drawn again (sizes as given,
|
||||
// not in a scaled label's own units).
|
||||
let mut labels_changed = false;
|
||||
if range.is_none() {
|
||||
super::graph::capture_label_styles(d, &ids, &|st| {
|
||||
labels_changed = super::graph::capture_label_styles(d, &ids, &|st| {
|
||||
if let Some(f) = &font {
|
||||
st.font_family = f.clone();
|
||||
st.font_version = None;
|
||||
@@ -1052,7 +1069,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), labels_changed))
|
||||
})?;
|
||||
let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }));
|
||||
if let Some(w) = super::fonts::set_font_warning(set) {
|
||||
@@ -1350,14 +1367,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
items.push((id, refs));
|
||||
}
|
||||
let d = Vec2::new(f64_req(p, "dx", C)?, f64_req(p, "dy", C)?);
|
||||
s.edit("Reshape Type Area", |doc, _| {
|
||||
// Anchors moved by no distance are no undo step.
|
||||
let ids: Vec<NodeId> = items.iter().map(|(id, _)| *id).collect();
|
||||
edit_if_nodes_changed(s, "Reshape Type Area", &ids, |doc| {
|
||||
for (id, refs) in &items {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(*id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(*id)) };
|
||||
if !reshape_area_with(t, |t| t.move_area_anchors(refs, d)) {
|
||||
return Err(bad(C, "no such frame anchor, or the frame would reach past the canvas"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1377,7 +1396,8 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not type on a path outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Path", |doc, _| {
|
||||
// Anchors moved by no distance are no undo step.
|
||||
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| {
|
||||
@@ -1388,7 +1408,7 @@ fn reshape_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1406,7 +1426,8 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !on_path || node.is_some_and(|n| n.extras().perspective.is_some()) {
|
||||
return Err(bad(C, "not type on a path outside perspective"));
|
||||
}
|
||||
s.edit("Reshape Type Path", |doc, _| {
|
||||
// A handle moved to where it is is no undo step.
|
||||
edit_if_nodes_changed(s, "Reshape Type Path", &[id], |doc| {
|
||||
let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) else { return Err(EngineError::NoNode(id)) };
|
||||
let before = t.kind.clone();
|
||||
let within = |t: &TextObject| {
|
||||
@@ -1417,7 +1438,7 @@ fn set_path_handle(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "no such path anchor, or the path would reach past the canvas"));
|
||||
}
|
||||
refresh_bounds(t);
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
@@ -1428,6 +1449,8 @@ mod block;
|
||||
pub(crate) use block::needed_room;
|
||||
#[cfg(test)]
|
||||
mod block_tests;
|
||||
#[cfg(test)]
|
||||
mod noop_tests;
|
||||
|
||||
/// Width of the first line and of the space it can fill, with `tracking` on the first paragraph.
|
||||
/// The third value is where the first line ends in the plain text.
|
||||
@@ -1469,7 +1492,9 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
plans.push((*id, (lo * 10.0).floor() / 10.0));
|
||||
}
|
||||
let first = plans.first().map(|p| p.1).ok_or_else(|| bad(C, "select area type whose first line can fit its frame"))?;
|
||||
s.edit("Fit Headline", |d, _| {
|
||||
// A headline that already has its fitted tracking is no undo step.
|
||||
let fitted: Vec<NodeId> = plans.iter().map(|(id, _)| *id).collect();
|
||||
edit_if_nodes_changed(s, "Fit Headline", &fitted, |d| {
|
||||
for (id, tr) in &plans {
|
||||
if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) {
|
||||
let end = t.plain_text().find('\n').unwrap_or(usize::MAX).min(vectorcraft_text::edit::runs_len(&t.runs));
|
||||
@@ -1477,7 +1502,7 @@ fn fit_headline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
refresh_bounds(t);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(((), false))
|
||||
})?;
|
||||
Ok(json!({ "tracking": first }))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use super::*;
|
||||
|
||||
/// The document and the number of undo steps, to compare before and after a command that sets
|
||||
/// what the type already has.
|
||||
fn state(s: &Session) -> (std::sync::Arc<Document>, usize) {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len())
|
||||
}
|
||||
|
||||
/// Assert that `cmd` with `p` leaves the document and its undo history as they were.
|
||||
fn records_nothing(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = state(s);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = state(s);
|
||||
assert!(*after.0 == *before.0, "{cmd} {p} changed the document");
|
||||
assert_eq!(after.1, before.1, "{cmd} {p} recorded an undo step");
|
||||
}
|
||||
|
||||
/// Assert that `cmd` with `p` changes the document in exactly one undo step.
|
||||
fn records_one_step(s: &mut Session, cmd: &str, p: Value) {
|
||||
let before = state(s);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
let after = state(s);
|
||||
assert!(*after.0 != *before.0, "{cmd} {p} left the document as it was");
|
||||
assert_eq!(after.1, before.1 + 1, "{cmd} {p} did not record one undo step");
|
||||
}
|
||||
|
||||
fn session_with(text: &str, area: Option<Value>) -> (Session, u64) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 600, "height": 400})).unwrap();
|
||||
let mut p = json!({"x": 10, "y": 20, "text": text, "size": 24});
|
||||
if let Some(a) = area {
|
||||
p["area"] = a;
|
||||
}
|
||||
let id = s.execute("text.create", &p).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// Ruby set again with the same reading and options, or removed where there is none, records no
|
||||
/// undo step; a step that does change the ruby still undoes in one.
|
||||
#[test]
|
||||
fn ruby_that_changes_nothing_records_no_undo_step() {
|
||||
let (mut s, id) = session_with("漢字です", None);
|
||||
records_nothing(&mut s, "type.ruby", json!({"remove": true}));
|
||||
let p = json!({"text": "かんじ", "start": 0, "end": 6, "position": "below", "size": 60});
|
||||
records_one_step(&mut s, "type.ruby", p.clone());
|
||||
records_nothing(&mut s, "type.ruby", p);
|
||||
s.execute("type.ruby", &json!({"remove": true})).unwrap();
|
||||
records_nothing(&mut s, "type.ruby", json!({"remove": true, "start": 0, "end": 6}));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.ruby.len(), 1, "undo brings the ruby back");
|
||||
}
|
||||
|
||||
/// `text.setStyle` (the Character panel) given the attributes the characters already have
|
||||
/// records no undo step, with a range too.
|
||||
#[test]
|
||||
fn character_attributes_already_set_record_no_undo_step() {
|
||||
let (mut s, _) = session_with("Headline", None);
|
||||
records_nothing(&mut s, "text.setStyle", json!({"size": 24}));
|
||||
s.execute("text.setStyle", &json!({"tracking": 50, "fill": "#ff0000"})).unwrap();
|
||||
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "fill": "#ff0000"}));
|
||||
records_nothing(&mut s, "text.setStyle", json!({"tracking": 50, "start": 0, "end": 4}));
|
||||
s.execute("text.setStyle", &json!({"justify": "center"})).unwrap();
|
||||
records_nothing(&mut s, "text.setStyle", json!({"justify": "center"}));
|
||||
records_one_step(&mut s, "text.setStyle", json!({"tracking": 80, "start": 0, "end": 4}));
|
||||
}
|
||||
|
||||
/// `text.setStyle` on a graph label that leaves the label's own text as it is but changes the
|
||||
/// style its kind of label keeps still records one undo step; given again, it records none.
|
||||
#[test]
|
||||
fn a_kept_graph_label_style_change_alone_records_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();
|
||||
let csv = ",2024,2025\nQ1,3,2\nQ2,5,4";
|
||||
let graph = NodeId(
|
||||
s.execute("graph.create", &json!({"type": "column", "x": 100, "y": 100, "width": 300, "height": 200, "csv": csv})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap(),
|
||||
);
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let legend = doc.node(graph).unwrap().children().unwrap().iter().find(|c| c.name.as_deref() == Some("Legend")).unwrap();
|
||||
let (label, size) = legend
|
||||
.children()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find_map(|c| match &c.kind {
|
||||
NodeKind::Text(t) if t.plain_text() == "2025" => Some((c.id, t.runs[0].style.size)),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let kept = |s: &Session| s.doc().unwrap().doc.node(graph).unwrap().extras().graph.as_deref().unwrap().label_styles.legend.clone();
|
||||
assert!(kept(&s).is_none());
|
||||
let p = json!({"ids": [label.0], "size": size});
|
||||
let before = state(&s);
|
||||
s.execute("text.setStyle", &p).unwrap();
|
||||
let after = state(&s);
|
||||
assert!(after.0.node(label) == before.0.node(label), "the label's own text changed");
|
||||
assert!(kept(&s).is_some(), "the legend keeps the style");
|
||||
assert_eq!(after.1, before.1 + 1, "the kept style change recorded no undo step");
|
||||
records_nothing(&mut s, "text.setStyle", p);
|
||||
}
|
||||
|
||||
/// `text.setText` with the text the type already has records no undo step; new text records one.
|
||||
#[test]
|
||||
fn the_same_text_records_no_undo_step() {
|
||||
let (mut s, _) = session_with("Same words", None);
|
||||
records_nothing(&mut s, "text.setText", json!({"text": "Same words"}));
|
||||
records_one_step(&mut s, "text.setText", json!({"text": "Other words"}));
|
||||
}
|
||||
|
||||
/// Fit Headline run again on a headline it already fitted records no undo step; the first run
|
||||
/// records one.
|
||||
#[test]
|
||||
fn fit_headline_again_records_no_undo_step() {
|
||||
let (mut s, _) = session_with("HEADLINE\nbody text", Some(json!({"width": 300, "height": 120})));
|
||||
let start = state(&s);
|
||||
let tracking = s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"].clone();
|
||||
let fitted = state(&s);
|
||||
assert!(*fitted.0 != *start.0 && fitted.1 == start.1 + 1, "the first Fit Headline did not record one undo step");
|
||||
let before = state(&s);
|
||||
assert_eq!(s.execute("text.fitHeadline", &json!({})).unwrap()["tracking"], tracking);
|
||||
let after = state(&s);
|
||||
assert!(*after.0 == *before.0 && after.1 == before.1, "a second Fit Headline recorded an undo step");
|
||||
}
|
||||
|
||||
/// Reshaping area type or type on a path by no distance, or moving a path handle to where it is,
|
||||
/// records no undo step; a reshape that moves something records one.
|
||||
#[test]
|
||||
fn reshaping_type_by_nothing_records_no_undo_step() {
|
||||
let (mut s, id) = session_with("Boxed words", Some(json!({"width": 200, "height": 100})));
|
||||
records_nothing(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 0, "dy": 0}));
|
||||
records_one_step(&mut s, "text.reshapeArea", json!({"id": id, "anchors": [[0, 2]], "dx": 20, "dy": 10}));
|
||||
let path =
|
||||
s.execute("path.create", &json!({"anchors": [{"x": 100, "y": 300, "in": [50, 300], "out": [150, 300]}, {"x": 400, "y": 300}]})).unwrap();
|
||||
let on_path = s.execute("text.createInPath", &json!({"path": path["id"], "mode": "onPath", "text": "Along the curve"})).unwrap()["id"].clone();
|
||||
records_nothing(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 0}));
|
||||
records_one_step(&mut s, "text.reshapePath", json!({"id": on_path, "anchors": [[0, 1]], "dx": 0, "dy": 20}));
|
||||
let handle = json!({"id": on_path, "anchor": 0, "which": "out", "x": 100, "y": 250});
|
||||
records_one_step(&mut s, "text.setPathHandle", handle.clone());
|
||||
records_nothing(&mut s, "text.setPathHandle", handle);
|
||||
}
|
||||
@@ -111,6 +111,9 @@ pub struct Prefs {
|
||||
pub recent_fonts_count: u32,
|
||||
pub missing_glyph_protection: bool,
|
||||
pub highlight_alternate_glyphs: bool,
|
||||
/// Type › Show Character Alternates: with a Type tool and one character selected, the canvas
|
||||
/// shows that character's alternate glyphs beside it (on by default).
|
||||
pub show_character_alternates: bool,
|
||||
/// Type › Highlight Substituted Fonts: on screen, text drawn in a substitute for a missing
|
||||
/// font is highlighted, in every document (on by default).
|
||||
pub highlight_substituted_fonts: bool,
|
||||
@@ -442,6 +445,7 @@ impl Default for Prefs {
|
||||
recent_fonts_count: 10,
|
||||
missing_glyph_protection: true,
|
||||
highlight_alternate_glyphs: false,
|
||||
show_character_alternates: true,
|
||||
highlight_substituted_fonts: true,
|
||||
placeholder_text: true,
|
||||
typed_lists: true,
|
||||
|
||||
@@ -415,14 +415,16 @@ impl Session {
|
||||
|
||||
/// Measure the type not measured yet in open document `index`, which is in the back, in its own
|
||||
/// composite fonts, lent for the measuring, so its type has the bounds it will have when the
|
||||
/// document comes to the front. The front document stays the one whose composite fonts are
|
||||
/// registered. The names of the document's composite fonts its type was measured in are kept,
|
||||
/// so its coming to the front does not measure that type again.
|
||||
/// document comes to the front: type naming a composite font the document does not define is
|
||||
/// measured as in a missing font, also when the front document defines one of that name, and
|
||||
/// also when the document defines none. The front document stays the one whose composite fonts
|
||||
/// are registered. The names of the document's composite fonts its type was measured in are
|
||||
/// kept, so its coming to the front does not measure that type again.
|
||||
fn measure_back_document(&mut self, index: usize) {
|
||||
let Some(st) = self.docs.get(index) else { return };
|
||||
let fonts = st.doc.composite_fonts.clone();
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let lent = db.lend_composite_fonts(&fonts);
|
||||
let lent = db.lend_document_composite_fonts(&fonts);
|
||||
// A name another loan holds with a different definition is not lent: type set in it is
|
||||
// measured again when the document comes to the front.
|
||||
let own: HashSet<String> =
|
||||
|
||||
@@ -3018,6 +3018,76 @@ fn live_paint_expand_keeps_the_faces_and_edges_that_draw() {
|
||||
assert_eq!(rows(&n, lower_stroke), rows(&before, lower_stroke));
|
||||
}
|
||||
|
||||
/// `livePaint.info` lists every fill and stroke row of each face and edge, bottom first, and says
|
||||
/// which rows draw by Expand's rule, while `fill`, `stroke` and `width` stay the top rows.
|
||||
#[test]
|
||||
fn live_paint_info_lists_every_appearance_row() {
|
||||
use vectorcraft_doc::{FillLayer, StrokeLayer};
|
||||
let fill = |p: Paint| AppearanceItem::Fill(FillLayer::new(p));
|
||||
let stroke = |p: Paint, w: f64| AppearanceItem::Stroke(StrokeLayer::new(p, w));
|
||||
let red = || Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let blue = || Paint::solid(Color::rgb(0.0, 0.0, 1.0));
|
||||
let green = || Paint::solid(Color::rgb(0.0, 1.0, 0.0));
|
||||
let hidden = |mut i: AppearanceItem| {
|
||||
match &mut i {
|
||||
AppearanceItem::Fill(f) => f.visible = false,
|
||||
AppearanceItem::Stroke(st) => st.visible = false,
|
||||
}
|
||||
i
|
||||
};
|
||||
let faded = |mut i: AppearanceItem, o: f32| {
|
||||
match &mut i {
|
||||
AppearanceItem::Fill(f) => f.opacity = o,
|
||||
AppearanceItem::Stroke(st) => st.opacity = o,
|
||||
}
|
||||
i
|
||||
};
|
||||
let mut s = session();
|
||||
let g = live(&mut s);
|
||||
// A drawn lower fill and a half-opaque stroke under an unpainted top fill.
|
||||
let lower = set_live_part(&mut s, g, Some((25.0, 50.0)), None, vec![fill(red()), faded(stroke(blue(), 2.0), 0.5), fill(Paint::None)], 1.0);
|
||||
let quiet = set_live_part(&mut s, g, Some((125.0, 50.0)), None, vec![hidden(fill(red())), faded(fill(red()), 0.0)], 1.0);
|
||||
let bare = set_live_part(&mut s, g, Some((75.0, 50.0)), None, vec![], 1.0);
|
||||
// Two strokes with a fill between them.
|
||||
let two = set_live_part(&mut s, g, None, Some((0.0, 50.0)), vec![stroke(red(), 4.0), fill(green()), stroke(blue(), 2.0)], 1.0);
|
||||
let thin = set_live_part(&mut s, g, None, Some((100.0, 50.0)), vec![stroke(red(), 0.0)], 1.0);
|
||||
let clear_edge = set_live_part(&mut s, g, None, Some((75.0, 0.0)), vec![stroke(red(), 2.0)], 0.0);
|
||||
let hidden_edge = set_live_part(&mut s, g, None, Some((150.0, 50.0)), vec![stroke(red(), 2.0)], 1.0);
|
||||
s.edit("t", |d, _| {
|
||||
d.node_mut(hidden_edge).unwrap().visible = false;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let parts = [lower, quiet, bare, two, thin, clear_edge, hidden_edge];
|
||||
assert_eq!(parts.iter().collect::<std::collections::HashSet<_>>().len(), parts.len());
|
||||
|
||||
let info = s.execute("livePaint.info", &json!({"group": g.0})).unwrap();
|
||||
let part = |key: &str, id: NodeId| info[key].as_array().unwrap().iter().find(|p| p["id"] == id.0).unwrap().clone();
|
||||
let fill_row = |i: usize, p: Paint, o: f64, v: bool, drawn: bool| json!({"index": i, "kind": "fill", "paint": p.label(), "opacity": o, "visible": v, "drawn": drawn});
|
||||
let stroke_row = |i: usize, p: Paint, o: f64, v: bool, w: f64, drawn: bool| json!({"index": i, "kind": "stroke", "paint": p.label(), "opacity": o, "visible": v, "width": w, "drawn": drawn});
|
||||
|
||||
let f = part("faces", lower);
|
||||
assert_eq!(f["fill"], json!(Paint::None.label()), "fill stays the top fill");
|
||||
assert!(f["bounds"].is_array());
|
||||
assert_eq!(
|
||||
f["appearance"],
|
||||
json!([fill_row(0, red(), 1.0, true, true), stroke_row(1, blue(), 0.5, true, 2.0, true), fill_row(2, Paint::None, 1.0, true, false)])
|
||||
);
|
||||
assert_eq!(part("faces", quiet)["appearance"], json!([fill_row(0, red(), 1.0, false, false), fill_row(1, red(), 0.0, true, false)]));
|
||||
assert_eq!(part("faces", bare)["appearance"], json!([]));
|
||||
|
||||
let e = part("edges", two);
|
||||
assert_eq!((e["stroke"].clone(), e["width"].clone()), (json!(blue().label()), json!(2.0)), "stroke and width stay the top stroke's");
|
||||
assert_eq!(
|
||||
e["appearance"],
|
||||
json!([stroke_row(0, red(), 1.0, true, 4.0, true), fill_row(1, green(), 1.0, true, true), stroke_row(2, blue(), 1.0, true, 2.0, true)])
|
||||
);
|
||||
assert_eq!(part("edges", thin)["appearance"], json!([stroke_row(0, red(), 1.0, true, 0.0, false)]));
|
||||
for id in [clear_edge, hidden_edge] {
|
||||
assert_eq!(part("edges", id)["appearance"], json!([stroke_row(0, red(), 1.0, true, 2.0, false)]), "a part that is not shown draws no row");
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge carries a face's whole appearance when one of its rows draws, and carries nothing from a
|
||||
/// face that draws nothing.
|
||||
#[test]
|
||||
|
||||
@@ -1034,14 +1034,39 @@ fn in_place_pastes_put_a_copy_on_each_artboard_named() {
|
||||
// `artboard` wins over `artboards`.
|
||||
let ids = ids_of(&s.execute(cmd, &json!({"artboard": 2, "artboards": [0, 3]})).unwrap());
|
||||
assert_eq!(doc(&s).bounds_of(&ids, false).unwrap().origin(), Point::new(boards[2].x0 + 10.0, boards[2].y0 + 5.0), "{cmd}");
|
||||
// A list that is empty, names an artboard the document lacks or names one twice, or isn't a
|
||||
// list: an error, and nothing pasted.
|
||||
let before = doc(&s).clone();
|
||||
for bad in [json!([]), json!([0, 4]), json!([1, 1]), json!([0, -1]), json!(2), json!("0,1")] {
|
||||
assert!(s.execute(cmd, &json!({"artboards": bad})).is_err(), "{cmd} with artboards {bad}");
|
||||
// A list that is empty, names an artboard the document lacks or names one twice, holds a
|
||||
// value that is not a whole number, or isn't a list, and an `artboard` that is not a whole
|
||||
// number or not an artboard: an error naming the fault, and nothing pasted. Both keys are
|
||||
// checked when both are given.
|
||||
let (before, undo) = (doc(&s).clone(), s.doc().unwrap().history.undo.len());
|
||||
let (not_list, missing, not_whole) = (
|
||||
"`artboards` must be a list of artboard indices",
|
||||
"`artboards` names an artboard the document does not have",
|
||||
"`artboards` must contain whole numbers",
|
||||
);
|
||||
let cases = [
|
||||
(json!({"artboards": []}), not_list),
|
||||
(json!({"artboards": 2}), not_list),
|
||||
(json!({"artboards": "0,1"}), not_list),
|
||||
(json!({"artboards": [0, 4]}), missing),
|
||||
(json!({"artboards": [1, 1]}), "`artboards` names an artboard twice"),
|
||||
(json!({"artboards": [0, -1]}), not_whole),
|
||||
(json!({"artboards": [0, 1.5]}), not_whole),
|
||||
(json!({"artboards": [4, "1"]}), not_whole),
|
||||
(json!({"artboard": 1, "artboards": [7]}), missing),
|
||||
(json!({"artboard": 1, "artboards": ["1"]}), not_whole),
|
||||
(json!({"artboard": 4}), "`artboard` must be the index of an artboard of the document"),
|
||||
(json!({"artboard": -1}), "`artboard` must be a whole number"),
|
||||
(json!({"artboard": 1.5}), "`artboard` must be a whole number"),
|
||||
(json!({"artboard": "1"}), "`artboard` must be a whole number"),
|
||||
(json!({"artboard": null}), "`artboard` must be a whole number"),
|
||||
(json!({"artboard": "1", "artboards": [9]}), "`artboard` must be a whole number"),
|
||||
];
|
||||
for (p, want) in cases {
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert_eq!(e, format!("invalid parameters for `{cmd}`: {want}"), "{cmd} {p}");
|
||||
}
|
||||
assert!(s.execute(cmd, &json!({"artboard": 1, "artboards": [7]})).is_err(), "{cmd}: a bad list with `artboard`");
|
||||
assert_eq!(doc(&s), &before, "{cmd}");
|
||||
assert_eq!((doc(&s), s.doc().unwrap().history.undo.len()), (&before, undo), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2023,6 +2023,260 @@ fn smoothing_leaves_paths_out_of_reach_and_straight_segments_alone() {
|
||||
assert_eq!(path(&s, line), before);
|
||||
}
|
||||
|
||||
/// The turn, in degrees, of `sp` at each of its anchors: round a closed path, and 0 at an open
|
||||
/// path's first and last anchors.
|
||||
fn smooth_anchor_turns(sp: &vectorcraft_geom::SubPath) -> Vec<f64> {
|
||||
let (n, segs) = (sp.anchors.len(), sp.segment_count());
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
if segs == 0 || !sp.closed && (i == 0 || i == n - 1) {
|
||||
return 0.0;
|
||||
}
|
||||
let (a, b) = (sp.segment((i + segs - 1) % segs), sp.segment(i % segs));
|
||||
let tin = [a.p3 - a.p2, a.p3 - a.p1, a.p3 - a.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
let tout = [b.p1 - b.p0, b.p2 - b.p0, b.p3 - b.p0].into_iter().find(|d| d.hypot() > 1e-9).unwrap_or_default();
|
||||
tin.cross(tout).atan2(tin.dot(tout)).abs().to_degrees()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The turn of `sp` at its anchor on `at`, `None` when no anchor is there.
|
||||
fn smooth_turn_at(sp: &vectorcraft_geom::SubPath, at: Point) -> Option<f64> {
|
||||
let i = sp.anchors.iter().position(|a| a.p.distance(at) < 1e-9)?;
|
||||
smooth_anchor_turns(sp).get(i).copied()
|
||||
}
|
||||
|
||||
/// An open polyline with a corner at (300, 300) whose angle inside is `inside` degrees: a 100 pt
|
||||
/// leg along y = 300 from the left, then a 100 pt leg turning by 180 - `inside` degrees, each leg cut
|
||||
/// into `per_leg` segments. Returns the path, the corner, and a drag along the path from 40 pt
|
||||
/// before the corner to 40 pt after it.
|
||||
fn smooth_corner_path(s: &mut Session, inside: f64, per_leg: usize) -> (NodeId, Point, Vec<Point>) {
|
||||
let v = Point::new(300.0, 300.0);
|
||||
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
|
||||
let back = kurbo::Vec2::new(-100.0, 0.0);
|
||||
let legs = per_leg as f64;
|
||||
let mut pts: Vec<Point> = (0..per_leg).map(|k| v + back * (1.0 - k as f64 / legs)).collect();
|
||||
pts.extend((0..=per_leg).map(|k| v + d * (100.0 * k as f64 / legs)));
|
||||
let anchors: Vec<_> = pts.iter().map(|p| json!({"x": p.x, "y": p.y})).collect();
|
||||
let id = smooth_test_path(s, &anchors, false);
|
||||
let mut drag: Vec<Point> = (0..8).map(|k| v - kurbo::Vec2::new(40.0 - 5.0 * f64::from(k), 0.0)).collect();
|
||||
drag.extend((0..=8).map(|k| v + d * (5.0 * f64::from(k))));
|
||||
(id, v, drag)
|
||||
}
|
||||
|
||||
/// A Smooth drag over a corner keeps it where the path turns there by more than 57.5° (an angle
|
||||
/// inside the corner under 122.5°), whether each leg is one segment or ten and at any Fidelity. A
|
||||
/// wider corner is not kept: when the drag changes the path, no anchor is left turning as sharply
|
||||
/// there, and a leg of ten segments at the default Fidelity always rounds it.
|
||||
#[test]
|
||||
fn the_smooth_tool_keeps_corners_sharper_than_its_threshold() {
|
||||
let mut wrong = vec![];
|
||||
for inside in [30.0f64, 60.0, 90.0, 120.0, 122.4, 122.6, 125.0, 135.0, 150.0, 170.0] {
|
||||
let kept = inside < 122.5;
|
||||
for per_leg in [1, 10] {
|
||||
for fidelity in [0.5, 2.5, 10.0] {
|
||||
let mut s = session();
|
||||
let (id, v, drag) = smooth_corner_path(&mut s, inside, per_leg);
|
||||
let before = path(&s, id);
|
||||
let pts: Vec<_> = drag.iter().map(|p| json!([p.x, p.y])).collect();
|
||||
s.execute("path.smoothRegion", &json!({"points": pts, "radius": 12, "fidelity": fidelity})).unwrap();
|
||||
let p = path(&s, id);
|
||||
let turn = 180.0 - inside;
|
||||
let at = smooth_turn_at(&p.subpaths[0], v);
|
||||
let changed = p != before;
|
||||
let rounded = changed && at.is_none_or(|t| t < turn - 1.0);
|
||||
let right = if kept { at.is_some_and(|t| (t - turn).abs() < 0.01) } else { rounded || !changed && per_leg == 1 };
|
||||
let most = smooth_anchor_turns(&p.subpaths[0]).into_iter().fold(0.0, f64::max);
|
||||
if !right || !kept && most > 57.5 || !kept && per_leg == 10 && fidelity == 2.5 && !rounded {
|
||||
wrong.push(format!(
|
||||
"{inside}° inside, {per_leg} segments a leg, Fidelity {fidelity}: turn {at:?}, changed {changed}, sharpest {most}°"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
|
||||
/// Three Smooth drags over a 90° and a 120° corner leave the corner where it was and as sharp, while
|
||||
/// the first one smooths a wobble on the same leg.
|
||||
#[test]
|
||||
fn smooth_drags_again_and_again_keep_a_corner_in_place() {
|
||||
for inside in [90.0f64, 120.0] {
|
||||
let mut s = session();
|
||||
let v = Point::new(300.0, 300.0);
|
||||
let d = kurbo::Vec2::from_angle(-(180.0 - inside).to_radians());
|
||||
let mut anchors: Vec<_> = (0..=18).map(|k| json!({"x": 180.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 301.5 } else { 300.0 }})).collect();
|
||||
anchors.push(json!({"x": v.x, "y": v.y}));
|
||||
let c = v + d * 100.0;
|
||||
anchors.push(json!({"x": c.x, "y": c.y}));
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let mut drag = smooth_drag_along(300.0, 175.0, 300.0);
|
||||
drag.extend((1..=8).map(|k| v + d * (5.0 * f64::from(k))));
|
||||
let mut before = path(&s, id);
|
||||
for pass in 1..=3 {
|
||||
smooth_pass(&mut s, &drag);
|
||||
let p = path(&s, id);
|
||||
let at = smooth_turn_at(&p.subpaths[0], v);
|
||||
assert!(at.is_some_and(|t| (t - (180.0 - inside)).abs() < 0.01), "{inside}°, pass {pass}: {at:?}");
|
||||
if pass == 1 {
|
||||
assert!(p.anchor_count() < before.anchor_count(), "{inside}°: the wobble is smoothed: {} anchors", p.anchor_count());
|
||||
assert!(total_turning(&p) < total_turning(&before), "{inside}°: the path turns less");
|
||||
}
|
||||
before = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A Smooth drag along a line with 1 pt jags, each turning by 90°, smooths them all: none is kept
|
||||
/// as a corner, since the line hardly turns over the tool's reach.
|
||||
#[test]
|
||||
fn smoothing_keeps_no_small_jag_as_a_corner() {
|
||||
let mut s = session();
|
||||
let anchors: Vec<_> = (0..=200).map(|i| json!({"x": 100.0 + f64::from(i), "y": if i % 2 == 1 { 201.0 } else { 200.0 }})).collect();
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
assert!(smooth_anchor_turns(&path(&s, id).subpaths[0]).iter().skip(1).take(199).all(|t| (t - 90.0).abs() < 1e-6));
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.5, 90.0, 310.0));
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.anchors.len() < 50, "the jags are smoothed: {} anchors", sp.anchors.len());
|
||||
let most = smooth_anchor_turns(sp).into_iter().fold(0.0, f64::max);
|
||||
assert!(most <= 57.5, "a jag is kept, turning {most}°");
|
||||
}
|
||||
|
||||
/// A closed path with 90° corners at (200, 200), where it starts, and at (400, 200), 75° ones at
|
||||
/// (400, 400) and (200, 400), a 30° bend at (300, 426.79), and a top side jagged by 1 pt. With
|
||||
/// `from_bend` the path starts at the bend instead.
|
||||
fn smooth_house(s: &mut Session, from_bend: bool) -> (NodeId, [Point; 4], Point) {
|
||||
let corners = [Point::new(200.0, 200.0), Point::new(400.0, 200.0), Point::new(400.0, 400.0), Point::new(200.0, 400.0)];
|
||||
let bend = Point::new(300.0, 400.0 + 100.0 / 75f64.to_radians().tan());
|
||||
let mut anchors = vec![json!({"x": 200.0, "y": 200.0})];
|
||||
anchors.extend((1..40).map(|k| json!({"x": 200.0 + 5.0 * f64::from(k), "y": if k % 2 == 1 { 201.0 } else { 200.0 }})));
|
||||
for p in [corners[1], corners[2], bend, corners[3]] {
|
||||
anchors.push(json!({"x": p.x, "y": p.y}));
|
||||
}
|
||||
if from_bend {
|
||||
let at_bend = anchors.len() - 2;
|
||||
anchors.rotate_left(at_bend);
|
||||
}
|
||||
(smooth_test_path(s, &anchors, true), corners, bend)
|
||||
}
|
||||
|
||||
/// A Smooth drag round the whole of a closed path, and one along its top side only, keep its corners,
|
||||
/// the one where the path starts included, and smooth the jags; the path still starts where it did,
|
||||
/// at a corner or at the bend, which the refit keeps, and the bend out of the second drag's reach
|
||||
/// stays as it was.
|
||||
#[test]
|
||||
fn smoothing_a_closed_path_keeps_its_corners() {
|
||||
let turns: Vec<f64> = {
|
||||
let mut s = session();
|
||||
let (id, corners, _) = smooth_house(&mut s, false);
|
||||
corners.iter().map(|c| smooth_turn_at(&path(&s, id).subpaths[0], *c).unwrap()).collect()
|
||||
};
|
||||
for from_bend in [false, true] {
|
||||
let mut s = session();
|
||||
let (id, corners, bend) = smooth_house(&mut s, from_bend);
|
||||
let before = path(&s, id);
|
||||
let mut ring: Vec<Point> = vec![];
|
||||
for (a, b) in [(corners[0], corners[1]), (corners[1], corners[2]), (corners[2], bend), (bend, corners[3]), (corners[3], corners[0])] {
|
||||
let n = (a.distance(b) / 5.0).ceil();
|
||||
ring.extend((0..n as usize).map(|k| a.lerp(b, k as f64 / n)));
|
||||
}
|
||||
ring.push(corners[0]);
|
||||
smooth_pass(&mut s, &ring);
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "{} anchors", sp.anchors.len());
|
||||
assert_eq!(sp.anchors[0].p, before.subpaths[0].anchors[0].p, "the path still starts where it did");
|
||||
for (c, t) in corners.iter().zip(&turns) {
|
||||
let at = smooth_turn_at(sp, *c);
|
||||
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "the corner at {c:?} turning {t}° is kept: {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
let mut s = session();
|
||||
let (id, corners, bend) = smooth_house(&mut s, false);
|
||||
let before = path(&s, id);
|
||||
smooth_pass(&mut s, &smooth_drag_along(200.0, 190.0, 410.0));
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert!(sp.closed && sp.anchors.len() < before.subpaths[0].anchors.len(), "the top is smoothed: {} anchors", sp.anchors.len());
|
||||
for (c, t) in corners.iter().zip(&turns) {
|
||||
let at = smooth_turn_at(sp, *c);
|
||||
assert!(at.is_some_and(|a| (a - t).abs() < 0.01), "part of the path: the corner at {c:?} turning {t}° is kept: {at:?}");
|
||||
}
|
||||
assert!(smooth_turn_at(sp, bend).is_some_and(|t| (t - 30.0).abs() < 0.01), "the bend out of reach stays");
|
||||
}
|
||||
|
||||
/// A Smooth drag over an anchor whose curved legs meet at a corner keeps the corner, taking the turn
|
||||
/// from the directions the curves arrive and leave along, a handle of no length included.
|
||||
#[test]
|
||||
fn smoothing_keeps_a_corner_between_curves() {
|
||||
use kurbo::ParamCurve;
|
||||
let v = Point::new(200.0, 300.0);
|
||||
let with_in = json!({"x": 200.0, "y": 300.0, "in": [170.0, 270.0], "out": [230.0, 270.0]});
|
||||
let no_in = json!({"x": 200.0, "y": 300.0, "out": [230.0, 270.0]});
|
||||
for (corner, first_out) in [(with_in, [130.0, 260.0]), (no_in, [170.0, 230.0])] {
|
||||
let mut s = session();
|
||||
let anchors = [json!({"x": 100.0, "y": 300.0, "out": first_out}), corner, json!({"x": 300.0, "y": 300.0, "in": [270.0, 260.0]})];
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let before = path(&s, id);
|
||||
let turn = smooth_turn_at(&before.subpaths[0], v).unwrap();
|
||||
assert!(turn > 80.0, "{turn}");
|
||||
let (a, b) = (before.subpaths[0].segment(0), before.subpaths[0].segment(1));
|
||||
let mut drag: Vec<Point> = (0..=10).map(|k| a.eval(0.6 + 0.04 * f64::from(k))).collect();
|
||||
drag.extend((1..=10).map(|k| b.eval(0.04 * f64::from(k))));
|
||||
smooth_pass(&mut s, &drag);
|
||||
let at = smooth_turn_at(&path(&s, id).subpaths[0], v);
|
||||
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° is kept: {at:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A corner at the first anchor of the stretch a drag smooths, just out of its reach, keeps its
|
||||
/// place and its turn.
|
||||
#[test]
|
||||
fn a_corner_at_the_end_of_the_smoothed_stretch_stays() {
|
||||
let mut s = session();
|
||||
let mut anchors = vec![json!({"x": 100.0, "y": 300.0}), json!({"x": 200.0, "y": 300.0})];
|
||||
anchors.extend((1..=20).map(|k| json!({"x": if k % 2 == 1 { 201.0 } else { 200.0 }, "y": 300.0 - 5.0 * f64::from(k)})));
|
||||
let id = smooth_test_path(&mut s, &anchors, false);
|
||||
let before = path(&s, id);
|
||||
let turn = smooth_turn_at(&before.subpaths[0], Point::new(200.0, 300.0)).unwrap();
|
||||
let drag: Vec<Point> = (0..=19).map(|k| Point::new(200.0, 287.0 - 5.0 * f64::from(k))).collect();
|
||||
smooth_pass(&mut s, &drag);
|
||||
let p = path(&s, id);
|
||||
let sp = &p.subpaths[0];
|
||||
assert_ne!(p, before, "the jagged leg is smoothed");
|
||||
assert_eq!(sp.anchors[0], before.subpaths[0].anchors[0]);
|
||||
let at = smooth_turn_at(sp, Point::new(200.0, 300.0));
|
||||
assert!(at.is_some_and(|t| (t - turn).abs() < 0.01), "the corner turning {turn}° stays: {at:?}");
|
||||
}
|
||||
|
||||
/// A Smooth drag over corners it keeps, with nothing else to smooth, changes nothing and adds no
|
||||
/// undo step: an open path's corner and a square's four corners.
|
||||
#[test]
|
||||
fn a_smooth_drag_that_only_meets_corners_adds_no_undo_step() {
|
||||
let mut s = session();
|
||||
let (id, v, drag) = smooth_corner_path(&mut s, 90.0, 1);
|
||||
let before = path(&s, id);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(smooth_pass_ids(&mut s, &drag), json!([]), "a corner at {v:?}");
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
|
||||
let square = [(100.0, 100.0), (200.0, 100.0), (200.0, 200.0), (100.0, 200.0)].map(|(x, y)| json!({"x": x, "y": y}));
|
||||
let id = smooth_test_path(&mut s, &square, true);
|
||||
let before = path(&s, id);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let mut ring = smooth_drag_along(100.0, 100.0, 200.0);
|
||||
ring.extend((1..=20).map(|k| Point::new(200.0, 100.0 + 5.0 * f64::from(k))));
|
||||
ring.extend(smooth_drag_along(200.0, 100.0, 200.0).into_iter().rev().skip(1));
|
||||
ring.extend((1..=20).map(|k| Point::new(100.0, 200.0 - 5.0 * f64::from(k))));
|
||||
assert_eq!(smooth_pass_ids(&mut s, &ring), json!([]), "a square");
|
||||
assert_eq!(path(&s, id), before);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_tool_joins_two_lines() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect};
|
||||
use vectorcraft_geom::{Affine, PathData, Point, Rect};
|
||||
|
||||
use crate::*;
|
||||
|
||||
@@ -320,16 +320,16 @@ fn global_edit_needs_one_supported_object() {
|
||||
let t = NodeId(s.execute("text.create", &json!({"x": 50, "y": 500, "text": "Hi"})).unwrap()["id"].as_u64().unwrap());
|
||||
sel(&mut s, &[t]);
|
||||
assert!(matches!(s.execute("select.globalEdit", &json!({})), Err(EngineError::Disabled(..))), "type");
|
||||
let lone = rect(&mut s, 0.0, 500.0, 30.0, 70.0);
|
||||
// A copy turned a quarter turn (70 × 30) is not taken: rotated copies are not matched, and the
|
||||
// message says so.
|
||||
let turned = rect(&mut s, 200.0, 500.0, 30.0, 70.0);
|
||||
sel(&mut s, &[turned]);
|
||||
s.execute("object.rotate", &json!({"angle": 90})).unwrap();
|
||||
// A reflected copy is not taken, and the message says so.
|
||||
let lone = kite(&mut s, 0.0, 500.0, 1.0);
|
||||
let mirrored = kite(&mut s, 200.0, 500.0, 1.0);
|
||||
sel(&mut s, &[mirrored]);
|
||||
s.execute("object.reflect", &json!({"axis": "vertical"})).unwrap();
|
||||
sel(&mut s, &[lone]);
|
||||
let e = s.execute("select.globalEdit", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("no similar objects") && e.contains("rotated"), "{e}");
|
||||
assert!(e.contains("no similar objects") && e.contains("reflected copies are not matched") && !e.contains("rotated"), "{e}");
|
||||
assert!(s.doc().unwrap().global_edit.is_none());
|
||||
assert!(crate::cmd::globaledit::highlights(&s).is_empty(), "and it is not highlighted");
|
||||
// A group holding type is not offered, though another such group exists.
|
||||
let group = |s: &mut Session, x: f64| {
|
||||
let p = rect(s, x, 100.0, 10.0, 10.0);
|
||||
@@ -851,3 +851,159 @@ fn groups_nested_as_deep_as_a_document_allows_are_edited_together() {
|
||||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||||
assert_eq!(fill(&s, other_inner), "#12ab34");
|
||||
}
|
||||
|
||||
/// A four-sided path with no symmetry, `k` times the size, its box's top left near (`x`, `y`): a
|
||||
/// 30 × 70 rectangle whose first anchor is moved.
|
||||
fn kite(s: &mut Session, x: f64, y: f64, k: f64) -> NodeId {
|
||||
let id = rect(s, x, y, 30.0 * k, 70.0 * k);
|
||||
s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 10.0 * k, "dy": -20.0 * k})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Rotate `id` by `deg` (counter-clockwise) about the centre of its box → that rotation.
|
||||
fn rotate(s: &mut Session, id: NodeId, deg: f64) -> Affine {
|
||||
let c = bounds(s, id).center();
|
||||
sel(s, &[id]);
|
||||
s.execute("object.rotate", &json!({"angle": deg})).unwrap();
|
||||
Affine::rotate_about(-deg.to_radians(), c)
|
||||
}
|
||||
|
||||
/// A kite at the origin side of the page, a copy of it turned a quarter turn, and a copy at half
|
||||
/// the size turned by 30° → the three and the transforms that lay the first on the other two.
|
||||
fn turned_kites(s: &mut Session) -> ([NodeId; 3], [Affine; 2]) {
|
||||
let a = kite(s, 0.0, 100.0, 1.0);
|
||||
let (b, c) = (kite(s, 200.0, 100.0, 1.0), kite(s, 400.0, 100.0, 0.5));
|
||||
let quarter = rotate(s, b, 90.0) * Affine::translate((200.0, 0.0));
|
||||
let small = rotate(s, c, 30.0) * Affine::translate((400.0, 100.0)) * Affine::scale(0.5) * Affine::translate((0.0, -100.0));
|
||||
assert!(placed(s, a, b, quarter) && placed(s, a, c, small), "the copies as made");
|
||||
([a, b, c], [quarter, small])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_global_edit_takes_rotated_copies_and_an_edit_reaches_each_turned_by_its_angle() {
|
||||
let mut s = session();
|
||||
let ([a, b, c], [quarter, small]) = turned_kites(&mut s);
|
||||
sel(&mut s, &[a]);
|
||||
assert_eq!(crate::cmd::globaledit::highlights(&s).len(), 2, "both are highlighted before the start");
|
||||
assert_eq!(s.execute("select.globalEditOptions", &json!({})).unwrap()["count"], 3);
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c]);
|
||||
let before = (*s.doc().unwrap().doc).clone();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// An anchor moved on the selected kite moves on each copy along the copy's own directions.
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 1]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 12, "dy": 5})).unwrap();
|
||||
assert!(placed(&s, a, b, quarter), "the quarter-turned copy");
|
||||
assert!(placed(&s, a, c, small), "the smaller copy turned by 30°");
|
||||
assert!(!placed(&s, a, b, Affine::translate((200.0, 0.0))), "the copy is still turned");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
crate::tests_large_edits::undo_gives_back(&mut s, &before);
|
||||
// A move of the whole kite to the right moves the quarter-turned copy up (counter-clockwise).
|
||||
sel(&mut s, &[a]);
|
||||
let was = bounds(&s, b);
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
let now = bounds(&s, b);
|
||||
assert!((now.x0 - was.x0).abs() < 1e-6 && (now.y0 - (was.y0 - 10.0)).abs() < 1e-6, "{was:?} → {now:?}");
|
||||
assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small), "each copy lies on the kite as before");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_copy_leads_an_edit_as_any_other_member_does() {
|
||||
let mut s = session();
|
||||
let ([a, b, c], [quarter, small]) = turned_kites(&mut s);
|
||||
// Started on the turned copy, and edited from the smaller one.
|
||||
assert_eq!(start(&mut s, b, json!({})), [b, a, c]);
|
||||
s.execute("select.anchors", &json!({"id": c.0, "anchors": [[0, 2]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 3, "dy": -4})).unwrap();
|
||||
assert!(placed(&s, a, b, quarter) && placed(&s, a, c, small));
|
||||
// A fill reaches the turned copies as it is.
|
||||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||||
assert_eq!((fill(&s, a), fill(&s, b)), ("#12ab34".to_string(), "#12ab34".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_takes_a_rotated_copy_of_the_same_size_and_other_shapes_stay_out() {
|
||||
let mut s = session();
|
||||
let ([a, b, c], _) = turned_kites(&mut s);
|
||||
// A quarter-turned copy is 70 × 30 by its box: it has the size of the 30 × 70 kite all the same.
|
||||
assert_eq!(start(&mut s, a, json!({"size": true})), [a, b], "not the half-size copy {c}");
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
// A copy turned and then stretched, and one whose anchor was moved, are other shapes.
|
||||
let stretched = kite(&mut s, 0.0, 300.0, 1.0);
|
||||
rotate(&mut s, stretched, 30.0);
|
||||
s.execute("object.scale", &json!({"sx": 150, "sy": 100})).unwrap();
|
||||
let bent = kite(&mut s, 200.0, 300.0, 1.0);
|
||||
rotate(&mut s, bent, 45.0);
|
||||
s.execute("select.anchors", &json!({"id": bent.0, "anchors": [[0, 2]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 1, "dy": 0})).unwrap();
|
||||
// A copy turned by half a turn and by a full turn in two steps.
|
||||
let half = kite(&mut s, 400.0, 300.0, 1.0);
|
||||
rotate(&mut s, half, 180.0);
|
||||
let full = kite(&mut s, 600.0, 300.0, 1.0);
|
||||
rotate(&mut s, full, 135.0);
|
||||
rotate(&mut s, full, 225.0);
|
||||
assert_eq!(start(&mut s, a, json!({})), [a, b, c, half, full], "not the stretched {stretched} nor the bent {bent}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_square_whose_anchors_were_turned_is_a_rotated_copy() {
|
||||
let mut s = session();
|
||||
let [a, b, ..] = squares(&mut s);
|
||||
let turn = rotate(&mut s, b, 90.0) * Affine::translate((300.0, 0.0));
|
||||
assert_eq!(start(&mut s, a, json!({})).len(), 3, "the turned square is still taken, with the small one");
|
||||
// The first anchor of the turned square lies at another corner, and that is the one that moves.
|
||||
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]]})).unwrap();
|
||||
s.execute("path.moveAnchors", &json!({"dx": 10, "dy": 30})).unwrap();
|
||||
assert!(placed(&s, a, b, turn));
|
||||
assert!(!placed(&s, a, b, Affine::translate((300.0, 0.0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_groups_are_like_the_group_and_an_edit_inside_one_reaches_the_others_turned() {
|
||||
let mut s = session();
|
||||
let (g, [sq, dot, _]) = card(&mut s, 0.0, 1.0);
|
||||
let (copy, [csq, cdot, cbar]) = card(&mut s, 300.0, 1.0);
|
||||
let turn = rotate(&mut s, copy, 90.0) * Affine::translate((300.0, 0.0));
|
||||
// The same three objects, each turned in place: not the group turned as a whole.
|
||||
let (apart, objects) = card(&mut s, 500.0, 1.0);
|
||||
for id in objects {
|
||||
rotate(&mut s, id, 90.0);
|
||||
}
|
||||
assert_eq!(start(&mut s, g, json!({})), [g, copy], "not the group {apart} whose objects were turned one by one");
|
||||
sel(&mut s, &[dot]);
|
||||
s.execute("paint.setFill", &json!({"color": "#12ab34"})).unwrap();
|
||||
assert_eq!(fill(&s, cdot), "#12ab34");
|
||||
sel(&mut s, &[sq]);
|
||||
s.execute("object.setBounds", &json!({"height": 20, "reference": 0})).unwrap();
|
||||
assert!(placed(&s, sq, csq, turn), "the square in the turned group is halved along the group's own direction");
|
||||
assert_eq!(kids(&s, copy), [csq, cdot, cbar], "each object keeps its id");
|
||||
s.execute("select.globalEdit", &json!({})).unwrap();
|
||||
// An object inside one group takes the object at its place in the turned group, as the
|
||||
// objects of rotated copies of a group are edited together.
|
||||
assert_eq!(start(&mut s, sq, json!({})), [sq, csq]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rotated_symbol_instance_is_taken_and_a_reflected_one_is_not() {
|
||||
let mut s = session();
|
||||
let r = rect(&mut s, 0.0, 0.0, 40.0, 40.0);
|
||||
let first = NodeId(s.execute("symbol.new", &json!({"ids": [r.0], "name": "Tile"})).unwrap()["id"].as_u64().unwrap());
|
||||
let place =
|
||||
|s: &mut Session, x: f64| NodeId(s.execute("symbol.place", &json!({"name": "Tile", "x": x, "y": 300})).unwrap()["id"].as_u64().unwrap());
|
||||
let (turned, mirrored) = (place(&mut s, 200.0), place(&mut s, 400.0));
|
||||
sel(&mut s, &[turned]);
|
||||
s.execute("object.rotate", &json!({"angle": 30, "origin": [220, 320]})).unwrap();
|
||||
sel(&mut s, &[mirrored]);
|
||||
s.execute("object.reflect", &json!({"axis": "vertical", "origin": [420, 320]})).unwrap();
|
||||
assert_eq!(start(&mut s, first, json!({})), [first, turned], "not the reflected instance {mirrored}");
|
||||
let xf = |s: &Session, id: NodeId| match node(s, id).kind {
|
||||
NodeKind::SymbolInstance { xf, .. } => xf,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let was = xf(&s, turned);
|
||||
s.execute("object.move", &json!({"dx": 10, "dy": 0})).unwrap();
|
||||
// The instance turned by 30° counter-clockwise moves along its own direction.
|
||||
let moved = Affine::rotate((-30f64).to_radians()) * Point::new(10.0, 0.0);
|
||||
let (now, want) = (xf(&s, turned).as_coeffs(), (Affine::translate(moved.to_vec2()) * was).as_coeffs());
|
||||
assert!(now.iter().zip(want).all(|(p, q)| (p - q).abs() < 1e-6), "{now:?} vs {want:?}");
|
||||
}
|
||||
|
||||
@@ -79,18 +79,29 @@ fn the_source_stays_as_it_is() {
|
||||
assert!(s.doc().unwrap().is_dirty(), "the new document is unsaved");
|
||||
}
|
||||
|
||||
/// An index that names no state, or is not a whole number, is an error and changes nothing: no
|
||||
/// document, no step and no Untitled number is used up.
|
||||
/// An index that names no state (`no history state N`), or is not a whole number ("`index` must be
|
||||
/// a whole number"), is an error and changes nothing: no document, no step and no Untitled number
|
||||
/// is used up.
|
||||
#[test]
|
||||
fn a_bad_index_is_an_error_and_changes_nothing() {
|
||||
let mut s = three_rectangles_one_undone();
|
||||
let source = s.doc().unwrap().doc.clone();
|
||||
let untitled = s.peek_untitled();
|
||||
let bad: [Value; 8] = [json!(4), json!(99), json!(-1), json!(1.5), json!("1"), json!(true), json!([1]), json!(u64::MAX)];
|
||||
for index in bad {
|
||||
let bad: [(Value, bool); 8] = [
|
||||
(json!(4), true),
|
||||
(json!(99), true),
|
||||
(json!(u64::MAX), true),
|
||||
(json!(-1), false),
|
||||
(json!(1.5), false),
|
||||
(json!("1"), false),
|
||||
(json!(true), false),
|
||||
(json!([1]), false),
|
||||
];
|
||||
for (index, whole) in bad {
|
||||
let e = s.execute(CMD, &json!({ "index": index })).unwrap_err();
|
||||
assert!(matches!(e, EngineError::BadParams { .. }), "{index}: {e}");
|
||||
assert!(e.to_string().contains(&format!("no history state {index}")), "{e}");
|
||||
let want = if whole { format!("no history state {index}") } else { "`index` must be a whole number".to_string() };
|
||||
assert_eq!(e.to_string(), format!("invalid parameters for `{CMD}`: {want}"), "{index}");
|
||||
assert_eq!(s.documents().len(), 1, "{index}");
|
||||
assert_eq!(steps(&s), (2, 1), "{index}");
|
||||
assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &source), "{index}");
|
||||
|
||||
@@ -611,15 +611,18 @@ fn edit_copy_and_cut_read_the_recorded_artboards() {
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
assert!(matches!(s.execute(id, &json!({})), Err(crate::EngineError::Disabled(..))), "{id}");
|
||||
let before = documents(&s);
|
||||
for bad in [
|
||||
json!({"selectedArtboards": [9]}),
|
||||
json!({"selectedArtboards": []}),
|
||||
json!({"selectedArtboards": "1"}),
|
||||
json!({"selectedArtboards": null}),
|
||||
json!({"selectedArtboards": [1], "art": "yes"}),
|
||||
for (bad, want) in [
|
||||
(json!({"selectedArtboards": [9]}), "no such artboard"),
|
||||
(json!({"selectedArtboards": []}), "`selectedArtboards` must be a non-empty list of artboard indices"),
|
||||
(json!({"selectedArtboards": "1"}), "`selectedArtboards` must be a list of artboard indices"),
|
||||
(json!({"selectedArtboards": null}), "`selectedArtboards` must be a list of artboard indices"),
|
||||
(json!({"selectedArtboards": [1, "0"]}), "`selectedArtboards` must contain whole numbers"),
|
||||
(json!({"selectedArtboards": [1, -1]}), "`selectedArtboards` must contain whole numbers"),
|
||||
(json!({"selectedArtboards": [1], "art": "yes"}), "`art` must be true or false"),
|
||||
] {
|
||||
let err = s.execute(id, &bad).unwrap_err().to_string();
|
||||
assert!(err.contains(id) || err.contains("no such artboard"), "{id} {bad}: {err}");
|
||||
assert!(err.ends_with(want), "{id} {bad}: {err}");
|
||||
assert!(err.contains(id) || want == "no such artboard", "{id} {bad}: {err}");
|
||||
assert_eq!(documents(&s), before, "{id} {bad}");
|
||||
assert!(s.clipboard.artboards.is_empty(), "{id} {bad}");
|
||||
}
|
||||
|
||||
@@ -1979,9 +1979,10 @@ fn fit_to_artwork_bounds_fits_each_artboard_to_its_own_art() {
|
||||
assert_eq!(boards(&s), [Rect::new(100.0, 100.0, 1050.0, 280.0), was[1], was[2]], "the first artboard by default");
|
||||
}
|
||||
|
||||
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on an `index` that names
|
||||
/// no artboard (past the last, negative, fractional, a string or null), and the artboards stay as
|
||||
/// they were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
|
||||
/// Artboard Options and Fit to Selected Art fail with `no such artboard` on a whole-number `index`
|
||||
/// past the last artboard, with "`index` must be a whole number" on one that is negative,
|
||||
/// fractional or a string, and with `missing index` on a null one, and the artboards stay as they
|
||||
/// were. Artboard Options needs an `index`; Fit to Selected Art without one fits the first
|
||||
/// artboard.
|
||||
#[test]
|
||||
fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboard() {
|
||||
@@ -1992,9 +1993,16 @@ fn artboard_options_and_fit_to_selected_art_refuse_an_index_that_names_no_artboa
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
let boards = |s: &Session| s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect::<Vec<_>>();
|
||||
let (was, n) = (boards(&s), undo_len(&s));
|
||||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), Value::Null] {
|
||||
for (index, error) in [
|
||||
(json!(99), None),
|
||||
(json!(-1), Some("`index` must be a whole number")),
|
||||
(json!(1.5), Some("`index` must be a whole number")),
|
||||
(json!("1"), Some("`index` must be a whole number")),
|
||||
(Value::Null, Some("missing index")),
|
||||
] {
|
||||
for (cmd, p) in [("artboard.setProps", json!({"index": index, "width": 300})), ("artboard.fitToSelection", json!({"index": index}))] {
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||||
let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
let e = s.execute("artboard.setProps", &json!({"width": 300})).unwrap_err().to_string();
|
||||
|
||||
@@ -257,6 +257,27 @@ fn shadows_under_a_groups_or_an_objects_transform_expand_appearance_as_the_canva
|
||||
}
|
||||
}
|
||||
|
||||
/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself,
|
||||
/// scaled with it, and Expand Appearance keeps those shadows: the square (20..40) has its shadow
|
||||
/// 20 pt to its right, and its copy scaled 200 % and moved 80 pt down (10..50 across, 90..130
|
||||
/// down) has its own 40 pt to its right.
|
||||
#[test]
|
||||
fn each_copys_shadow_expands_as_the_canvas_draws_it() {
|
||||
let mut s = session();
|
||||
run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 20, "height": 20}));
|
||||
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
|
||||
run(&mut s, "paint.setStroke", json!({"none": true}));
|
||||
let shadow = json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"});
|
||||
run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "params": shadow}));
|
||||
run(&mut s, "effect.apply", json!({"effect": "distort.transform", "params": {"scaleH": 200, "scaleV": 200, "moveV": 80, "copies": 1}}));
|
||||
let before = render(&s);
|
||||
assert_eq!(before.pixel(70, 110), [0, 255, 0, 255], "the copy's shadow");
|
||||
run(&mut s, "effect.expandAppearance", json!({}));
|
||||
let after = render(&s);
|
||||
let diff = differing(&before, &after, 64);
|
||||
assert_eq!(diff, 0, "{diff} pixels differ");
|
||||
}
|
||||
|
||||
/// A group's Transform effect runs after its member's own Offset Path in Expand Appearance as on
|
||||
/// the canvas: the 100 pt square offset to 120 pt and scaled by half expands 60 pt wide.
|
||||
#[test]
|
||||
|
||||
@@ -492,9 +492,11 @@ fn new_and_renamed_artboards_trim_their_names() {
|
||||
assert_eq!(names(&s), ["Back", "Artboard 2", "Artboard 3", "Artboard 4"]);
|
||||
}
|
||||
|
||||
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on an
|
||||
/// `index` that names no artboard (past the last, negative, fractional, a string or null), and the
|
||||
/// artboards, the art and the clipboard stay as they were.
|
||||
/// Duplicate, Copy and Cut Artboard and Move Artboard Up/Down fail with `no such artboard` on a
|
||||
/// whole-number `index` past the last artboard, with "`index` must be a whole number" on one that
|
||||
/// is negative, fractional, a string, true or a list, and with `missing index` on a null one,
|
||||
/// instead of acting on the Artboard tool's active artboard. The artboards, the art and the
|
||||
/// clipboard stay as they were.
|
||||
#[test]
|
||||
fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
let mut s = session();
|
||||
@@ -502,10 +504,22 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.set_tool_options(Some("artboard"), json!({"active": 1}).as_object().unwrap());
|
||||
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
|
||||
for index in [json!(99), json!(-1), json!(1.5), json!("1"), serde_json::Value::Null] {
|
||||
let not_whole = Some("`index` must be a whole number");
|
||||
let cases = [
|
||||
(json!(99), None),
|
||||
(json!(u64::MAX), None),
|
||||
(json!(-1), not_whole),
|
||||
(json!(1.5), not_whole),
|
||||
(json!("1"), not_whole),
|
||||
(json!(true), not_whole),
|
||||
(json!([1]), not_whole),
|
||||
(serde_json::Value::Null, Some("missing index")),
|
||||
];
|
||||
for (index, error) in cases {
|
||||
for cmd in ["artboard.duplicate", "artboard.copy", "artboard.cut", "artboard.reorder"] {
|
||||
let p = if cmd == "artboard.reorder" { json!({"index": index, "to": 0}) } else { json!({"index": index}) };
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||||
let want = error.map_or_else(|| "no such artboard".to_string(), |e| format!("invalid parameters for `{cmd}`: {e}"));
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
@@ -513,6 +527,90 @@ fn artboard_copies_refuse_an_index_that_names_no_artboard() {
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||||
}
|
||||
|
||||
/// The artboard commands that take `indices` (and `selectedArtboards`, a replay of the journal)
|
||||
/// name the fault: a value that is not a list, an empty list and a list with an entry that is not a
|
||||
/// whole number each have their own message naming the key, and a whole number past the last
|
||||
/// artboard is `no such artboard`, also when another entry is malformed after it. The artboards,
|
||||
/// the art, the undo history and the clipboard stay as they were.
|
||||
#[test]
|
||||
fn artboard_lists_name_the_fault_and_the_key() {
|
||||
let mut s = session();
|
||||
rect(&mut s);
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
|
||||
let cmds = [
|
||||
("artboard.duplicate", json!({})),
|
||||
("artboard.copy", json!({})),
|
||||
("artboard.cut", json!({})),
|
||||
("artboard.rename", json!({"name": "A"})),
|
||||
("artboard.switchOrientation", json!({})),
|
||||
("artboard.delete", json!({})),
|
||||
("artboard.fitToArt", json!({})),
|
||||
("artboard.move", json!({"dx": 10, "dy": 0})),
|
||||
("artboard.align", json!({"horizontal": "left"})),
|
||||
];
|
||||
for key in ["indices", "selectedArtboards"] {
|
||||
let cases = [
|
||||
(json!(null), format!("`{key}` must be a list of artboard indices")),
|
||||
(json!(1), format!("`{key}` must be a list of artboard indices")),
|
||||
(json!("0,1"), format!("`{key}` must be a list of artboard indices")),
|
||||
(json!([]), format!("`{key}` must be a non-empty list of artboard indices")),
|
||||
(json!([0, -1]), format!("`{key}` must contain whole numbers")),
|
||||
(json!([0, 1.5]), format!("`{key}` must contain whole numbers")),
|
||||
(json!(["1"]), format!("`{key}` must contain whole numbers")),
|
||||
(json!([null]), format!("`{key}` must contain whole numbers")),
|
||||
(json!([9, "1"]), format!("`{key}` must contain whole numbers")),
|
||||
(json!([0, 9]), "no such artboard".to_string()),
|
||||
];
|
||||
for (cmd, base) in &cmds {
|
||||
if key == "selectedArtboards" && matches!(*cmd, "artboard.delete" | "artboard.fitToArt" | "artboard.move") {
|
||||
continue;
|
||||
}
|
||||
for (value, want) in &cases {
|
||||
let mut p = base.clone();
|
||||
p[key] = value.clone();
|
||||
let want = if want.starts_with('`') { format!("invalid parameters for `{cmd}`: {want}") } else { want.clone() };
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!((boards(&s), st.doc.layers[0].children().unwrap().len(), st.history.undo.len()), (was, 1, n));
|
||||
assert!(s.clipboard.nodes.is_empty() && s.clipboard.artboards.is_empty(), "nothing copied");
|
||||
}
|
||||
|
||||
/// Align, Distribute and Distribute Spacing for artboards name the key that holds a malformed
|
||||
/// artboard to align to (`artboard` or `activeArtboard`), not `index`; a null one is left out, and
|
||||
/// a whole number past the last artboard is `no such artboard`.
|
||||
#[test]
|
||||
fn artboard_align_names_the_key_of_a_malformed_reference() {
|
||||
let mut s = session();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
s.execute("artboard.new", &json!({})).unwrap();
|
||||
let cmds = [
|
||||
("artboard.align", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
|
||||
("artboard.distribute", json!({"indices": [0, 1, 2], "horizontal": "left", "to": "artboard"})),
|
||||
("artboard.distributeSpacing", json!({"indices": [0, 1, 2], "to": "artboard"})),
|
||||
];
|
||||
for (cmd, base) in &cmds {
|
||||
for key in ["artboard", "activeArtboard"] {
|
||||
let (was, n) = (boards(&s), s.doc().unwrap().history.undo.len());
|
||||
for value in [json!(-1), json!(1.5), json!("1"), json!(true)] {
|
||||
let mut p = base.clone();
|
||||
p[key] = value;
|
||||
let want = format!("invalid parameters for `{cmd}`: `{key}` must be a whole number");
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), want, "{cmd} {p}");
|
||||
}
|
||||
let mut p = base.clone();
|
||||
p[key] = json!(9);
|
||||
assert_eq!(s.execute(cmd, &p).unwrap_err().to_string(), "no such artboard", "{cmd} {p}");
|
||||
assert_eq!((boards(&s), s.doc().unwrap().history.undo.len()), (was, n), "{cmd} {key}: nothing moved");
|
||||
p[key] = json!(null);
|
||||
s.execute(cmd, &p).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Artboard Options, Move Artboard, Duplicate Artboards, Copy Artboard and Cut Artboard refuse an
|
||||
/// option given as text or as another value that is not true or false, such as "true", and
|
||||
/// Artboard Options and Move Artboard a position, size or move given as text, such as "10". The
|
||||
|
||||
@@ -1978,6 +1978,26 @@ fn highlight_alternate_glyphs_preference_is_off_by_default() {
|
||||
assert!(!s.prefs.highlight_alternate_glyphs);
|
||||
}
|
||||
|
||||
/// Preferences › Type › Show Character Alternates: on by default, listed in the Type category,
|
||||
/// saved with the other preferences, and a value that is not true or false is refused.
|
||||
#[test]
|
||||
fn show_character_alternates_preference_is_on_by_default_and_saved() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.prefs.show_character_alternates, "on by default");
|
||||
let spec = crate::cmd::prefscmds::PREF_SPECS.iter().find(|p| p.key == "showCharacterAlternates").expect("listed");
|
||||
assert_eq!((spec.category, spec.label), ("Type", "Show Character Alternates"));
|
||||
assert_eq!(s.execute("prefs.get", &json!({"key": "showCharacterAlternates"})).unwrap(), json!(true));
|
||||
set_pref(&mut s, "showCharacterAlternates", json!(false));
|
||||
assert!(!s.prefs.show_character_alternates);
|
||||
assert!(s.execute("prefs.set", &json!({"key": "showCharacterAlternates", "value": "yes"})).is_err(), "not a boolean");
|
||||
assert!(!s.prefs.show_character_alternates, "a refused value changes nothing");
|
||||
let saved: Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(!saved.show_character_alternates);
|
||||
// Preferences saved before the key existed read it as on.
|
||||
let old: Prefs = serde_json::from_value(json!({"highlightAlternateGlyphs": true})).unwrap();
|
||||
assert!(old.show_character_alternates);
|
||||
}
|
||||
|
||||
/// Preferences › Type › Highlight Substituted Fonts: on by default, listed in the Type category,
|
||||
/// and saved with the other preferences.
|
||||
/// <https://helpx.adobe.com/illustrator/desktop/design-with-text/fonts-and-scripts/edit-text-without-replacing-missing-fonts.html>
|
||||
|
||||
@@ -4495,8 +4495,8 @@ fn type_the_data_cant_hold_is_written_as_its_outlines() {
|
||||
rings.subpaths.extend(shapes::rectangle(Rect::new(120.0, 30.0, 170.0, 70.0)).subpaths);
|
||||
holed.kind = vectorcraft_doc::TextKind::Area { frame: rings };
|
||||
holed.xf = Affine::IDENTITY;
|
||||
// A carriage return in the characters: a story ends its paragraphs with it. Area type that
|
||||
// flows around a wrap object: the wrap object's settings aren't written.
|
||||
// A carriage return in the characters: a story ends its paragraphs with it. Area type with
|
||||
// wrap shapes and no wrap object: the text document doesn't hold the shapes.
|
||||
let returned = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::new(10.0, 90.0), "A\rB", style(10.0, Color::BLACK));
|
||||
let mut wrapped = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "Wrapped round", style(10.0, Color::BLACK));
|
||||
wrapped.kind = vectorcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)) };
|
||||
@@ -10584,6 +10584,96 @@ fn text_wrap_options_of_type_and_envelopes_are_read_back() {
|
||||
same_wraps(&wraps(top), &[(Some("Bent".into()), 2.5, true)]);
|
||||
}
|
||||
|
||||
/// Type written as its outlines wraps area type below it by its bounds, as it did before saving,
|
||||
/// not by its glyphs: its text wrap options go on an unpainted rectangle of its bounds, the last
|
||||
/// member of the outlines' group, which keeps the type's name and has no options of its own.
|
||||
/// Mirrored point type with a positive offset, a negative offset with Invert Wrap, hidden and
|
||||
/// locked, type of only white space, and no room for the rectangle within the object limit.
|
||||
#[test]
|
||||
fn type_written_as_outlines_keeps_wrapping_by_its_bounds() {
|
||||
use vectorcraft_doc::{TextKind, TextObject, TextRun};
|
||||
type Region = Vec<([f64; 4], f64, bool)>;
|
||||
// The wrap shapes of the area type, in its own space: each one's bounds and options.
|
||||
let region = |doc: &Document| -> Region {
|
||||
let mut doc = doc.clone();
|
||||
vectorcraft_doc::text::resolve_wrap(None, &mut doc);
|
||||
let top = doc.layers[0].children().unwrap();
|
||||
let Some(NodeKind::Text(t)) = top.iter().map(|n| &n.kind).find(|k| matches!(k, NodeKind::Text(t) if matches!(t.kind, TextKind::Area { .. })))
|
||||
else {
|
||||
panic!("no area type");
|
||||
};
|
||||
t.wrap
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let b = s.path.bounds().unwrap_or_default();
|
||||
([b.x0, b.y0, b.x1, b.y1], s.wrap.offset, s.wrap.invert)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let make = |text: &str, offset: f64, invert: bool, visible: bool, locked: bool| {
|
||||
let mut doc = Document::new(300.0, 200.0);
|
||||
let layer = doc.layers[0].id;
|
||||
let mut body = TextObject::point(vectorcraft_geom::Point::ZERO, "", style(10.0, Color::BLACK));
|
||||
body.runs = vec![TextRun::new("one two three four five six seven eight nine ten eleven twelve", style(10.0, Color::BLACK))];
|
||||
body.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(10.0, 10.0, 290.0, 190.0)) };
|
||||
body.xf = Affine::IDENTITY;
|
||||
let body = named(text_node(&mut doc, body), "Body");
|
||||
let mut label = TextObject::point(vectorcraft_geom::Point::ZERO, text, style(60.0, Color::BLACK));
|
||||
// Mirrored, so written as its outlines.
|
||||
label.xf = Affine::new([-1.0, 0.0, 0.0, 1.0, 150.0, 120.0]);
|
||||
let mut label = wrapped(named(text_node(&mut doc, label), "Label"), offset, invert);
|
||||
(label.visible, label.locked) = (visible, locked);
|
||||
for n in [body, label] {
|
||||
doc.insert(Some(layer), usize::MAX, n).unwrap();
|
||||
}
|
||||
doc
|
||||
};
|
||||
for (text, offset, invert, visible, locked) in
|
||||
[("Ill", 4.5, false, true, false), ("Ill", -2.0, true, true, false), ("Ill", 3.0, false, false, true), (" ", 3.0, false, true, false)]
|
||||
{
|
||||
let doc = make(text, offset, invert, visible, locked);
|
||||
let bounds = doc.layers[0].children().unwrap()[1].geometric_bounds();
|
||||
let text_out = program(&doc);
|
||||
let back = read_back(&doc);
|
||||
let (before, after) = (region(&doc), region(&back));
|
||||
let same = before.len() == after.len()
|
||||
&& before
|
||||
.iter()
|
||||
.zip(&after)
|
||||
.all(|(b, a)| b.0.iter().zip(a.0).all(|(x, y)| (x - y).abs() < 1e-3) && (b.1 - a.1).abs() < 1e-6 && b.2 == a.2);
|
||||
assert!(same, "{text:?}\n{before:?}\n{after:?}\n{text_out}");
|
||||
let top = back.layers[0].children().unwrap();
|
||||
let group = &top[1];
|
||||
assert!(matches!(group.kind, NodeKind::Group { clip: false, .. }), "{text_out}");
|
||||
assert_eq!((group.name.as_deref(), group.extras().wrap, group.visible, group.locked), (Some("Label"), None, visible, locked));
|
||||
if let Some(want) = bounds {
|
||||
assert_eq!(text_out.matches("(AI11TextWrapOffset)").count(), 1, "{text_out}");
|
||||
same_wraps(&wraps(top), &[(None, offset, invert)]);
|
||||
let last = group.children().unwrap().last().unwrap();
|
||||
assert!(matches!(last.kind, NodeKind::Path { guide: false, .. }) && last.appearance.items.is_empty(), "{text_out}");
|
||||
assert_eq!((last.visible, last.locked), (visible, locked));
|
||||
let b = last.geometric_bounds().unwrap();
|
||||
assert!([b.x0 - want.x0, b.y0 - want.y0, b.x1 - want.x1, b.y1 - want.y1].iter().all(|d| d.abs() < 1e-3), "{b:?} {want:?}");
|
||||
} else {
|
||||
assert!(wraps(top).is_empty(), "{text_out}");
|
||||
}
|
||||
}
|
||||
// The rectangle counts toward the object limit. Without room for it, the type's wrap options
|
||||
// are left out with the limit's warning, and the glyphs do not take them.
|
||||
let doc = make("Ill", 4.5, false, true, false);
|
||||
let read = crate::import::objects_read(&export_ai_editing(&doc, &opts()).unwrap().program).unwrap();
|
||||
let all = export_within(&doc, &opts(), read).unwrap();
|
||||
assert!(!all.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", all.warnings);
|
||||
let out = export_within(&doc, &opts(), read - 1).unwrap();
|
||||
assert!(out.warnings.iter().any(|w| w == writer::OBJECT_LIMIT), "{:?}", out.warnings);
|
||||
let text_out = String::from_utf8_lossy(&out.program).replace("\r\n", "\n");
|
||||
assert!(!text_out.contains("(AI11TextWrapOffset)"), "{text_out}");
|
||||
let (back, _) = crate::ai_alone(&out.program).unwrap();
|
||||
let top = back.layers[0].children().unwrap();
|
||||
assert!(wraps(top).is_empty(), "{text_out}");
|
||||
assert_eq!(top[1].name.as_deref(), Some("Label"), "{text_out}");
|
||||
}
|
||||
|
||||
/// A clipping path inside art cut to a clipping object is written as its fills and its strokes,
|
||||
/// two objects: its text wrap options are written once, whether it has both or only strokes, and
|
||||
/// with its strokes when the region outside leaves nothing of its fills.
|
||||
|
||||
@@ -9,7 +9,7 @@ use vectorcraft_color::swatch::{REGISTRATION, Swatch};
|
||||
use vectorcraft_color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Document, FillLayer, Knockout, LayerColor, Node, NodeId, NodeKind, OpacityMask, StrokeLayer, TextWrap};
|
||||
use vectorcraft_effects::stroke;
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath};
|
||||
use vectorcraft_geom::{Affine, FillRule, PathData, Shape, SubPath, shapes};
|
||||
use vectorcraft_pathops::BoolOp;
|
||||
|
||||
use crate::ai_ops::{self, unit};
|
||||
@@ -271,6 +271,9 @@ pub(super) struct Writer<'a> {
|
||||
/// brushed stroke's pieces) the art being written is in. Text wrap options are written only
|
||||
/// outside them, since only the document's own objects wrap type.
|
||||
drawn: u32,
|
||||
/// The type just written as outlines has its text wrap options on the rectangle of its bounds
|
||||
/// ([`Self::wrap_bounds`]), not in the group's dictionary.
|
||||
wrap_moved: bool,
|
||||
/// The brushes written ([`brushes`]).
|
||||
brush_set: brushes::Brushes,
|
||||
/// Brushes may be written; false on a large canvas, where they are left out.
|
||||
@@ -350,6 +353,7 @@ impl<'a> Writer<'a> {
|
||||
styles: styles::Styles::default(),
|
||||
in_look: false,
|
||||
drawn: 0,
|
||||
wrap_moved: false,
|
||||
brush_set: brushes::Brushes::default(),
|
||||
brushes_live: true,
|
||||
deform: false,
|
||||
@@ -829,7 +833,8 @@ impl<'a> Writer<'a> {
|
||||
/// deep, or its art doesn't fit.
|
||||
fn dictionary(&mut self, n: &Node) -> bool {
|
||||
let name = n.name.as_deref().filter(|s| !s.is_empty());
|
||||
let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look);
|
||||
let moved = std::mem::take(&mut self.wrap_moved);
|
||||
let wrap = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look && !moved);
|
||||
let placed = std::mem::take(&mut self.placed);
|
||||
let typed = std::mem::take(&mut self.typed);
|
||||
let bound = self.bound_variables(n, placed, typed);
|
||||
@@ -1404,7 +1409,8 @@ impl<'a> Writer<'a> {
|
||||
/// ([`vectorcraft_effects::outline_text`]): each run's glyphs with their underline and
|
||||
/// strikethrough, its color glyphs, and the object's own fills and strokes; then its inline
|
||||
/// graphics as their symbols' art where the layout places them, as the PDF page draws them;
|
||||
/// the group has the type's transparency `xy`.
|
||||
/// last, with text wrap options, the rectangle of its bounds ([`Self::wrap_bounds`]). The group
|
||||
/// has the type's transparency `xy`.
|
||||
fn outlined_text(&mut self, n: &Node, xy: Xy) {
|
||||
if !self.open_group("u") {
|
||||
return;
|
||||
@@ -1460,12 +1466,34 @@ impl<'a> Writer<'a> {
|
||||
for c in over {
|
||||
self.node(c);
|
||||
}
|
||||
// Type wraps by its bounds: its text wrap options go on a rectangle of its bounds, the
|
||||
// group's last member, and the group's dictionary leaves them out.
|
||||
if let Some(w) = n.extras().wrap.filter(|_| self.drawn == 0 && !self.in_look) {
|
||||
self.wrap_bounds(n, w);
|
||||
self.wrap_moved = true;
|
||||
}
|
||||
if let Some((locked, hidden)) = state {
|
||||
self.state(locked, hidden);
|
||||
}
|
||||
self.end_group("U", xy);
|
||||
}
|
||||
|
||||
/// An unpainted closed rectangle at the geometric bounds of type `n`, the region its text wraps
|
||||
/// around, with `n`'s lock and hidden state and text wrap options `w` in its dictionary. Nothing
|
||||
/// when `n` has no finite bounds, or without room for it ([`Self::take_objects`]).
|
||||
fn wrap_bounds(&mut self, n: &Node, w: TextWrap) {
|
||||
let Some(b) = n.geometric_bounds().filter(|b| [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite())) else { return };
|
||||
self.state(n.locked, !n.visible);
|
||||
let objects = self.objects;
|
||||
self.plain_object(&shapes::rectangle(b), FillRule::NonZero, None, None, Xy::PLAIN);
|
||||
if self.objects == objects {
|
||||
return;
|
||||
}
|
||||
self.body.push_str("%_/ArtDictionary :\n");
|
||||
self.text_wrap(w);
|
||||
self.body.push_str("%_;\n%_\n");
|
||||
}
|
||||
|
||||
// ---------- color ----------
|
||||
|
||||
/// Set the fill (or with `stroke`, the stroke) to paint `p`: a spot or global swatch by its
|
||||
|
||||
@@ -204,7 +204,9 @@ pub(in crate::ai_editing) fn holds(n: &Node, t: &TextObject) -> bool {
|
||||
shape(t).as_ref().and_then(segments).is_some_and(|s| !(open && closed(&s)))
|
||||
}
|
||||
};
|
||||
// Area type that flows around wrap objects: the data doesn't hold their wrap settings.
|
||||
// Area type with wrap shapes: the text document doesn't hold them. The reader works them out
|
||||
// again from the art above the type, which can wrap it differently (a live object written as
|
||||
// its drawn pieces) or not at all.
|
||||
shape_ok
|
||||
&& t.wrap.is_empty()
|
||||
&& t.ruby.is_empty()
|
||||
@@ -962,6 +964,7 @@ impl TextDoc {
|
||||
matches!(t.kind, TextKind::Area { .. })
|
||||
&& t.vertical == vertical
|
||||
&& writable(n.id)
|
||||
// The text document doesn't hold wrap shapes (see `holds`).
|
||||
&& t.wrap.is_empty()
|
||||
&& plain(n)
|
||||
&& t.ruby.is_empty()
|
||||
|
||||
@@ -648,10 +648,11 @@ fn named_cid_type_in_charpath_cshow_and_glyphshow() {
|
||||
}
|
||||
|
||||
/// A font name ending in the row-and-cell CMap `H` or `V` stays a font of the whole name, its
|
||||
/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines (or
|
||||
/// an installed face that covers Japanese). Type in a predefined CMap whose mappings aren't
|
||||
/// available is left out with a warning, each code moving the point one em, and a long string in a
|
||||
/// CID-keyed font over a predefined CMap shown in a loop runs out of the budget.
|
||||
/// strings read byte by byte, unless the part before the CMap names a CIDFont the file defines, a
|
||||
/// Japanese CIDFont a printer carries (or an installed face that covers Japanese). Type in a
|
||||
/// predefined CMap whose mappings aren't available is left out with a warning, each code moving
|
||||
/// the point one em, and a long string in a CID-keyed font over a predefined CMap shown in a loop
|
||||
/// runs out of the budget.
|
||||
#[test]
|
||||
fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() {
|
||||
for (font, family, style) in [("Futura-H", "Futura", "H"), ("Foo-Bold-H", "Foo", "Bold-H"), ("Foo-Condensed-V", "Foo", "Condensed-V")] {
|
||||
@@ -675,6 +676,18 @@ fn latin_names_ending_in_h_and_cmaps_that_cannot_be_read() {
|
||||
let r = super::tests::read(&format!("{define} findfont 20 scalefont setfont 10 20 moveto <467c> show"));
|
||||
assert_eq!(texts(&r).iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日"], "{define}: {:?}", r.warnings);
|
||||
}
|
||||
// The Japanese CIDFonts a printer carries (Adobe Technical Note #5014, appendix A.2) split at
|
||||
// `H` or `V` without the file defining them or a face of their name being installed. A face
|
||||
// with the whole name, when installed, is that face's.
|
||||
for (font, vertical) in [("Ryumin-Light-H", false), ("GothicBBB-Medium-V", true)] {
|
||||
if vectorcraft_text::FontDb::global().find_postscript(font).is_some() {
|
||||
continue;
|
||||
}
|
||||
let r = super::tests::read(&format!("/{font} findfont 20 scalefont setfont 10 20 moveto <467c> show"));
|
||||
let t = texts(&r);
|
||||
assert_eq!(t.iter().map(|t| t.plain_text()).collect::<Vec<_>>(), ["日"], "{font}: {:?}", r.warnings);
|
||||
assert_eq!(t[0].vertical, vertical, "{font}");
|
||||
}
|
||||
let r = super::tests::read("/Ryumin-Light-GBT-H findfont 20 scalefont setfont 10 20 moveto (AB) show 0 0 10 10 rectfill");
|
||||
assert!(texts(&r).is_empty(), "{:?}", all(&r.document));
|
||||
assert_eq!(super::tests::program_warnings(&r.warnings), [super::text::UNREAD_CMAP], "{:?}", r.warnings);
|
||||
|
||||
@@ -509,9 +509,11 @@ impl Interp<'_> {
|
||||
/// Font name `name` as a CIDFont's name and the name of a predefined CMap other than a Unicode
|
||||
/// CMap ([`super::predefined_cmap::composite_font`]), unless an installed face has the whole
|
||||
/// name. A name that ends in the row-and-cell CMap `H` or `V` splits only when the part before
|
||||
/// it names a CIDFont the file defines, or has a hyphen and names an installed face that covers
|
||||
/// Japanese; otherwise it stays a font of the whole name (`Futura-H`, `Foo-Bold-H`), its
|
||||
/// strings read byte by byte.
|
||||
/// it names a CIDFont the file defines, one of the Japanese CIDFonts a printer carries
|
||||
/// (`Ryumin-Light`, `GothicBBB-Medium`: Adobe Technical Note #5014, appendix A.2,
|
||||
/// <https://adobe-type-tools.github.io/font-tech-notes/pdfs/5014.CIDFont_Spec.pdf>), or has a
|
||||
/// hyphen and names an installed face that covers Japanese; otherwise it stays a font of the
|
||||
/// whole name (`Futura-H`, `Foo-Bold-H`), its strings read byte by byte.
|
||||
fn predefined_split<'a>(&mut self, name: &'a str) -> Option<(&'a str, &'a str)> {
|
||||
let (cid_font, cmap) = super::predefined_cmap::composite_font(name)?;
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
@@ -525,7 +527,8 @@ impl Interp<'_> {
|
||||
let defined = self.instances(&Obj::name("CIDFont")).is_ok_and(|d| d.borrow().contains_key(&key)) || cid_font_in(&self.fonts);
|
||||
// 日 is in the row-and-cell CMaps' character set (JIS X 0208).
|
||||
let installed = || db.find_postscript(cid_font).is_some_and(|face| face.covers('日'));
|
||||
if !(defined || (cid_font.contains('-') && installed())) {
|
||||
let printer = matches!(cid_font, "Ryumin-Light" | "GothicBBB-Medium");
|
||||
if !(defined || printer || (cid_font.contains('-') && installed())) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"type.convertListToText",
|
||||
"Convert to Text",
|
||||
"",
|
||||
"{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs (or of those the Type tool's selection touches) become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type",
|
||||
"{} Type › Bullets and Numbering › Convert to Text: the bullets and numbers of the selected type's list paragraphs, in every frame of a threaded story (or of those the Type tool's selection touches), become characters of the text, followed by a tab, and the lists are turned off (text.convertListToText); the Type tool's anchor and caret move past the characters inserted at or before them, into the frame the caret's character moved to in threaded type",
|
||||
),
|
||||
(
|
||||
"align.horizontalLeft",
|
||||
@@ -668,18 +668,36 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{} the Learn panel's skills in the order its Skills tab lists them, with the progress its rings, counts and check marks show: {skills: [{id, title, done: number of its tutorials finished, total: number of its tutorials, tutorials: [{id, title, steps: number of steps, finished: bool, completedSteps: [zero-based indexes of the steps marked done, every step once finished]}]}]}",
|
||||
),
|
||||
(
|
||||
"learn.tab",
|
||||
"Show Learn Tab",
|
||||
"",
|
||||
"{tab: forYou|skills} show the Learn panel on its For you or Skills tab, as the tab buttons do; the Skills tab keeps the skill and tutorial it had open; a missing, non-string or unknown tab is an error and changes nothing; returns {tab, skill, tutorial}, with null for no skill or no tutorial open",
|
||||
),
|
||||
(
|
||||
"learn.skills",
|
||||
"Back to Skills",
|
||||
"",
|
||||
"{} show the Learn panel on its Skills tab with the list of skills, with no skill and no tutorial open, as Back to skills after a skill's last tutorial and the Quick skill guide card do; returns what learn.list returns",
|
||||
),
|
||||
(
|
||||
"learn.skill",
|
||||
"Show Skill",
|
||||
"",
|
||||
"{skill: id (see learn.list)} show the Learn panel on its Skills tab with that skill's tutorials, as choosing the skill's row does; a missing, non-string or unknown id is an error and changes nothing; returns the skill as learn.list lists it: {id, title, done, total, tutorials: [{id, title, steps, finished, completedSteps}]}",
|
||||
),
|
||||
(
|
||||
"learn.show",
|
||||
"Show Tutorial",
|
||||
"",
|
||||
"{tutorial: id (see learn.list)} show the Learn panel on its Skills tab with that tutorial's numbered steps, as choosing its skill and then the tutorial does; a missing, non-string or unknown id is an error and changes nothing; returns {skill, tutorial, title, steps: [text]}",
|
||||
),
|
||||
(
|
||||
"learn.back",
|
||||
"Back One Level",
|
||||
"",
|
||||
"{} show the Learn panel and go up one level on its Skills tab, as its Back button does: from a tutorial's steps to its skill's tutorials, and from a skill's tutorials to the list of skills; on the list of skills or the For you tab the view stays as it is; returns {tab, skill, tutorial}, as learn.tab does",
|
||||
),
|
||||
(
|
||||
"learn.locateTool",
|
||||
"Show Tool Location",
|
||||
|
||||
@@ -67,6 +67,13 @@ pub(crate) fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = painted_art(symbols, n) {
|
||||
return art.visual_bounds_with(symbols).map(|r| with_raster_reach(symbols, n, r));
|
||||
}
|
||||
// Copies that each carry the raster effects listed before their Transform effect reach as far
|
||||
// as each copy's raster effects paint, and the raster effects after it spread them all.
|
||||
if effects::outer_effects(n).len() < n.appearance.effects.len()
|
||||
&& let Some(art) = effects::reshape(Some(symbols.document()), n, None)
|
||||
{
|
||||
return cull_bounds(symbols, &art);
|
||||
}
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = GeomContext { doc: Some(symbols.document()), ..GeomContext::of(n) };
|
||||
let (g, carry) = effected_path_carrying(symbols.document(), n, &bp);
|
||||
|
||||
@@ -423,11 +423,12 @@ impl Renderer {
|
||||
let outer = self.made_again;
|
||||
self.made_again = outer || !kept;
|
||||
// With Crop Marks, the object inside the art paints its own raster effects; Vector
|
||||
// Halftone samples them with the object.
|
||||
// Halftone samples them with the object. The copies of a Transform effect that carry the
|
||||
// raster effects listed before it paint those themselves.
|
||||
let rfx = if effects::has_crop_marks(a) || effects::addons::halftone::has_halftone(a) {
|
||||
vec![]
|
||||
} else {
|
||||
effects::raster_effects(&a.appearance.effects)
|
||||
effects::raster_effects(effects::outer_effects(a))
|
||||
};
|
||||
match cull_bounds(f.symbols, &art) {
|
||||
Some(reach) if !f.opts.outline && !rfx.is_empty() => {
|
||||
|
||||
@@ -425,8 +425,9 @@ impl Renderer {
|
||||
if !k.is_finite() || k <= 1e-12 {
|
||||
return;
|
||||
}
|
||||
// The art in its own coordinates, with its own resources (its composite fonts included).
|
||||
let _lent = vectorcraft_text::FontDb::global().lend_composite_fonts(&e.doc.composite_fonts);
|
||||
// The art in its own coordinates, with its own resources (its composite fonts included, and
|
||||
// none of the host document's).
|
||||
let _lent = vectorcraft_text::FontDb::global().lend_document_composite_fonts(&e.doc.composite_fonts);
|
||||
let symbols = e.doc.symbol_boxes().quick();
|
||||
let visible = m.inverse().transform_rect_bbox(f.visible);
|
||||
let inner = Frame { doc: &e.doc, symbols: &symbols, view: f.view * m, visible, px: f.px / k, output: f.output * m, ..*f };
|
||||
|
||||
@@ -209,7 +209,7 @@ fn a_documents_own_composite_font_draws_without_being_registered() {
|
||||
}
|
||||
|
||||
/// A placed document draws its type in its own definition of a composite font that the document
|
||||
/// placing it defines differently.
|
||||
/// placing it defines differently, and as missing in one only the document placing it defines.
|
||||
#[test]
|
||||
fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() {
|
||||
use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts, ImageBlob, LinkInfo, PlacedDocument};
|
||||
@@ -260,6 +260,55 @@ fn a_placed_document_draws_its_own_definition_of_a_name_the_page_defines() {
|
||||
assert!(own == page(&plain, plain_id, "fontchange-placed-twice-plain", vec![]), "drawn in the placed document's definition");
|
||||
assert!(own != page(&inter, inter_id, "fontchange-placed-twice-inter", vec![]), "not in the page's");
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font(NAME).is_none());
|
||||
// A placed document that defines no composite font draws type naming the page's as missing.
|
||||
let (undefined, undefined_id) = typed(NAME);
|
||||
let (missing, missing_id) = typed("CF Placed Nowhere");
|
||||
let borrowed = page(&undefined, undefined_id, "fontchange-placed-undefined", vec![definition("Inter")]);
|
||||
assert!(borrowed == page(&missing, missing_id, "fontchange-placed-missing", vec![]), "drawn as in a missing font");
|
||||
assert!(borrowed != page(&inter, inter_id, "fontchange-placed-undefined-inter", vec![]), "not in the page's definition");
|
||||
}
|
||||
|
||||
/// A document that defines no composite font, drawn while another document's composite font of
|
||||
/// the name its type is set in is registered (that document in front), draws that type as in a
|
||||
/// missing font: in a render, an ink render, a thumbnail and a node render.
|
||||
#[test]
|
||||
fn a_document_draws_type_in_another_documents_composite_font_as_missing() {
|
||||
use vectorcraft_doc::{CompositeFont, CompositePart, CompositeParts};
|
||||
let _adding = adding_fonts();
|
||||
const NAME: &str = "CF Front Only";
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let typed = |family: &str| {
|
||||
let mut d = Document::new(300.0, 150.0);
|
||||
let style = CharStyle { size: 60.0, font_family: family.into(), ..Default::default() };
|
||||
let n = Node::new(d.alloc_id(), NodeKind::Text(Box::new(TextObject::point(Point::new(10.0, 100.0), "Hamburg", style))));
|
||||
let l = d.layers[0].id;
|
||||
let id = d.insert(Some(l), 0, n).unwrap();
|
||||
(d, id)
|
||||
};
|
||||
let (back, id) = typed(NAME);
|
||||
let (missing, missing_id) = typed("CF Front Nowhere");
|
||||
let (plain, plain_id) = typed("JetBrains Mono");
|
||||
let opts = RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||||
let region = Rect::new(0.0, 0.0, 300.0, 150.0);
|
||||
let draw = |d: &Document, id: NodeId| {
|
||||
let n = d.node(id).unwrap().clone();
|
||||
(
|
||||
Renderer::new().render(d, 300, 150, Affine::IDENTITY, &opts).pixels,
|
||||
Renderer::new().render_region_inks(d, region, 1.0, &opts),
|
||||
Renderer::new().render_thumbnail(d, id, 64).map(|r| r.pixels),
|
||||
Renderer::new().render_node(d, &std::sync::Arc::new(n), 300, 150, Affine::IDENTITY).map(|r| r.pixels),
|
||||
)
|
||||
};
|
||||
let mut parts = CompositeParts::all(CompositePart::new("Source Sans 3", "Regular"));
|
||||
parts.roman = CompositePart::new("JetBrains Mono", "Regular");
|
||||
db.set_composite_font(&CompositeFont { name: NAME.into(), parts });
|
||||
let (drawn, as_missing, in_front) = (draw(&back, id), draw(&missing, missing_id), draw(&plain, plain_id));
|
||||
db.remove_composite_font(NAME);
|
||||
assert!(drawn.0 != in_front.0, "not in the front document's definition");
|
||||
assert!(drawn.0 == as_missing.0, "rendered");
|
||||
assert!(drawn.1 == as_missing.1, "rendered as ink amounts");
|
||||
assert!(drawn.2 == as_missing.2, "thumbnail");
|
||||
assert!(drawn.3 == as_missing.3, "node");
|
||||
}
|
||||
|
||||
/// Type at 80% opacity, in Multiply and with a drop shadow redraws in a font that becomes
|
||||
|
||||
@@ -1219,3 +1219,26 @@ fn a_shadow_before_a_skipped_transform_reaches_as_far_as_it_is_drawn() {
|
||||
assert!(within(b), "{what}: painted {b:?} for art {art:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each copy of a Transform effect draws the drop shadow listed before the effect under itself,
|
||||
/// scaled with it: the red square (10..30) and its copy scaled 200 % and moved 60 pt down (0..40
|
||||
/// across, 60..100 down) each have a green shadow to their right, 20 pt for the square and 40 pt
|
||||
/// for the copy. The bounds reach as far as the copy's shadow.
|
||||
#[test]
|
||||
fn each_copy_of_a_transform_effect_draws_its_own_shadow() {
|
||||
let shadow = fx("stylize.dropShadow", json!({"x": 20, "y": 0, "blur": 0, "opacity": 100, "mode": "normal", "color": "#00ff00"}));
|
||||
let copies = fx("distort.transform", json!({"scaleH": 200, "scaleV": 200, "moveV": 60, "copies": 1}));
|
||||
let red = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0);
|
||||
let mut n = Node::path(NodeId(0), shapes::rectangle(Rect::new(10.0, 10.0, 30.0, 30.0)), red);
|
||||
n.appearance.effects = vec![shadow, copies];
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let img = render(&d);
|
||||
assert_eq!(img.pixel(20, 20), [255, 0, 0, 255], "the square");
|
||||
assert_eq!(img.pixel(45, 20), [0, 255, 0, 255], "the square's shadow");
|
||||
assert_eq!(img.pixel(20, 80), [255, 0, 0, 255], "the copy");
|
||||
assert_eq!(img.pixel(70, 80), [0, 255, 0, 255], "the copy's shadow, 40 pt to its right");
|
||||
assert_eq!(img.pixel(85, 80), [255, 255, 255, 255], "past the copy's shadow");
|
||||
let n = &d.layers[0].children().unwrap()[0];
|
||||
let b = crate::fx::visual_bounds(&d.symbol_boxes(), n).unwrap();
|
||||
assert!(b.x1 >= 80.0 - 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ type Resolved = Option<(Arc<FontFace>, FontMatch)>;
|
||||
|
||||
thread_local! {
|
||||
/// The composite fonts the guards of this thread lend ([`FontDb::lend_composite_fonts`]), the
|
||||
/// latest loan last.
|
||||
/// latest loan last. Only the latest loan of a database is looked in.
|
||||
static LENT: RefCell<Vec<Loan>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ struct Loan {
|
||||
number: u64,
|
||||
/// The fonts, by lowercased name.
|
||||
fonts: HashMap<String, Arc<CompositeFont>>,
|
||||
/// The database's generation when they were lent, when each was then the font the database
|
||||
/// holds by its name.
|
||||
/// The database's generation when they were lent, when they were then exactly the fonts the
|
||||
/// database holds: each the font it holds by its name, and none it holds left out.
|
||||
same_at: Option<u64>,
|
||||
/// A number for the definitions lent: loans of the same definitions have the same one.
|
||||
print: u64,
|
||||
@@ -67,8 +67,8 @@ pub struct CompositeCoverage {
|
||||
impl FontDb {
|
||||
/// Make `font` available by its name, in place of a composite font of that name (any case).
|
||||
/// The font menus and anything else that lists fonts change only when the definition does.
|
||||
/// Whether it did. A font of that name a guard lends ([`FontDb::lend_composite_fonts`]) stays
|
||||
/// in use on the guard's thread.
|
||||
/// Whether it did. While a guard lends composite fonts ([`FontDb::lend_composite_fonts`]), the
|
||||
/// guard's thread uses the fonts it lends and not this one.
|
||||
pub fn set_composite_font(&self, font: &CompositeFont) -> bool {
|
||||
let key = font.name.to_lowercase();
|
||||
{
|
||||
@@ -91,15 +91,13 @@ impl FontDb {
|
||||
removed
|
||||
}
|
||||
|
||||
/// The composite font named `name` (any case): the one the latest guard of this thread that
|
||||
/// lends that name lends ([`FontDb::lend_composite_fonts`]), else the one the database holds.
|
||||
/// The composite font named `name` (any case). While a guard of this thread lends a document's
|
||||
/// composite fonts ([`FontDb::lend_composite_fonts`]), the one the latest such guard lends, and
|
||||
/// `None` when that document does not define the name, also when the database or an earlier
|
||||
/// guard holds one of that name. Otherwise the one the database holds.
|
||||
pub fn composite_font(&self, name: &str) -> Option<Arc<CompositeFont>> {
|
||||
if self.lends() {
|
||||
let key = name.to_lowercase();
|
||||
if let Some(f) = self.lent_font(&key) {
|
||||
return Some(f);
|
||||
}
|
||||
return self.composites.read().unwrap_or_else(|e| e.into_inner()).get(&key).cloned();
|
||||
return self.lent_font(&name.to_lowercase());
|
||||
}
|
||||
let map = self.composites.read().unwrap_or_else(|e| e.into_inner());
|
||||
if map.is_empty() {
|
||||
@@ -113,30 +111,27 @@ impl FontDb {
|
||||
LENT.try_with(|l| l.borrow().iter().any(|loan| loan.db == self.id)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// The composite font named `key` (lowercased) that the latest guard of this thread lending that
|
||||
/// name lends.
|
||||
/// The composite font named `key` (lowercased) that the latest guard of this thread lending
|
||||
/// composite fonts of this database lends.
|
||||
fn lent_font(&self, key: &str) -> Option<Arc<CompositeFont>> {
|
||||
LENT.try_with(|l| l.borrow().iter().rev().filter(|loan| loan.db == self.id).find_map(|loan| loan.fonts.get(key).cloned())).ok().flatten()
|
||||
LENT.try_with(|l| l.borrow().iter().rev().find(|loan| loan.db == self.id).and_then(|loan| loan.fonts.get(key).cloned())).ok().flatten()
|
||||
}
|
||||
|
||||
/// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while each
|
||||
/// font the guards of this thread lend is the one the database held by its name when it was lent
|
||||
/// and the database has not changed since, else a number made from `g` and the definitions
|
||||
/// lent, so what was laid out in one document's definitions is not taken for another's.
|
||||
/// [`FontDb::generation`] on this thread, from the database's own generation `g`: `g` while no
|
||||
/// guard of this thread lends composite fonts of this database, or while the latest one lends
|
||||
/// exactly the fonts the database held when it was lent and the database has not changed since,
|
||||
/// else a number made from `g` and the definitions lent, so what was laid out in one document's
|
||||
/// definitions is not taken for another's.
|
||||
pub(super) fn lent_generation(&self, g: u64) -> u64 {
|
||||
LENT.try_with(|l| {
|
||||
let l = l.borrow();
|
||||
let loans = l.iter().filter(|loan| loan.db == self.id);
|
||||
if loans.clone().all(|loan| loan.same_at == Some(g)) {
|
||||
return g;
|
||||
}
|
||||
let mut h = DefaultHasher::new();
|
||||
g.hash(&mut h);
|
||||
for loan in loans {
|
||||
LENT.try_with(|l| match l.borrow().iter().rev().find(|loan| loan.db == self.id) {
|
||||
Some(loan) if loan.same_at != Some(g) => {
|
||||
let mut h = DefaultHasher::new();
|
||||
g.hash(&mut h);
|
||||
loan.print.hash(&mut h);
|
||||
// The database's generations count up from 0 and never reach the top bit.
|
||||
h.finish() | 1 << 63
|
||||
}
|
||||
// The database's generations count up from 0 and never reach the top bit.
|
||||
h.finish() | 1 << 63
|
||||
_ => g,
|
||||
})
|
||||
.unwrap_or(g)
|
||||
}
|
||||
@@ -245,20 +240,41 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make each of the composite fonts `fonts` (a document's) available on this thread while the
|
||||
/// returned guard lives, in place of a composite font of the same name that the database holds
|
||||
/// or that an earlier guard of this thread lends. An app's session registers the composite fonts
|
||||
/// of its front document; with the guard, a document exported without a session, one that is not
|
||||
/// in front, or one placed in another document draws its type in its own, also while another
|
||||
/// thread draws another document that defines the same name differently, or the session
|
||||
/// registers another document's. Of fonts that share a name (in any case), the first is lent.
|
||||
/// Make the composite fonts `fonts` (a document's) the only ones available on this thread while
|
||||
/// the returned guard lives: type naming a composite font they do not define is set as in a
|
||||
/// missing font, also when the database or an earlier guard of this thread holds one of that
|
||||
/// name. An app's session registers the composite fonts of its front document; with the guard, a
|
||||
/// document exported without a session, one that is not in front, or one placed in another
|
||||
/// document draws its type in its own, also while another thread draws another document that
|
||||
/// defines the same name differently, or the session registers another document's. Of fonts
|
||||
/// that share a name (in any case), the first is lent. When none of `fonts` is usable and no
|
||||
/// guard of this thread lends composite fonts of the database, the guard lends none: type naming
|
||||
/// any composite font is set as in a missing font while it lives. When none is usable inside
|
||||
/// another guard (a temporary document drawn while a document draws), nothing is lent and that
|
||||
/// guard's fonts stay in use; [`FontDb::lend_document_composite_fonts`] lends none there too.
|
||||
/// Other threads, and the fonts the database holds and lists, are left as they are. The guard
|
||||
/// stays on its thread and ends its loan when it drops, also when the thread unwinds.
|
||||
/// stays on its thread and ends its loan when it drops, also when the thread unwinds. With no
|
||||
/// guard (type laid out on its own), the fonts the database holds are used.
|
||||
pub fn lend_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> {
|
||||
self.lend(fonts, false)
|
||||
}
|
||||
|
||||
/// [`FontDb::lend_composite_fonts`] for a document that is drawn or measured as itself, also
|
||||
/// inside another document's guard (a document placed in another): when it defines no usable
|
||||
/// composite font, type naming any composite font is set as in a missing font while the guard
|
||||
/// lives.
|
||||
pub fn lend_document_composite_fonts(&self, fonts: &[CompositeFont]) -> LentCompositeFonts<'_> {
|
||||
self.lend(fonts, true)
|
||||
}
|
||||
|
||||
/// Lend the usable fonts of `fonts`. With none, lend an empty set when `always` or when no
|
||||
/// guard of this thread lends fonts of this database (and the database holds some), else
|
||||
/// nothing.
|
||||
fn lend(&self, fonts: &[CompositeFont], always: bool) -> LentCompositeFonts<'_> {
|
||||
// A font a document can't use (its name an installed family's, or a value out of range)
|
||||
// is not lent: an app leaves it out when it opens the document.
|
||||
let usable: Vec<&CompositeFont> = fonts.iter().filter(|f| f.check().is_ok() && !self.has_installed_family(&f.name)).collect();
|
||||
if usable.is_empty() {
|
||||
if usable.is_empty() && !always && (self.lends() || self.composites.read().unwrap_or_else(|e| e.into_inner()).is_empty()) {
|
||||
return LentCompositeFonts { number: None, thread: PhantomData };
|
||||
}
|
||||
let mut lent: HashMap<String, Arc<CompositeFont>> = HashMap::with_capacity(usable.len());
|
||||
@@ -281,6 +297,8 @@ impl FontDb {
|
||||
};
|
||||
lent.insert(key, font);
|
||||
}
|
||||
// Each lent font is the database's; with as many, none it holds is left out.
|
||||
same &= lent.len() == map.len();
|
||||
self.generation.load(Ordering::Relaxed)
|
||||
};
|
||||
let number = NEXT_LOAN.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
@@ -243,11 +243,16 @@ pub(super) fn drawn_after_tab<'g>(sg: &'g [SGlyph], drawn: &'g [usize], k: usize
|
||||
/// them) set on a line whose content starts `x_start` from the tab origin: tabs of the text take
|
||||
/// what [`tab_advance`] gives them from where the glyphs drawn before them end, aligning the glyphs
|
||||
/// drawn after them, not the width of their glyph. The tab of a list marker (length 0) keeps its
|
||||
/// own width.
|
||||
pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 {
|
||||
/// own width. With `end_trim` (a glyph index and an amount), that glyph is set that much narrower
|
||||
/// where it is drawn (a line's end trim), before the tabs drawn after it take their stops.
|
||||
pub(super) fn drawn_width(sg: &[SGlyph], drawn: &[usize], tabs: &[vectorcraft_doc::TabStop], x_start: f64, end_trim: Option<(usize, f64)>) -> f64 {
|
||||
let mut x = x_start;
|
||||
for (k, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, g))) {
|
||||
x += if g.ch == '\t' && g.len > 0 { tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0 } else { g.adv };
|
||||
for (k, j, g) in drawn.iter().enumerate().filter_map(|(k, &j)| sg.get(j).map(|g| (k, j, g))) {
|
||||
x += if g.ch == '\t' && g.len > 0 {
|
||||
tab_advance(tabs, 0.0, x, drawn_after_tab(sg, drawn, k)).0
|
||||
} else {
|
||||
g.adv - end_trim.filter(|&(at, _)| at == j).map_or(0.0, |(_, trim)| trim)
|
||||
};
|
||||
}
|
||||
x - x_start
|
||||
}
|
||||
|
||||
@@ -459,8 +459,9 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
.filter(|&j| j < trimmed)
|
||||
.collect();
|
||||
let hyphen = if hyphenated { end.checked_sub(1).and_then(|k| glyphs.get(k)).map_or(0.0, |g| hyphen_glyph(g).adv) } else { 0.0 };
|
||||
let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
|
||||
drawn_width(glyphs, &drawn, ¶.tabs, ind_l) + hyphen - credit <= width + EPS
|
||||
// The last glyph's end trim comes off where it is drawn, before the tabs after it.
|
||||
let credit = trimmed.checked_sub(1).filter(|&k| k >= start).and_then(|k| line_end.get(k).map(|&trim| (k, trim)));
|
||||
drawn_width(glyphs, &drawn, ¶.tabs, ind_l, credit) + hyphen <= width + EPS
|
||||
};
|
||||
let fits: Option<&dyn Fn(usize, bool) -> bool> = (!cx.vertical && bidi.is_some_and(|b| b.has_rtl())).then_some(&drawn_fits);
|
||||
let line = |ty| {
|
||||
@@ -710,9 +711,11 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
|
||||
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
|
||||
let order = visual_order(paragraph.bidi.as_ref().filter(|_| !cx.vertical), paragraph.pr.start, line_start..line_end, &sg[index..position.end]);
|
||||
// The line is as wide as it is drawn: its glyphs before its trailing spaces in drawing order,
|
||||
// each tab up to its stop from where the glyphs drawn before it end.
|
||||
// each tab up to its stop from where the glyphs drawn before it end, with the last glyph's end
|
||||
// trim taken off where it is drawn.
|
||||
let measured: Vec<usize> = order.iter().map(|&offset| index + offset).filter(|&j| j < trimmed).collect();
|
||||
let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
|
||||
let trim_at = trimmed.checked_sub(1).filter(|&k| k >= index).map(|k| (k, end_trim));
|
||||
let measured_width = drawn_width(sg, &measured, ¶.tabs, position.ind_l, trim_at) + hyphen.as_ref().map_or(0.0, |g| g.adv);
|
||||
// The glyphs before `stop` in visual order, each with the advance it is set with. A list
|
||||
// marker does not hang: it is counted with no width.
|
||||
let visible = |stop: usize| -> (Vec<usize>, Vec<(&SGlyph, f64)>) {
|
||||
|
||||
@@ -265,6 +265,10 @@ type Slant = (Point, Point, (f64, f64), (f64, f64));
|
||||
/// The line through two points.
|
||||
type Line = (Point, Point);
|
||||
|
||||
/// The x at which an edge crosses a height, and the line it was found along (`None` when it is
|
||||
/// the edge's end).
|
||||
type Crossing = (f64, Option<Line>);
|
||||
|
||||
/// The x of the line through `p` and `q` (not horizontal) at `y`.
|
||||
fn line_x(p: Point, q: Point, y: f64) -> f64 {
|
||||
p.x + (y - p.y) / (q.y - p.y) * (q.x - p.x)
|
||||
@@ -426,20 +430,36 @@ fn band_intervals(polys: &[Vec<Point>], top: f64, bottom: f64) -> Vec<(f64, f64)
|
||||
let (edges, _, mut xs) = band_boundaries(polys, top, bottom);
|
||||
// The edges of contours that meet along an edge cross the middle of the band at the same x,
|
||||
// since each is crossed on the line through the same two points: the intervals on either side
|
||||
// join there (an edge that bounds the shape there is still subtracted). Intervals with any gap
|
||||
// join there (an edge that bounds the shape there is still subtracted). Crossings of other edges
|
||||
// that round to the same x join only when both lie exactly at that x. Intervals with any gap
|
||||
// between them stay apart.
|
||||
xs.sort_by(f64::total_cmp);
|
||||
let mut mid: Vec<(f64, f64)> = xs.as_chunks::<2>().0.iter().map(|c| (c[0], c[1])).collect();
|
||||
mid.dedup_by(|next, prev| {
|
||||
let seam = next.0 <= prev.1;
|
||||
if seam {
|
||||
prev.1 = prev.1.max(next.1);
|
||||
let y = (top + bottom) * 0.5;
|
||||
xs.sort_by(|a, b| a.0.total_cmp(&b.0).then_with(|| line_order(a.1, b.1)));
|
||||
let mut mid: Vec<(f64, f64)> = Vec::new();
|
||||
let mut last: Option<Line> = None;
|
||||
for &[(s, ks), (t, kt)] in xs.as_chunks::<2>().0 {
|
||||
match mid.last_mut() {
|
||||
Some(prev) if s <= prev.1 && ((ks.is_some() && last == ks) || (exactly_at(last, s, y) && exactly_at(ks, s, y))) => prev.1 = prev.1.max(t),
|
||||
_ => mid.push((s, t)),
|
||||
}
|
||||
seam
|
||||
});
|
||||
last = kt;
|
||||
}
|
||||
subtract_intervals(&mid, &edges)
|
||||
}
|
||||
|
||||
/// An order of the lines crossings of the band's middle were found along, which keeps crossings
|
||||
/// along one line next to each other; a crossing at an edge's end (`None`) comes first.
|
||||
fn line_order(a: Option<Line>, b: Option<Line>) -> std::cmp::Ordering {
|
||||
let key = |l: Option<Line>| l.map_or([f64::NEG_INFINITY; 4], |(p, q)| [p.x, p.y, q.x, q.y]);
|
||||
key(a).iter().zip(key(b)).fold(std::cmp::Ordering::Equal, |o, (u, v)| o.then(u.total_cmp(&v)))
|
||||
}
|
||||
|
||||
/// Does a crossing of the height `y` at `x`, found along `line` (or at an edge's end when `None`),
|
||||
/// lie exactly at `x`?
|
||||
fn exactly_at(line: Option<Line>, x: f64, y: f64) -> bool {
|
||||
line.is_none_or(|(p, q)| on_line_exactly(p, q, Point::new(x, y)))
|
||||
}
|
||||
|
||||
/// The x at which `polys` (even-odd) is inside somewhere in the band `top..bottom`, sorted and
|
||||
/// disjoint: the intervals at the middle of the band and every x at which an edge lies within the
|
||||
/// band. A vertical line at any other x meets no edge inside the band, so it is inside somewhere in
|
||||
@@ -482,17 +502,19 @@ fn band_cover_closed(polys: &[Vec<Point>], top: f64, bottom: f64) -> Vec<(f64, f
|
||||
/// the band, stretches of edges that an even number of edges cover, and vertical edges. The flag is
|
||||
/// false when [`MAX_EDGE_PAIRS`] left edges unpaired, which can only add to the result. Last, the x
|
||||
/// at which the edges cross the middle of the band (even-odd, as [`poly_intervals`] finds them),
|
||||
/// unsorted, each found along the line [`slant_boundaries`] gives its edge.
|
||||
fn band_boundaries(polys: &[Vec<Point>], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec<f64>) {
|
||||
/// unsorted, each found along the line [`slant_boundaries`] gives its edge, with that line (`None`
|
||||
/// for a crossing at an edge's end).
|
||||
fn band_boundaries(polys: &[Vec<Point>], top: f64, bottom: f64) -> (Vec<(f64, f64)>, bool, Vec<Crossing>) {
|
||||
let (mut flats, mut slants) = band_edges(polys, top, bottom);
|
||||
let (mut edges, paired, lines) = slant_boundaries(&mut slants);
|
||||
let y = (top + bottom) * 0.5;
|
||||
let mut xs: Vec<f64> = slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| line_x(p, q, y)).collect();
|
||||
let mut xs: Vec<Crossing> =
|
||||
slants.iter().zip(&lines).filter(|((p, q, ..), _)| p.y <= y && y < q.y).map(|(_, &(p, q))| (line_x(p, q, y), Some((p, q)))).collect();
|
||||
// When the middle is the band's bottom (a band of no height), the edges that start there cross
|
||||
// it but are not in the band: they cross it at their lower end.
|
||||
if y == bottom {
|
||||
let starts = polys.iter().flat_map(|poly| poly.iter().zip(poly.iter().cycle().skip(1)));
|
||||
xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| if a.y < b.y { a.x } else { b.x }));
|
||||
xs.extend(starts.filter(|(a, b)| a.y != b.y && a.y.min(b.y) == y).map(|(a, b)| (if a.y < b.y { a.x } else { b.x }, None)));
|
||||
}
|
||||
// The x covered by an odd number of the horizontal edges at one height: pairs of their
|
||||
// sorted ends.
|
||||
@@ -1364,6 +1386,29 @@ fn shared_slanted_edge_still_joins_near_canvas_limit() {
|
||||
assert_spans(&cells[0].spans(top, bottom), &[(-100.0 + 3.0 * bottom / 7.0, 100.0 + 3.0 * top / 7.0)]);
|
||||
}
|
||||
|
||||
/// A slanted edge and a vertical edge of two contours less than one unit in the last place apart
|
||||
/// near the edge of the canvas, whose crossings of the band's middle round to the same x, are not
|
||||
/// one edge: the line stops at the gap, drawn either way around.
|
||||
#[cfg(test)]
|
||||
#[test]
|
||||
fn contours_whose_crossings_round_together_stay_apart() {
|
||||
let x = 3_999_000.0;
|
||||
let u = 2f64.powi(-31);
|
||||
assert_eq!((x + u).to_bits(), x.to_bits() + 1);
|
||||
let left: &[(f64, f64)] = &[(x - 100.0, 0.0), (x, 0.0), (x + u, 10.0), (x - 100.0, 10.0)];
|
||||
let right: &[(f64, f64)] = &[(x + u, 0.0), (x + 100.0, 0.0), (x + 100.0, 10.0), (x + u, 10.0)];
|
||||
let mirror = |c: &[(f64, f64)]| -> Vec<(f64, f64)> { c.iter().map(|&(px, py)| (px, 10.0 - py)).collect() };
|
||||
let (left_m, right_m) = (mirror(left), mirror(right));
|
||||
for frame in [closed(&[left, right]), closed(&[&right_m, &left_m])] {
|
||||
let cells = Region::cells(&frame, &LayoutOptions::default(), &[]);
|
||||
for (top, bottom) in [(8.0, 9.0), (1.0, 2.0)] {
|
||||
let spans = cells[0].spans(top, bottom);
|
||||
assert_eq!(spans.len(), 2, "{top}..{bottom}: {spans:?}");
|
||||
assert_spans(&spans, &[(x - 100.0, x + u), (x + u, x + 100.0)]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Area type in two slanted contours four units in the last place apart near the edge of the canvas
|
||||
/// sets no line across the gap between them.
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -192,6 +192,72 @@ fn nested_loans_of_one_name_use_each_documents_definition() {
|
||||
assert_eq!(db.generation(), plain);
|
||||
}
|
||||
|
||||
/// While one document's composite fonts are lent, a name only another document defines (the one
|
||||
/// the database holds, or one an outer guard lends) is no composite font: type set in it lays out
|
||||
/// as in a missing font, also after it was laid out in that definition with nothing lent.
|
||||
#[test]
|
||||
fn a_lent_document_does_not_use_another_documents_composite_font() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (a, b) = (roman_at("CF Only A", 50.0), roman_at("CF Only B", 50.0));
|
||||
// The session registered B's font, and A's (the same definition) as well.
|
||||
db.set_composite_font(&a);
|
||||
db.set_composite_font(&b);
|
||||
let half = advance(&db, "JetBrains Mono", 10.0);
|
||||
assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9);
|
||||
let missing = advance(&db, "CF Nowhere", 20.0);
|
||||
assert!((missing - half).abs() > 1e-6, "the missing font draws otherwise");
|
||||
// Lending looks the name up among the installed families, which catalogs them the first time.
|
||||
assert!(!db.has_installed_family("CF Only A"));
|
||||
let plain = db.generation();
|
||||
{
|
||||
let _a = db.lend_composite_fonts(std::slice::from_ref(&a));
|
||||
assert!(db.composite_font("CF Only A").is_some());
|
||||
assert!(db.composite_font("cf only b").is_none());
|
||||
assert!(db.composite_fonts_for("CF Only B", "A").is_none());
|
||||
assert_ne!(db.generation(), plain, "type laid out with B's font is not reused");
|
||||
assert!((advance(&db, "CF Only B", 20.0) - missing).abs() < 1e-9);
|
||||
assert!((advance(&db, "CF Only A", 20.0) - half).abs() < 1e-9);
|
||||
// A document placed in A that defines B's font sees only its own.
|
||||
let _b = db.lend_composite_fonts(std::slice::from_ref(&b));
|
||||
assert!(db.composite_font("CF Only A").is_none());
|
||||
assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9);
|
||||
}
|
||||
assert_eq!(db.generation(), plain);
|
||||
assert!((advance(&db, "CF Only B", 20.0) - half).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// A document that defines no composite font, drawn or lent as a document, uses none of the
|
||||
/// database's. A temporary document that defines none, drawn inside another document's guard,
|
||||
/// keeps that guard's fonts; with no guard at all, the database's stay in use.
|
||||
#[test]
|
||||
fn a_document_without_composite_fonts_uses_none_and_a_preview_is_unchanged() {
|
||||
let db = FontDb::with_font_dirs(vec![]);
|
||||
let (a, b) = (roman_at("CF Held A", 50.0), roman_at("CF Held B", 50.0));
|
||||
db.set_composite_font(&b);
|
||||
let half = advance(&db, "JetBrains Mono", 10.0);
|
||||
let missing = advance(&db, "CF Nowhere Else", 20.0);
|
||||
let plain = db.generation();
|
||||
for lend in [FontDb::lend_composite_fonts, FontDb::lend_document_composite_fonts] {
|
||||
let _none = lend(&db, &[]);
|
||||
assert!(db.composite_font("CF Held B").is_none());
|
||||
assert_ne!(db.generation(), plain);
|
||||
assert!((advance(&db, "CF Held B", 20.0) - missing).abs() < 1e-9);
|
||||
}
|
||||
{
|
||||
let _a = db.lend_composite_fonts(std::slice::from_ref(&a));
|
||||
// A temporary document drawn inside it lends nothing and keeps A's fonts.
|
||||
let _preview = db.lend_composite_fonts(&[]);
|
||||
assert!(db.composite_font("CF Held A").is_some());
|
||||
assert!(db.composite_font("CF Held B").is_none());
|
||||
// A document placed in it that defines none uses none of A's.
|
||||
let _placed = db.lend_document_composite_fonts(&[]);
|
||||
assert!(db.composite_font("CF Held A").is_none());
|
||||
}
|
||||
assert_eq!(db.generation(), plain);
|
||||
assert!(db.composite_font("CF Held B").is_some());
|
||||
assert!((advance(&db, "CF Held B", 20.0) - half).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Two threads that lend different definitions of one name at once each lay out in their own.
|
||||
#[test]
|
||||
fn threads_lending_one_name_at_once_use_their_own_definitions() {
|
||||
|
||||
@@ -668,6 +668,36 @@ fn a_right_to_left_line_with_a_tab_and_many_breaks_wraps_when_it_does_not_fit_as
|
||||
}
|
||||
}
|
||||
|
||||
/// A right-to-left line with a tab whose last character loses its Japanese–Latin space at the
|
||||
/// line's end is measured with that space taken off where the character is drawn, before the tab:
|
||||
/// right aligned, it ends on the frame's right edge, and it wraps when it does not fit as drawn.
|
||||
#[test]
|
||||
fn a_right_to_left_tabbed_line_is_measured_without_its_line_end_space() {
|
||||
use vectorcraft_doc::Mojikumi;
|
||||
// א (0), ב (2), tab (4), ג (5), 漢 (7), then a Hebrew word too long for the frame: the first
|
||||
// line ends after 漢, which loses the quarter em it had before the Hebrew word.
|
||||
let text = format!("אב\tג漢{}", "ד".repeat(40));
|
||||
let lay = |width: f64| {
|
||||
let mut t = area(&text, width, Justify::Right, no_ranges());
|
||||
t.para.mojikumi = Mojikumi::GyomatsuYakumonoZenkaku;
|
||||
t.para.tabs = vec![TabStop { position: 150.0, align: TabAlign::Left, leader: String::new(), align_on: '.' }];
|
||||
layout(db(), &t)
|
||||
};
|
||||
let l = lay(300.0);
|
||||
assert!(l.lines.len() >= 2, "wraps: {} line(s)", l.lines.len());
|
||||
assert!(l.glyphs.iter().filter(|g| g.line == 0).all(|g| g.byte < 10), "the first line ends after 漢");
|
||||
let (_, right) = drawn_extent(&l, &text, 0);
|
||||
assert!((right - 300.0).abs() < 1e-6, "ends on the right edge: {right}");
|
||||
assert!((l.lines[0].x1 - right).abs() < 1e-6, "the line's record ends where it is drawn: {}", l.lines[0].x1);
|
||||
// Drawn, that line is as wide as the stop and the text after the tab (אב). In a frame a point
|
||||
// narrower, less than the quarter em, it does not fit and the first line stays in the frame.
|
||||
let after_tab: f64 = l.glyphs.iter().filter(|g| g.byte < 4).map(|g| g.advance).sum();
|
||||
let width = 150.0 + after_tab - 1.0;
|
||||
let l = lay(width);
|
||||
let (left, right) = drawn_extent(&l, &text, 0);
|
||||
assert!(left >= -1e-6 && right <= width + 1e-6, "the first line stays in the frame: {left} {right} (frame {width})");
|
||||
}
|
||||
|
||||
/// Point type has no measure to fill: a justified line with a tab keeps its natural width.
|
||||
#[test]
|
||||
fn justified_point_type_with_a_tab_keeps_its_width() {
|
||||
|
||||
@@ -24,3 +24,5 @@ serde_json = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# The tests lay type out in the bundled fonts (the workspace leaves turning them on to the engine).
|
||||
vectorcraft-text = { workspace = true, features = ["test-fonts", "bundled-fonts"] }
|
||||
# How many times a path is flattened.
|
||||
vectorcraft-doc = { workspace = true, features = ["spine-count"] }
|
||||
|
||||
+418
-116
@@ -42,13 +42,14 @@
|
||||
//! Behavior: <https://helpx.adobe.com/illustrator/using/objects-on-path.html>,
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/manage-objects/arrange-objects/rearrange-and-edit-objects-on-a-path.html>.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::blend::Spine;
|
||||
use vectorcraft_doc::objects_on_path::{
|
||||
CENTER, Member, ObjectsOnPath, attachable_path, is_objects_on_path, layout, nth_length, path_length, stop, stops,
|
||||
CENTER, Measured, Member, ObjectsOnPath, attachable, attachable_path, is_objects_on_path, layout, nth_length,
|
||||
};
|
||||
use vectorcraft_doc::{Document, Node, NodeId, Selection};
|
||||
use vectorcraft_geom::{BezPath, Point, Vec2};
|
||||
use vectorcraft_geom::{BezPath, PathData, Point, Vec2};
|
||||
|
||||
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
|
||||
|
||||
@@ -85,14 +86,20 @@ pub const MEMBER_RADIUS_PX: f32 = 4.5;
|
||||
/// How near (screen px) the pointer must be to hit a Select/Move widget.
|
||||
const MEMBER_HIT_PX: f64 = 6.0;
|
||||
|
||||
/// The Objects on Path group the widgets show for at `zoom` (screen px per document point), with
|
||||
/// its record, its children and its path: only when the selection is that one editable group, on
|
||||
/// a path with length, and no anchors are Direct-Selected.
|
||||
fn one_group<'a>(
|
||||
doc: &'a Document,
|
||||
selection: &Selection,
|
||||
zoom: f64,
|
||||
) -> Option<(NodeId, &'a ObjectsOnPath, &'a [std::sync::Arc<vectorcraft_doc::Node>], &'a vectorcraft_geom::PathData)> {
|
||||
/// An Objects on Path group the widgets show for, with its record, its children, its path and the
|
||||
/// path measured: one flattening serves every widget of the group.
|
||||
struct Group<'a> {
|
||||
id: NodeId,
|
||||
rec: &'a ObjectsOnPath,
|
||||
children: &'a [Arc<Node>],
|
||||
path: &'a PathData,
|
||||
measured: Arc<Measured>,
|
||||
}
|
||||
|
||||
/// The Objects on Path group the widgets show for at `zoom` (screen px per document point): only
|
||||
/// when the selection is that one editable group, on a path with length, and no anchors are
|
||||
/// Direct-Selected.
|
||||
fn one_group<'a>(doc: &'a Document, selection: &Selection, zoom: f64) -> Option<Group<'a>> {
|
||||
if !selection.anchors.is_empty() || !zoom.is_finite() || zoom <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
@@ -103,8 +110,39 @@ fn one_group<'a>(
|
||||
let n = doc.node(*group)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
let children = n.children()?;
|
||||
let path = attachable_path(children.iter().find(|c| c.id == rec.path)?)?;
|
||||
Some((*group, rec, children.as_slice(), path))
|
||||
let (path, measured) = attachable(children.iter().find(|c| c.id == rec.path)?)?;
|
||||
Some(Group { id: *group, rec, children: children.as_slice(), path, measured: Arc::new(measured) })
|
||||
}
|
||||
|
||||
/// The path of an Objects on Path group as measured when a widget drag began, kept for the drag:
|
||||
/// the previews change the group's record and its objects, not its path.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Kept {
|
||||
group: NodeId,
|
||||
path: PathData,
|
||||
measured: Arc<Measured>,
|
||||
}
|
||||
|
||||
impl Kept {
|
||||
fn of(g: &Group) -> Self {
|
||||
Self { group: g.id, path: g.path.clone(), measured: Arc::clone(&g.measured) }
|
||||
}
|
||||
|
||||
/// The group's path in `doc` measured: the kept measurement while the path is as it was, else
|
||||
/// the path measured again and kept. `None` when the group no longer has a path with length.
|
||||
fn now(&mut self, doc: &Document) -> Option<Arc<Measured>> {
|
||||
let n = doc.node(self.group)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
let node = n.children()?.iter().find(|c| c.id == rec.path)?;
|
||||
if let vectorcraft_doc::NodeKind::Path { path, guide: false, .. } = &node.kind
|
||||
&& *path == self.path
|
||||
{
|
||||
return Some(Arc::clone(&self.measured));
|
||||
}
|
||||
let (path, measured) = attachable(node)?;
|
||||
*self = Self { group: self.group, path: path.clone(), measured: Arc::new(measured) };
|
||||
Some(Arc::clone(&self.measured))
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction the top of the object `m` faces: the -y direction of the frame it is drawn in
|
||||
@@ -148,22 +186,27 @@ impl RotateAllWidget {
|
||||
/// selection is one editable Objects on Path group with at least one object on a path with
|
||||
/// length, and no anchors are Direct-Selected.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||||
let first = rec.members.first()?;
|
||||
let (pivot, _) = stop(path, rec.members.len(), rec.spacing, rec.start, 0)?;
|
||||
let at = beside(doc, children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?;
|
||||
Some(Self { group, pivot, at, rotate: rec.rotate })
|
||||
Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom)
|
||||
}
|
||||
|
||||
/// The widget of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX))
|
||||
/// The widget of the group `g`.
|
||||
fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> {
|
||||
let rec = g.rec;
|
||||
let first = rec.members.first()?;
|
||||
let (pivot, _) = g.measured.stop(rec.members.len(), rec.spacing, rec.start, 0)?;
|
||||
let at = beside(doc, g.children, first, pivot, top(first), ROTATE_ALL_OFFSET_PX / zoom)?;
|
||||
Some(Self { group: g.id, pivot, at, rotate: rec.rotate })
|
||||
}
|
||||
|
||||
/// The widget of the group `g` of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> {
|
||||
Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(ROTATE_ALL_HIT_PX))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over the Rotate All widget?
|
||||
pub fn over_rotate_all(cx: &ToolContext, p: Point) -> bool {
|
||||
RotateAllWidget::at_point(cx, p).is_some()
|
||||
one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| RotateAllWidget::at_point(cx, &g, p)).is_some()
|
||||
}
|
||||
|
||||
/// Dragging the Rotate All widget: the group's Rotate follows the angle the pointer turns about
|
||||
@@ -191,7 +234,12 @@ impl RotateAllDrag {
|
||||
|
||||
/// Start a drag when the press `ev` is on the Rotate All widget.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = RotateAllWidget::at_point(cx, ev.pos)?;
|
||||
Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev)
|
||||
}
|
||||
|
||||
/// [`RotateAllDrag::hit`] on the group `g` of `cx`.
|
||||
fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = RotateAllWidget::at_point(cx, g, ev.pos)?;
|
||||
let d = ev.pos - widget.pivot;
|
||||
let last = d.y.atan2(d.x);
|
||||
let last = if last.is_finite() { last } else { 0.0 };
|
||||
@@ -272,17 +320,22 @@ impl SpaceWidget {
|
||||
/// length, and no anchors are Direct-Selected. It sits beyond the last object's bounds, ahead
|
||||
/// along the path's direction there.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||||
Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom)
|
||||
}
|
||||
|
||||
/// The widget of the group `g`.
|
||||
fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> {
|
||||
let (group, rec) = (g.id, g.rec);
|
||||
let count = rec.members.len();
|
||||
if count < 2 {
|
||||
return None;
|
||||
}
|
||||
let (len, closed) = path_length(path)?;
|
||||
let (len, closed) = (g.measured.length(), g.measured.closed());
|
||||
let (start, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||||
let (last, angle) = stop(path, count, rec.spacing, rec.start, count - 1)?;
|
||||
let (last, angle) = g.measured.stop(count, rec.spacing, rec.start, count - 1)?;
|
||||
let dir = Vec2::new(angle.cos(), angle.sin());
|
||||
let m = rec.members.last()?;
|
||||
let b = children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?;
|
||||
let b = g.children.iter().find(|c| c.id == m.id)?.geometric_bounds_with(&doc.symbol_boxes())?;
|
||||
let corners = [Point::new(b.x0, b.y0), Point::new(b.x1, b.y0), Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)];
|
||||
let reach = corners.iter().map(|c| (*c - last).dot(dir)).fold(0.0, f64::max);
|
||||
let at = last + dir * (reach + SPACE_OFFSET_PX / zoom);
|
||||
@@ -291,29 +344,26 @@ impl SpaceWidget {
|
||||
finite.then_some(Self { group, last, last_length, at, dir, len, closed, count, start, gap, saved: (rec.spacing, rec.start) })
|
||||
}
|
||||
|
||||
/// The widget of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX))
|
||||
/// The widget of the group `g` of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> {
|
||||
Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(SPACE_HIT_PX))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over the Space widget?
|
||||
pub fn over_space(cx: &ToolContext, p: Point) -> bool {
|
||||
SpaceWidget::at_point(cx, p).is_some()
|
||||
one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| SpaceWidget::at_point(cx, &g, p)).is_some()
|
||||
}
|
||||
|
||||
/// The distance along the path of the Objects on Path group of `w` in `doc` to the point nearest
|
||||
/// `p` ([`Spine::nearest`]: past the ends of an open path, along the lines of its end segments),
|
||||
/// staying near `from` when the pointer is following the path from there. The line from the last
|
||||
/// object's attachment point through the widget counts as the path too, as far as the pointer is
|
||||
/// ahead of that object along it, so a widget beyond a corner measures from where it is. The flag
|
||||
/// says whether the point is on that line, where the distance runs on past the end of a closed path
|
||||
/// instead of round it.
|
||||
fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option<f64>) -> Option<(f64, bool)> {
|
||||
let n = doc.node(w.group)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
let path = attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?;
|
||||
let spine = Spine::new(path)?;
|
||||
/// The distance along the path of the Objects on Path group of `w`, `measured`, to the point nearest
|
||||
/// `p` ([`vectorcraft_doc::blend::Spine::nearest`]: past the ends of an open path, along the lines
|
||||
/// of its end segments), staying near `from` when the pointer is following the path from there. The
|
||||
/// line from the last object's attachment point through the widget counts as the path too, as far
|
||||
/// as the pointer is ahead of that object along it, so a widget beyond a corner measures from where
|
||||
/// it is. The flag says whether the point is on that line, where the distance runs on past the end
|
||||
/// of a closed path instead of round it.
|
||||
fn length_near(measured: &Measured, w: &SpaceWidget, p: Point, from: Option<f64>) -> Option<(f64, bool)> {
|
||||
let spine = measured.spine();
|
||||
let bias = |s: f64| from.filter(|f| f.is_finite()).map_or(0.0, |f| spine.apart(s, f) / 2.0);
|
||||
let ahead = (p - w.last).dot(w.dir);
|
||||
let line = (ahead >= 0.0).then(|| {
|
||||
@@ -334,6 +384,8 @@ fn length_near(doc: &Document, w: &SpaceWidget, p: Point, from: Option<f64>) ->
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SpaceDrag {
|
||||
widget: SpaceWidget,
|
||||
/// The group's path, measured.
|
||||
path: Kept,
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// The distance along the path to the point nearest the pointer at the last drag (points).
|
||||
@@ -353,17 +405,23 @@ impl SpaceDrag {
|
||||
|
||||
/// Start a drag when the press `ev` is on the Space widget.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = SpaceWidget::at_point(cx, ev.pos)?;
|
||||
let (last, _) = length_near(cx.doc, &widget, ev.pos, None)?;
|
||||
Some(Self { widget, start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos })
|
||||
Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev)
|
||||
}
|
||||
|
||||
/// [`SpaceDrag::hit`] on the group `g` of `cx`.
|
||||
fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = SpaceWidget::at_point(cx, g, ev.pos)?;
|
||||
let (last, _) = length_near(&g.measured, &widget, ev.pos, None)?;
|
||||
Some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, along: 0.0, gap: widget.gap, at: ev.pos })
|
||||
}
|
||||
|
||||
/// The pointer moved to `p` from `self.at`: add the distance it moved along the path. On the
|
||||
/// line through the widget, from a point ahead of the last object, that is how far it moved
|
||||
/// along the line; otherwise it takes the shorter way round a closed path.
|
||||
fn follow(&mut self, doc: &Document, p: Point) {
|
||||
let Some(measured) = self.path.now(doc) else { return };
|
||||
let w = &self.widget;
|
||||
let Some((t, on_line)) = length_near(doc, w, p, Some(self.last)) else { return };
|
||||
let Some((t, on_line)) = length_near(&measured, w, p, Some(self.last)) else { return };
|
||||
let before = (self.at - w.last).dot(w.dir);
|
||||
let step = if on_line && before >= 0.0 {
|
||||
(p - w.last).dot(w.dir) - before
|
||||
@@ -469,10 +527,15 @@ impl MoveAllWidget {
|
||||
/// the distance from the first object to the last is shorter than the path. It sits beyond the
|
||||
/// first object's bounds, on the side its bottom faces.
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Option<Self> {
|
||||
let (group, rec, children, path) = one_group(doc, selection, zoom)?;
|
||||
Self::in_group(doc, &one_group(doc, selection, zoom)?, zoom)
|
||||
}
|
||||
|
||||
/// The widget of the group `g`.
|
||||
fn in_group(doc: &Document, g: &Group, zoom: f64) -> Option<Self> {
|
||||
let (group, rec, children) = (g.id, g.rec, g.children);
|
||||
let count = rec.members.len();
|
||||
let first = rec.members.first()?;
|
||||
let (len, closed) = path_length(path)?;
|
||||
let (len, closed) = (g.measured.length(), g.measured.closed());
|
||||
let (start, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||||
let most = if closed { len } else { len - gap * count.saturating_sub(1) as f64 };
|
||||
if !closed && (!most.is_finite() || most <= 1e-9) {
|
||||
@@ -481,21 +544,21 @@ impl MoveAllWidget {
|
||||
// On an open path, Auto and a Space value the path is too short for draw the objects apart
|
||||
// by what is left of the path after the start, so another start would change the distance.
|
||||
let keeps = closed || count < 2 || rec.spacing.is_some_and(|g| (g - gap).abs() < 1e-9);
|
||||
let (at_first, _) = stop(path, count, rec.spacing, rec.start, 0)?;
|
||||
let (at_first, _) = g.measured.stop(count, rec.spacing, rec.start, 0)?;
|
||||
let at = beside(doc, children, first, at_first, -top(first), MOVE_ALL_OFFSET_PX / zoom)?;
|
||||
let finite = [len, start, most, rec.start].iter().all(|v| v.is_finite());
|
||||
finite.then_some(Self { group, first: at_first, at, len, closed, start, most, gap: (!keeps).then_some(gap), saved: rec.start })
|
||||
}
|
||||
|
||||
/// The widget of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
Self::of(cx.doc, cx.selection, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX))
|
||||
/// The widget of the group `g` of `cx` when `p` is within reach of it.
|
||||
fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> {
|
||||
Self::in_group(cx.doc, g, cx.zoom).filter(|w| w.at.distance(p) <= cx.tol(MOVE_ALL_HIT_PX))
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `p` over the Move All widget?
|
||||
pub fn over_move_all(cx: &ToolContext, p: Point) -> bool {
|
||||
MoveAllWidget::at_point(cx, p).is_some()
|
||||
one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MoveAllWidget::at_point(cx, &g, p)).is_some()
|
||||
}
|
||||
|
||||
/// Dragging the Move All widget: the distance the pointer moves along the path moves every object
|
||||
@@ -504,6 +567,8 @@ pub fn over_move_all(cx: &ToolContext, p: Point) -> bool {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MoveAllDrag {
|
||||
widget: MoveAllWidget,
|
||||
/// The group's path, measured.
|
||||
path: Kept,
|
||||
start: Point,
|
||||
began: bool,
|
||||
/// The distance along the path to the point nearest the pointer at the last drag (points).
|
||||
@@ -521,9 +586,14 @@ impl MoveAllDrag {
|
||||
|
||||
/// Start a drag when the press `ev` is on the Move All widget.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = MoveAllWidget::at_point(cx, ev.pos)?;
|
||||
let (_, last) = group_spine(cx.doc, widget.group)?.nearest(ev.pos, Some(widget.start))?;
|
||||
last.is_finite().then_some(Self { widget, start: ev.pos, began: false, last, to: widget.start, at: ev.pos })
|
||||
Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev)
|
||||
}
|
||||
|
||||
/// [`MoveAllDrag::hit`] on the group `g` of `cx`.
|
||||
fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = MoveAllWidget::at_point(cx, g, ev.pos)?;
|
||||
let (_, last) = g.measured.spine().nearest(ev.pos, Some(widget.start))?;
|
||||
last.is_finite().then_some(Self { widget, path: Kept::of(g), start: ev.pos, began: false, last, to: widget.start, at: ev.pos })
|
||||
}
|
||||
|
||||
/// The pointer moved to `p`: move the first object as far along the path as the point nearest
|
||||
@@ -531,8 +601,8 @@ impl MoveAllDrag {
|
||||
/// the first anchor and [`MoveAllWidget::most`], so moving back from beyond an end moves the
|
||||
/// objects back at once.
|
||||
fn follow(&mut self, doc: &Document, p: Point) {
|
||||
let Some((_, t)) = self.path.now(doc).and_then(|m| m.spine().nearest(p, Some(self.last))) else { return };
|
||||
let w = &self.widget;
|
||||
let Some((_, t)) = group_spine(doc, w.group).and_then(|s| s.nearest(p, Some(self.last))) else { return };
|
||||
let step = if w.closed { (t - self.last + w.len / 2.0).rem_euclid(w.len) - w.len / 2.0 } else { t - self.last };
|
||||
if !t.is_finite() || !step.is_finite() {
|
||||
return;
|
||||
@@ -604,8 +674,13 @@ impl MemberWidget {
|
||||
/// its order along the path that is neither hidden nor locked: only when the selection is one
|
||||
/// editable Objects on Path group on a path with length, and no anchors are Direct-Selected.
|
||||
pub fn all(doc: &Document, selection: &Selection, zoom: f64) -> Vec<Self> {
|
||||
let Some((group, rec, _, path)) = one_group(doc, selection, zoom) else { return vec![] };
|
||||
let Some(at) = stops(path, rec.members.len(), rec.spacing, rec.start) else { return vec![] };
|
||||
one_group(doc, selection, zoom).map(|g| Self::in_group(doc, selection, &g)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The widgets of the group `g`, the selection.
|
||||
fn in_group(doc: &Document, selection: &Selection, g: &Group) -> Vec<Self> {
|
||||
let (group, rec) = (g.id, g.rec);
|
||||
let at = g.measured.stops(rec.members.len(), rec.spacing, rec.start);
|
||||
let editable = doc.editable();
|
||||
rec.members
|
||||
.iter()
|
||||
@@ -616,17 +691,16 @@ impl MemberWidget {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The widget of `cx` nearest `p`, when `p` is within reach of it; of widgets as near, the
|
||||
/// chosen one, else the first along the path.
|
||||
fn at_point(cx: &ToolContext, p: Point) -> Option<Self> {
|
||||
/// The widget of the group `g` of `cx` nearest `p`, when `p` is within reach of it; of widgets
|
||||
/// as near, the chosen one, else the first along the path.
|
||||
fn at_point(cx: &ToolContext, g: &Group, p: Point) -> Option<Self> {
|
||||
let reach = cx.tol(MEMBER_HIT_PX);
|
||||
// Every attachment point lies on the path: a pointer beyond reach of its bounds is over none.
|
||||
let (_, _, _, path) = one_group(cx.doc, cx.selection, cx.zoom)?;
|
||||
if !path.control_bounds()?.inflate(reach, reach).contains(p) {
|
||||
if !g.path.control_bounds()?.inflate(reach, reach).contains(p) {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<(f64, Self)> = None;
|
||||
for w in Self::all(cx.doc, cx.selection, cx.zoom) {
|
||||
for w in Self::in_group(cx.doc, cx.selection, g) {
|
||||
let d = w.at.distance(p);
|
||||
let nearer = best.is_none_or(|(b, bw)| d < b - 1e-9 || (d <= b + 1e-9 && w.chosen && !bw.chosen));
|
||||
if d <= reach && nearer {
|
||||
@@ -639,17 +713,87 @@ impl MemberWidget {
|
||||
|
||||
/// Is `p` over a Select/Move widget?
|
||||
pub fn over_member(cx: &ToolContext, p: Point) -> bool {
|
||||
MemberWidget::at_point(cx, p).is_some()
|
||||
one_group(cx.doc, cx.selection, cx.zoom).and_then(|g| MemberWidget::at_point(cx, &g, p)).is_some()
|
||||
}
|
||||
|
||||
/// The highlight of the Select/Move widget under `p`, if any: a ring around it.
|
||||
pub fn member_hover(cx: &ToolContext, p: Point) -> Option<Overlay> {
|
||||
let w = MemberWidget::at_point(cx, p)?;
|
||||
let w = MemberWidget::at_point(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, p)?;
|
||||
let r = cx.tol(f64::from(MEMBER_RADIUS_PX) + 2.0);
|
||||
let ring = vectorcraft_geom::shapes::ellipse(vectorcraft_geom::Rect::new(w.at.x - r, w.at.y - r, w.at.x + r, w.at.y + r));
|
||||
Some(Overlay::Path { path: ring.to_bezpath(), color: HIGHLIGHT, width: 1.5, dashed: false })
|
||||
}
|
||||
|
||||
/// The widgets of the selection at `zoom` (screen px per document point), each as its own `of` or
|
||||
/// `all` gives it, from one measurement of the group's path: for drawing them together.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Widgets {
|
||||
pub members: Vec<MemberWidget>,
|
||||
pub rotate_all: Option<RotateAllWidget>,
|
||||
pub space: Option<SpaceWidget>,
|
||||
pub move_all: Option<MoveAllWidget>,
|
||||
}
|
||||
|
||||
impl Widgets {
|
||||
pub fn of(doc: &Document, selection: &Selection, zoom: f64) -> Self {
|
||||
let Some(g) = one_group(doc, selection, zoom) else { return Self::default() };
|
||||
Self {
|
||||
members: MemberWidget::in_group(doc, selection, &g),
|
||||
rotate_all: RotateAllWidget::in_group(doc, &g, zoom),
|
||||
space: SpaceWidget::in_group(doc, &g, zoom),
|
||||
move_all: MoveAllWidget::in_group(doc, &g, zoom),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget of a selected Objects on Path group.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum WidgetKind {
|
||||
RotateAll,
|
||||
Space,
|
||||
MoveAll,
|
||||
Member,
|
||||
}
|
||||
|
||||
/// The widget `p` is over, of the Rotate All, Space, Move All and Select/Move widgets in that
|
||||
/// order, from one measurement of the group's path: as [`over_rotate_all`], [`over_space`],
|
||||
/// [`over_move_all`] and [`over_member`] answer in turn.
|
||||
pub fn widget_at(cx: &ToolContext, p: Point) -> Option<WidgetKind> {
|
||||
let g = one_group(cx.doc, cx.selection, cx.zoom)?;
|
||||
if RotateAllWidget::at_point(cx, &g, p).is_some() {
|
||||
Some(WidgetKind::RotateAll)
|
||||
} else if SpaceWidget::at_point(cx, &g, p).is_some() {
|
||||
Some(WidgetKind::Space)
|
||||
} else if MoveAllWidget::at_point(cx, &g, p).is_some() {
|
||||
Some(WidgetKind::MoveAll)
|
||||
} else {
|
||||
MemberWidget::at_point(cx, &g, p).map(|_| WidgetKind::Member)
|
||||
}
|
||||
}
|
||||
|
||||
/// A drag of a widget of a selected Objects on Path group.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WidgetDrag {
|
||||
RotateAll(RotateAllDrag),
|
||||
Space(SpaceDrag),
|
||||
MoveAll(MoveAllDrag),
|
||||
Member(MemberDrag),
|
||||
}
|
||||
|
||||
impl WidgetDrag {
|
||||
/// The drag the press `ev` starts, of the Rotate All, Space, Move All and Select/Move widgets
|
||||
/// in that order, from one measurement of the group's path: as [`RotateAllDrag::hit`],
|
||||
/// [`SpaceDrag::hit`], [`MoveAllDrag::hit`] and [`MemberDrag::hit`] answer in turn.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let g = one_group(cx.doc, cx.selection, cx.zoom)?;
|
||||
RotateAllDrag::hit_in(cx, &g, ev)
|
||||
.map(Self::RotateAll)
|
||||
.or_else(|| SpaceDrag::hit_in(cx, &g, ev).map(Self::Space))
|
||||
.or_else(|| MoveAllDrag::hit_in(cx, &g, ev).map(Self::MoveAll))
|
||||
.or_else(|| MemberDrag::hit_in(cx, &g, ev).map(Self::Member))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dragging a Select/Move widget: the object goes to the place along the path nearest the
|
||||
/// pointer, and the objects between it and its place move one place toward where it was, except
|
||||
/// hidden and locked ones (`object.objectsOnPath.moveMember`). On an open path a pointer beyond
|
||||
@@ -661,6 +805,8 @@ pub fn member_hover(cx: &ToolContext, p: Point) -> Option<Overlay> {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MemberDrag {
|
||||
widget: MemberWidget,
|
||||
/// The group's path, measured.
|
||||
path: Kept,
|
||||
/// The objects in their order along the path when the press began.
|
||||
order: Vec<NodeId>,
|
||||
/// The distance along the path to each place (points), in that order.
|
||||
@@ -687,10 +833,14 @@ impl MemberDrag {
|
||||
|
||||
/// Start a drag when the press `ev` is on a Select/Move widget.
|
||||
pub fn hit(cx: &ToolContext, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = MemberWidget::at_point(cx, ev.pos)?;
|
||||
let n = cx.doc.node(widget.group)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
let (len, closed) = path_length(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)?;
|
||||
Self::hit_in(cx, &one_group(cx.doc, cx.selection, cx.zoom)?, ev)
|
||||
}
|
||||
|
||||
/// [`MemberDrag::hit`] on the group `g` of `cx`.
|
||||
fn hit_in(cx: &ToolContext, g: &Group, ev: &PointerEvent) -> Option<Self> {
|
||||
let widget = MemberWidget::at_point(cx, g, ev.pos)?;
|
||||
let rec = g.rec;
|
||||
let (len, closed) = (g.measured.length(), g.measured.closed());
|
||||
let count = rec.members.len();
|
||||
let (s0, gap) = layout(len, closed, count, rec.spacing, rec.start);
|
||||
let places: Vec<f64> = (0..count).map(|i| nth_length(len, closed, s0, gap, i)).collect();
|
||||
@@ -698,7 +848,7 @@ impl MemberDrag {
|
||||
let editable = cx.doc.editable();
|
||||
let open = rec.members.iter().map(|m| editable(m.id)).collect();
|
||||
let order = rec.members.iter().map(|m| m.id).collect();
|
||||
Some(Self { widget, order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index })
|
||||
Some(Self { widget, path: Kept::of(g), order, places, open, len, closed, start: ev.pos, began: false, last, target: widget.index })
|
||||
}
|
||||
|
||||
/// How far apart along the path the distances `a` and `b` are: the shorter way round a closed
|
||||
@@ -733,8 +883,8 @@ impl MemberDrag {
|
||||
self.began = true;
|
||||
out.push(Action::Begin("Move Object on Path".into()));
|
||||
}
|
||||
let w = &self.widget;
|
||||
let Some(spine) = group_spine(cx.doc, w.group) else { return out };
|
||||
let Some(measured) = self.path.now(cx.doc) else { return out };
|
||||
let (w, spine) = (&self.widget, measured.spine());
|
||||
let Some((near, t)) = spine.nearest(p, None) else { return out };
|
||||
// Where the path passes as near the pointer more than once, the point nearest along the path
|
||||
// to where the drag was counts.
|
||||
@@ -767,13 +917,6 @@ impl MemberDrag {
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of the Objects on Path group `g` of `doc`, to measure along.
|
||||
fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> {
|
||||
let n = doc.node(g)?;
|
||||
let rec = n.extras().objects_on_path.as_deref()?;
|
||||
Spine::new(attachable_path(n.children()?.iter().find(|c| c.id == rec.path)?)?)
|
||||
}
|
||||
|
||||
/// The path a click at `p` attaches the selected objects to: the path object hit there, when it can
|
||||
/// take objects, is in no Objects on Path group, is not a clipping path or part of a compound path,
|
||||
/// blend, envelope, repeat or Intertwine group ([`vectorcraft_doc::objects_on_path::held`]), and
|
||||
@@ -781,14 +924,13 @@ fn group_spine(doc: &Document, g: NodeId) -> Option<Spine> {
|
||||
pub fn target(cx: &ToolContext, p: Point) -> Option<(NodeId, BezPath)> {
|
||||
let h = vectorcraft_doc::hit::hit_test(cx.doc, p, cx.hit_options())?;
|
||||
let n = cx.doc.node(h.leaf)?;
|
||||
let path = attachable_path(n)?;
|
||||
if h.ancestry.iter().filter(|a| **a != h.leaf).any(|a| cx.doc.node(*a).is_some_and(is_objects_on_path)) {
|
||||
return None;
|
||||
}
|
||||
if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() {
|
||||
if vectorcraft_doc::objects_on_path::held(cx.doc, h.leaf).is_some() || !cx.selection.objects.iter().any(|id| *id != h.leaf) {
|
||||
return None;
|
||||
}
|
||||
cx.selection.objects.iter().any(|id| *id != h.leaf).then(|| (h.leaf, path.to_bezpath()))
|
||||
attachable_path(n).map(|path| (h.leaf, path.to_bezpath()))
|
||||
}
|
||||
|
||||
pub struct ObjectsOnPathTool {
|
||||
@@ -856,28 +998,17 @@ impl Tool for ObjectsOnPathTool {
|
||||
self.pointer = (ev.kind == PointerKind::Move).then_some(p);
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
let widget = over_rotate_all(cx, p) || over_space(cx, p) || over_move_all(cx, p) || over_member(cx, p);
|
||||
self.hover = if widget { None } else { target(cx, p) };
|
||||
self.hover = if widget_at(cx, p).is_some() { None } else { target(cx, p) };
|
||||
vec![]
|
||||
}
|
||||
PointerKind::Down => {
|
||||
if let Some(d) = RotateAllDrag::hit(cx, ev) {
|
||||
self.rotating = Some(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(d) = SpaceDrag::hit(cx, ev) {
|
||||
self.spacing = Some(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(d) = MoveAllDrag::hit(cx, ev) {
|
||||
self.moving = Some(d);
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
if let Some(d) = MemberDrag::hit(cx, ev) {
|
||||
self.member = Some(d);
|
||||
if let Some(d) = WidgetDrag::hit(cx, ev) {
|
||||
match d {
|
||||
WidgetDrag::RotateAll(d) => self.rotating = Some(d),
|
||||
WidgetDrag::Space(d) => self.spacing = Some(d),
|
||||
WidgetDrag::MoveAll(d) => self.moving = Some(d),
|
||||
WidgetDrag::Member(d) => self.member = Some(d),
|
||||
}
|
||||
self.hover = None;
|
||||
return vec![];
|
||||
}
|
||||
@@ -917,17 +1048,21 @@ impl Tool for ObjectsOnPathTool {
|
||||
if self.member.is_some() {
|
||||
return Cursor::Move;
|
||||
}
|
||||
let idle = self.spacing.is_none() && self.moving.is_none();
|
||||
if self.rotating.is_some() || (idle && over_rotate_all(cx, p)) {
|
||||
Cursor::Rotate
|
||||
} else if self.spacing.is_some() || (self.moving.is_none() && over_space(cx, p)) {
|
||||
Cursor::ArrowHollow
|
||||
} else if self.moving.is_some() || over_move_all(cx, p) || over_member(cx, p) {
|
||||
Cursor::Move
|
||||
} else if target(cx, p).is_some() {
|
||||
Cursor::ObjectsOnPath
|
||||
} else {
|
||||
Cursor::Arrow
|
||||
if self.rotating.is_some() {
|
||||
return Cursor::Rotate;
|
||||
}
|
||||
if self.spacing.is_some() {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if self.moving.is_some() {
|
||||
return Cursor::Move;
|
||||
}
|
||||
match widget_at(cx, p) {
|
||||
Some(WidgetKind::RotateAll) => Cursor::Rotate,
|
||||
Some(WidgetKind::Space) => Cursor::ArrowHollow,
|
||||
Some(WidgetKind::MoveAll | WidgetKind::Member) => Cursor::Move,
|
||||
None if target(cx, p).is_some() => Cursor::ObjectsOnPath,
|
||||
None => Cursor::Arrow,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1405,7 +1540,10 @@ mod tests {
|
||||
gap: 100.0,
|
||||
saved: (None, 0.0),
|
||||
};
|
||||
let moved = |along| SpaceDrag { widget: w, start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO };
|
||||
let line = shapes::line(Point::ZERO, Point::new(400.0, 0.0));
|
||||
let path = Kept { group: NodeId(1), measured: Arc::new(Measured::new(&line).unwrap()), path: line };
|
||||
let moved =
|
||||
|along| SpaceDrag { widget: w, path: path.clone(), start: Point::ZERO, began: true, last: 0.0, along, gap: w.gap, at: Point::ZERO };
|
||||
assert_eq!(moved(30.0).spacing_at(false), (100.0, 0.0), "a quarter of the way round at most");
|
||||
assert_eq!(moved(-60.0).spacing_at(false), (80.0, 0.0));
|
||||
assert_eq!(moved(-60.0).spacing_at(true), (60.0, 310.0), "the span of 180 centered on the first anchor");
|
||||
@@ -1958,4 +2096,168 @@ mod tests {
|
||||
t.pointer(&c, &ev(PointerKind::Down, 0.0, 300.0));
|
||||
assert_eq!(t.pointer(&c, &ev(PointerKind::Up, 0.0, 300.0)), vec![choose(ids[2])], "the chosen one");
|
||||
}
|
||||
|
||||
/// What `f` returns, and how many times it flattened a path.
|
||||
fn flattenings<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||
let before = vectorcraft_doc::blend::spines_built();
|
||||
let out = f();
|
||||
(out, vectorcraft_doc::blend::spines_built() - before)
|
||||
}
|
||||
|
||||
/// Space 100 from 50 on the line from (0, 300) to (400, 300): the objects attach at (50, 300)
|
||||
/// and (150, 300). The Rotate All widget is at (50, 274), the Move All widget at (50, 326) and
|
||||
/// the Space widget at (176, 300).
|
||||
#[test]
|
||||
fn each_widget_lookup_flattens_the_path_once() {
|
||||
let (d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
// Points to 1/1,000 pt, as the path is measured along its flattening.
|
||||
let close = |a: Point, b: Point| a.distance(b) < 1e-3;
|
||||
let (w, n) = flattenings(|| RotateAllWidget::of(&d, &sel, 1.0));
|
||||
assert_eq!(n, 1, "Rotate All");
|
||||
let w = w.unwrap();
|
||||
assert!(w.group == g && w.rotate == 0.0, "{w:?}");
|
||||
assert!(close(w.pivot, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 274.0)), "{w:?}");
|
||||
let (w, n) = flattenings(|| SpaceWidget::of(&d, &sel, 1.0));
|
||||
assert_eq!(n, 1, "Space");
|
||||
let w = w.unwrap();
|
||||
assert!(close(w.last, Point::new(150.0, 300.0)) && close(w.at, Point::new(176.0, 300.0)), "{w:?}");
|
||||
assert!((w.last_length - 150.0).abs() < 1e-3 && (w.len - 400.0).abs() < 1e-3 && !w.closed && w.count == 2, "{w:?}");
|
||||
assert!((w.start - 50.0).abs() < 1e-3 && (w.gap - 100.0).abs() < 1e-3 && w.saved == (Some(100.0), 50.0), "{w:?}");
|
||||
let (w, n) = flattenings(|| MoveAllWidget::of(&d, &sel, 1.0));
|
||||
assert_eq!(n, 1, "Move All");
|
||||
let w = w.unwrap();
|
||||
assert!(close(w.first, Point::new(50.0, 300.0)) && close(w.at, Point::new(50.0, 326.0)), "{w:?}");
|
||||
assert!((w.len - 400.0).abs() < 1e-3 && (w.start - 50.0).abs() < 1e-3 && (w.most - 300.0).abs() < 1e-3, "{w:?}");
|
||||
assert!(w.gap.is_none() && w.saved == 50.0 && !w.closed, "{w:?}");
|
||||
let (ws, n) = flattenings(|| MemberWidget::all(&d, &sel, 1.0));
|
||||
assert_eq!(n, 1, "Select/Move");
|
||||
assert_eq!(ws.iter().map(|w| w.index).collect::<Vec<_>>(), [0, 1]);
|
||||
assert!(close(ws[0].at, Point::new(50.0, 300.0)) && close(ws[1].at, Point::new(150.0, 300.0)), "{ws:?}");
|
||||
// Each hit test, over its widget.
|
||||
type Over = fn(&ToolContext, Point) -> bool;
|
||||
let hits: [(&str, Over, Point); 4] = [
|
||||
("Rotate All", over_rotate_all, Point::new(50.0, 274.0)),
|
||||
("Space", over_space, Point::new(176.0, 300.0)),
|
||||
("Move All", over_move_all, Point::new(50.0, 326.0)),
|
||||
("Select/Move", over_member, Point::new(150.0, 300.0)),
|
||||
];
|
||||
for (what, over, p) in hits {
|
||||
assert_eq!(flattenings(|| over(&c, p)), (true, 1), "{what}");
|
||||
}
|
||||
assert_eq!(flattenings(|| member_hover(&c, Point::new(150.0, 300.0)).is_some()), (true, 1));
|
||||
// A group of one object has no Space widget, and its objects no room to move on an open
|
||||
// path: one flattening finds that.
|
||||
let (d1, sel1) = single_doc();
|
||||
assert_eq!(flattenings(|| SpaceWidget::of(&d1, &sel1, 1.0)), (None, 1));
|
||||
let (d2, _, sel2) = group_doc(0.0);
|
||||
assert_eq!(flattenings(|| MoveAllWidget::of(&d2, &sel2, 1.0)), (None, 1));
|
||||
// Nothing flattens for a selection the widgets do not show for.
|
||||
assert_eq!(flattenings(|| Widgets::of(&d, &Selection::default(), 1.0)), (Widgets::default(), 0));
|
||||
}
|
||||
|
||||
/// All four kinds of widget come from one flattening, as each one's own lookup gives it; and the
|
||||
/// widget under the pointer, in the order a press takes them, from one flattening too.
|
||||
#[test]
|
||||
fn the_widgets_of_a_group_share_one_flattening() {
|
||||
let (d, _, sel) = spaced_doc(Some(100.0), 50.0);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let (all, n) = flattenings(|| Widgets::of(&d, &sel, 1.0));
|
||||
assert_eq!(n, 1);
|
||||
assert_eq!(all.members, MemberWidget::all(&d, &sel, 1.0));
|
||||
assert_eq!(all.rotate_all, RotateAllWidget::of(&d, &sel, 1.0));
|
||||
assert_eq!(all.space, SpaceWidget::of(&d, &sel, 1.0));
|
||||
assert_eq!(all.move_all, MoveAllWidget::of(&d, &sel, 1.0));
|
||||
assert!(all.rotate_all.is_some() && all.space.is_some() && all.move_all.is_some() && all.members.len() == 2);
|
||||
let cases = [
|
||||
(Point::new(50.0, 274.0), Some(WidgetKind::RotateAll)),
|
||||
(Point::new(176.0, 300.0), Some(WidgetKind::Space)),
|
||||
(Point::new(50.0, 326.0), Some(WidgetKind::MoveAll)),
|
||||
(Point::new(150.0, 300.0), Some(WidgetKind::Member)),
|
||||
// On the path between the objects: over none of them, after every one is tested.
|
||||
(Point::new(250.0, 300.0), None),
|
||||
];
|
||||
for (p, kind) in cases {
|
||||
assert_eq!(flattenings(|| widget_at(&c, p)), (kind, 1), "{p:?}");
|
||||
}
|
||||
// A pointer move and the cursor of each tool look once.
|
||||
for t in [Box::new(crate::select::SelectionTool::default()) as Box<dyn Tool>, Box::new(ObjectsOnPathTool::default())] {
|
||||
for (p, cursor) in [(Point::new(50.0, 274.0), Cursor::Rotate), (Point::new(176.0, 300.0), Cursor::ArrowHollow)] {
|
||||
assert_eq!(flattenings(|| t.cursor(&c, p, Mods::default())), (cursor, 1), "{}", t.id());
|
||||
}
|
||||
}
|
||||
let mut t = ObjectsOnPathTool::default();
|
||||
assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Move, 250.0, 300.0))), (vec![], 1));
|
||||
}
|
||||
|
||||
/// A press on each widget measures the path once, and the drag that follows measures it no more
|
||||
/// while the path stays as it was.
|
||||
#[test]
|
||||
fn a_widget_drag_flattens_the_path_once() {
|
||||
let (d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let presses = [
|
||||
("Rotate All", Point::new(50.0, 274.0), Point::new(24.0, 300.0)),
|
||||
("Space", Point::new(176.0, 300.0), Point::new(226.0, 300.0)),
|
||||
("Move All", Point::new(50.0, 326.0), Point::new(100.0, 326.0)),
|
||||
("Select/Move", Point::new(150.0, 300.0), Point::new(40.0, 300.0)),
|
||||
];
|
||||
let tools: [Box<dyn Fn() -> Box<dyn Tool>>; 2] =
|
||||
[Box::new(|| Box::new(crate::select::SelectionTool::default())), Box::new(|| Box::new(ObjectsOnPathTool::default()))];
|
||||
for new in &tools {
|
||||
for (what, at, to) in presses {
|
||||
let mut t = new();
|
||||
assert_eq!(flattenings(|| t.pointer(&c, &ev(PointerKind::Down, at.x, at.y))), (vec![], 1), "{what} {}", t.id());
|
||||
assert!(t.busy(), "{what} {}", t.id());
|
||||
let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x, to.y)));
|
||||
assert_eq!(n, 0, "{what} {}", t.id());
|
||||
assert!(matches!(acts.last(), Some(Action::Preview(..))), "{what} {}: {acts:?}", t.id());
|
||||
let (acts, n) = flattenings(|| t.pointer(&c, &ev(PointerKind::Drag, to.x + 1.0, to.y)));
|
||||
assert_eq!(n, 0, "{what} {}: {acts:?}", t.id());
|
||||
}
|
||||
}
|
||||
// The drags as each one's own press starts it.
|
||||
type Hit = fn(&ToolContext, &PointerEvent) -> bool;
|
||||
let checks: [(&str, Hit, Point); 4] = [
|
||||
("Rotate All", |c, e| RotateAllDrag::hit(c, e).is_some(), Point::new(50.0, 274.0)),
|
||||
("Space", |c, e| SpaceDrag::hit(c, e).is_some(), Point::new(176.0, 300.0)),
|
||||
("Move All", |c, e| MoveAllDrag::hit(c, e).is_some(), Point::new(50.0, 326.0)),
|
||||
("Select/Move", |c, e| MemberDrag::hit(c, e).is_some(), Point::new(150.0, 300.0)),
|
||||
];
|
||||
for (what, hit, p) in checks {
|
||||
assert_eq!(flattenings(|| hit(&c, &ev(PointerKind::Down, p.x, p.y))), (true, 1), "{what}");
|
||||
}
|
||||
// The Move All drag moves the objects by what the pointer moves along the path: 50 pt.
|
||||
let mut d = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap();
|
||||
let acts = d.drag(&c, Point::new(100.0, 326.0));
|
||||
assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}");
|
||||
assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}");
|
||||
}
|
||||
|
||||
/// A drag whose group's path changes while it goes on measures the new path, once.
|
||||
#[test]
|
||||
fn a_widget_drag_follows_a_path_that_changed() {
|
||||
let (d, g, sel) = spaced_doc(Some(100.0), 50.0);
|
||||
let paint = paint();
|
||||
let c = cx(&d, &sel, &paint);
|
||||
let mut drag = MoveAllDrag::hit(&c, &ev(PointerKind::Down, 50.0, 326.0)).unwrap();
|
||||
// The path now runs from (100, 300) to (500, 300): the pointer at (200, 326) is 100 pt along
|
||||
// it, 50 pt on from where the press was.
|
||||
let mut longer = d.clone();
|
||||
let line = longer.node(g).and_then(|n| n.children()).unwrap()[0].id;
|
||||
if let Some(NodeKind::Path { path, .. }) = longer.node_mut(line).map(|n| &mut n.kind) {
|
||||
*path = shapes::line(Point::new(100.0, 300.0), Point::new(500.0, 300.0));
|
||||
}
|
||||
let c = cx(&longer, &sel, &paint);
|
||||
let (acts, n) = flattenings(|| drag.drag(&c, Point::new(200.0, 326.0)));
|
||||
assert_eq!(n, 1);
|
||||
assert!(matches!(acts.last(), Some(Action::Preview(_, p)) if p["ids"] == json!([g.0])), "{acts:?}");
|
||||
assert_eq!(moved(&acts), Some((100.0, None)), "{acts:?}");
|
||||
let (acts, n) = flattenings(|| drag.drag(&c, Point::new(300.0, 326.0)));
|
||||
assert_eq!(n, 0);
|
||||
assert_eq!(moved(&acts), Some((200.0, None)), "{acts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
+13
-25
@@ -48,9 +48,7 @@ use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_dra
|
||||
use crate::blendwidget::{self, BlendWidgetDrag};
|
||||
use crate::corners::{self, CornerDrag, over_widget};
|
||||
use crate::guides::{PointSnap, Targets, distance_guides, hold_on_path};
|
||||
use crate::objects_on_path::{
|
||||
MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, member_hover, over_member, over_move_all, over_rotate_all, over_space,
|
||||
};
|
||||
use crate::objects_on_path::{MemberDrag, MoveAllDrag, RotateAllDrag, SpaceDrag, WidgetDrag, WidgetKind, member_hover, widget_at};
|
||||
use crate::pathtype::{self, BracketDrag, over_bracket};
|
||||
use crate::pie::{self, PieDrag};
|
||||
use crate::rulerguide::GuideEdit;
|
||||
@@ -1269,14 +1267,11 @@ impl Tool for SelectionTool {
|
||||
if blendwidget::over_widget(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if over_rotate_all(cx, p) {
|
||||
return Cursor::Rotate;
|
||||
}
|
||||
if over_space(cx, p) {
|
||||
return Cursor::ArrowHollow;
|
||||
}
|
||||
if over_move_all(cx, p) || over_member(cx, p) {
|
||||
return Cursor::Move;
|
||||
match widget_at(cx, p) {
|
||||
Some(WidgetKind::RotateAll) => return Cursor::Rotate,
|
||||
Some(WidgetKind::Space) => return Cursor::ArrowHollow,
|
||||
Some(WidgetKind::MoveAll | WidgetKind::Member) => return Cursor::Move,
|
||||
None => {}
|
||||
}
|
||||
if over_bracket(cx, p) {
|
||||
return Cursor::PathBracket;
|
||||
@@ -1651,20 +1646,13 @@ impl SelectionTool {
|
||||
self.state = State::BlendWidget(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = RotateAllDrag::hit(cx, ev) {
|
||||
self.state = State::RotateAll(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = SpaceDrag::hit(cx, ev) {
|
||||
self.state = State::Space(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = MoveAllDrag::hit(cx, ev) {
|
||||
self.state = State::MoveAll(d);
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(d) = MemberDrag::hit(cx, ev) {
|
||||
self.state = State::Member(d);
|
||||
if let Some(d) = WidgetDrag::hit(cx, ev) {
|
||||
self.state = match d {
|
||||
WidgetDrag::RotateAll(d) => State::RotateAll(d),
|
||||
WidgetDrag::Space(d) => State::Space(d),
|
||||
WidgetDrag::MoveAll(d) => State::MoveAll(d),
|
||||
WidgetDrag::Member(d) => State::Member(d),
|
||||
};
|
||||
return Some(vec![]);
|
||||
}
|
||||
if let Some(b) = BracketDrag::hit(cx, ev) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Colour reduction and noise removal. Colours are compared in Oklab, where equal distances look
|
||||
//! equally different.
|
||||
|
||||
use crate::{Mode, Palette, Raster, TraceParams};
|
||||
use crate::{Mode, Palette, Raster, TraceParams, parallel_map};
|
||||
|
||||
mod cut_edge;
|
||||
mod denoise;
|
||||
@@ -191,6 +191,11 @@ fn own_colours(img: &Raster) -> Vec<bool> {
|
||||
/// [`own_colours`] among the pixels that `among` marks: the others are neither own colours nor
|
||||
/// the neighbours a pixel is a blend of.
|
||||
fn own_colours_among(img: &Raster, among: &[bool]) -> Vec<bool> {
|
||||
own_colours_among_by(img, among, PIXELS_PER_SHARE)
|
||||
}
|
||||
|
||||
/// [`own_colours_among`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]).
|
||||
fn own_colours_among_by(img: &Raster, among: &[bool], per_share: usize) -> Vec<bool> {
|
||||
let (w, h) = (img.width as isize, img.height as isize);
|
||||
let px = img.rgba.as_chunks::<4>().0;
|
||||
let at = |x: isize, y: isize| {
|
||||
@@ -198,28 +203,52 @@ fn own_colours_among(img: &Raster, among: &[bool]) -> Vec<bool> {
|
||||
let p = px.get(i).filter(|_| among.get(i).copied().unwrap_or(false))?;
|
||||
Some([f64::from(p[0]), f64::from(p[1]), f64::from(p[2])])
|
||||
};
|
||||
let mut out = Vec::with_capacity(px.len());
|
||||
for y in 0..h {
|
||||
for x in 0..w {
|
||||
let Some(p) = at(x, y) else {
|
||||
out.push(false);
|
||||
continue;
|
||||
};
|
||||
let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| {
|
||||
let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false };
|
||||
let d = [0, 1, 2].map(|k| b[k] - a[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
if len2 <= 400.0 {
|
||||
return false;
|
||||
}
|
||||
let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::<f64>() / len2;
|
||||
let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum();
|
||||
t > 0.06 && t < 0.94 && off2 < len2 / 16.0
|
||||
});
|
||||
out.push(!blend);
|
||||
over_rows(img.width as usize, img.height as usize, per_share, |rows| {
|
||||
let mut out = Vec::with_capacity(rows.len() * img.width as usize);
|
||||
for y in rows.start as isize..rows.end as isize {
|
||||
for x in 0..w {
|
||||
let Some(p) = at(x, y) else {
|
||||
out.push(false);
|
||||
continue;
|
||||
};
|
||||
let blend = [(1, 0), (0, 1), (1, 1), (1, -1)].into_iter().any(|(dx, dy)| {
|
||||
let (Some(a), Some(b)) = (at(x - dx, y - dy), at(x + dx, y + dy)) else { return false };
|
||||
let d = [0, 1, 2].map(|k| b[k] - a[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
if len2 <= 400.0 {
|
||||
return false;
|
||||
}
|
||||
let t = (0..3).map(|k| (p[k] - a[k]) * d[k]).sum::<f64>() / len2;
|
||||
let off2: f64 = (0..3).map(|k| (p[k] - a[k] - t * d[k]).powi(2)).sum();
|
||||
t > 0.06 && t < 0.94 && off2 < len2 / 16.0
|
||||
});
|
||||
out.push(!blend);
|
||||
}
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
/// About how many pixels one thread works through at a time in the passes over every pixel that
|
||||
/// [`over_rows`] spreads over the available cores. A picture of fewer pixels is worked through on
|
||||
/// one thread.
|
||||
const PIXELS_PER_SHARE: usize = 1 << 16;
|
||||
|
||||
/// `f` of each band of rows of a picture `w` pixels wide and `h` rows high, each band about
|
||||
/// `per_share` pixels (at least one row), spread over the available cores (one thread on the web),
|
||||
/// and the results joined in row order. Each band's result depends only on its rows, so the
|
||||
/// joined result is the same however many threads there are. A picture with no pixels gives no
|
||||
/// result.
|
||||
fn over_rows<R: Send>(w: usize, h: usize, per_share: usize, f: impl Fn(std::ops::Range<usize>) -> Vec<R> + Sync) -> Vec<R> {
|
||||
if w == 0 || h == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
out
|
||||
let rows = (per_share / w.max(1)).max(1);
|
||||
let bands: Vec<std::ops::Range<usize>> = (0..h).step_by(rows).map(|y| y..(y + rows).min(h)).collect();
|
||||
if bands.len() <= 1 {
|
||||
return bands.into_iter().flat_map(&f).collect();
|
||||
}
|
||||
parallel_map(&bands, |rows| f(rows.clone())).into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
/// Whether the palette is made from the picture's own colours alone (`own` of `all` opaque
|
||||
@@ -325,3 +354,40 @@ fn merge_mixed(q: Quantized, w: usize, h: usize, keep: &[[u8; 3]]) -> Quantized
|
||||
let labels = q.labels.iter().map(|&l| into.get(l as usize).map_or(l, |&t| t as u16)).collect();
|
||||
compact(labels, color.iter().map(|c| [c[0].round() as u8, c[1].round() as u8, c[2].round() as u8]).collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn blend_pixels_are_found_the_same_however_the_picture_is_shared_out() {
|
||||
// Slanted stripes of red and blue, blended over three pixels at each edge, with a little
|
||||
// noise and a band of half-transparent pixels along the bottom. Odd sizes, so that the last
|
||||
// band of rows is short.
|
||||
let (w, h) = (173u32, 97u32);
|
||||
let (red, blue) = ([200.0, 30.0, 40.0], [20.0, 60.0, 180.0]);
|
||||
let img = Raster::from_fn(w, h, |x, y| {
|
||||
let s = (x + y / 2) % 24;
|
||||
let t = f64::from(s.saturating_sub(9).min(3)) / 3.0 - f64::from(s.saturating_sub(21).min(3)) / 3.0;
|
||||
let noise = f64::from((x * 7 + y * 13) % 5) - 2.0;
|
||||
let c = [0, 1, 2].map(|k| (red[k] + t * (blue[k] - red[k]) + noise).clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }]
|
||||
});
|
||||
let all = w as usize * h as usize;
|
||||
let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect();
|
||||
// One row per share, a few rows per share, and the whole picture as one.
|
||||
let own: Vec<Vec<bool>> = [1, 3 * w as usize + 5, all].iter().map(|&s| own_colours_among_by(&img, &opaque, s)).collect();
|
||||
assert_eq!(own[0].len(), all);
|
||||
assert!(own[0].iter().any(|&o| !o), "the edges have blend pixels");
|
||||
assert!(own.iter().all(|o| *o == own[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_with_no_pixels_is_not_shared_out() {
|
||||
// No band for each of the rows of a picture with no columns, however many rows it has.
|
||||
assert!(over_rows(0, usize::MAX, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty());
|
||||
assert!(over_rows(5, 0, PIXELS_PER_SHARE, |_| vec![0u8]).is_empty());
|
||||
let img = Raster::new(0, u32::MAX, vec![]).unwrap();
|
||||
assert!(own_colours_among(&img, &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::palette::{Bin, HistKey, Used, centers, median_cut, nearest_lab};
|
||||
use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab};
|
||||
use super::{Mode, PIXELS_PER_SHARE, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, oklab, over_rows};
|
||||
use crate::parallel_map;
|
||||
|
||||
/// `q` with its blend pixels settled, and the colours that leaves unused dropped.
|
||||
///
|
||||
@@ -10,59 +11,136 @@ use super::{Mode, Palette, Quantized, Raster, TRANSPARENT, TraceParams, luma, ok
|
||||
/// own pixels within two pixels of it takes the side it is nearer to of the pair of those colours
|
||||
/// it is best a blend of, when that explains it twice as well as its own colour does. With one
|
||||
/// colour around it, it keeps its own.
|
||||
pub(super) fn settled(mut q: Quantized, img: &Raster, own: &[bool]) -> Quantized {
|
||||
pub(super) fn settled(q: Quantized, img: &Raster, own: &[bool]) -> Quantized {
|
||||
settled_by(q, img, own, PIXELS_PER_SHARE)
|
||||
}
|
||||
|
||||
/// [`settled`], with the rows worked on in bands of about `per_share` pixels (see [`over_rows`]).
|
||||
fn settled_by(mut q: Quantized, img: &Raster, own: &[bool], per_share: usize) -> Quantized {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if q.labels.len() != w * h || own.len() != w * h {
|
||||
return q;
|
||||
}
|
||||
let before = q.labels.clone();
|
||||
// Each pixel is settled by the labels as they were before any label changed. Each band of rows
|
||||
// keeps its changes (see [`Changes`]), and they are made once every band is done.
|
||||
let before = &q.labels;
|
||||
let color = |l: u16| q.palette.get(l as usize).map(|c| c.map(f64::from));
|
||||
let dist = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>().sqrt();
|
||||
let mut changed = false;
|
||||
for i in 0..w * h {
|
||||
let mine_l = before[i];
|
||||
if own[i] || mine_l == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
// The colours of the picture's own pixels within two pixels (seldom more than three).
|
||||
let mut near: Vec<u16> = Vec::new();
|
||||
for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) {
|
||||
for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) {
|
||||
let j = yy * w + xx;
|
||||
if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) {
|
||||
near.push(before[j]);
|
||||
let bands: Vec<(usize, Changes)> = over_rows(w, h, per_share, |rows| {
|
||||
let (first, end) = (rows.start * w, rows.end * w);
|
||||
let mut band = Changes::new(end - first);
|
||||
for i in first..end {
|
||||
let mine_l = before[i];
|
||||
if own[i] || mine_l == TRANSPARENT {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (i % w, i / w);
|
||||
// The colours of the picture's own pixels within two pixels (seldom more than three).
|
||||
let mut near: Vec<u16> = Vec::new();
|
||||
for yy in y.saturating_sub(2)..=(y + 2).min(h - 1) {
|
||||
for xx in x.saturating_sub(2)..=(x + 2).min(w - 1) {
|
||||
let j = yy * w + xx;
|
||||
if own[j] && before[j] != TRANSPARENT && !near.contains(&before[j]) {
|
||||
near.push(before[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if near.len() < 2 || near.contains(&mine_l) {
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<(f64, u16)> = None;
|
||||
for (n, &a) in near.iter().enumerate() {
|
||||
for &b in &near[n + 1..] {
|
||||
let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue };
|
||||
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k]));
|
||||
if best.is_none_or(|x| off < x.0) {
|
||||
best = Some((off, if t < 0.5 { a } else { b }));
|
||||
let (Some(p), Some(mine)) = (img.rgba.get(i * 4..i * 4 + 3).map(|p| [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])]), color(mine_l))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if near.len() < 2 || near.contains(&mine_l) {
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<(f64, u16)> = None;
|
||||
for (n, &a) in near.iter().enumerate() {
|
||||
for &b in &near[n + 1..] {
|
||||
let (Some(ca), Some(cb)) = (color(a), color(b)) else { continue };
|
||||
let d = [0, 1, 2].map(|k| cb[k] - ca[k]);
|
||||
let len2: f64 = d.iter().map(|v| v * v).sum();
|
||||
let t = if len2 > 0.0 { ((0..3).map(|k| (p[k] - ca[k]) * d[k]).sum::<f64>() / len2).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let off = dist(p, [0, 1, 2].map(|k| ca[k] + t * d[k]));
|
||||
if best.is_none_or(|x| off < x.0) {
|
||||
best = Some((off, if t < 0.5 { a } else { b }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some((off, to)) = best
|
||||
&& off < 0.5 * dist(p, mine)
|
||||
{
|
||||
band.set(i - first, to, || before.get(first..end).unwrap_or_default().to_vec());
|
||||
}
|
||||
}
|
||||
if let Some((off, to)) = best
|
||||
&& off < 0.5 * dist(p, mine)
|
||||
{
|
||||
q.labels[i] = to;
|
||||
changed = true;
|
||||
if band.is_empty() { vec![] } else { vec![(first, band)] }
|
||||
});
|
||||
if bands.is_empty() {
|
||||
return q;
|
||||
}
|
||||
for (first, band) in bands {
|
||||
band.each(|at, to| {
|
||||
if let Some(l) = q.labels.get_mut(first + at) {
|
||||
*l = to;
|
||||
}
|
||||
});
|
||||
}
|
||||
compact(q.labels, q.palette)
|
||||
}
|
||||
|
||||
/// The labels a pass changes in one part of a picture, `len` pixels long, counting each pixel from
|
||||
/// the part's first. They are kept as pairs of a pixel and its new label until the pairs would take
|
||||
/// more memory than a copy of the part's labels, and as that copy, with the changes made, after
|
||||
/// that. The pairs take at most as much memory as the copy, and a part holds both only while it
|
||||
/// moves the pairs into the copy.
|
||||
struct Changes {
|
||||
len: usize,
|
||||
few: Vec<(usize, u16)>,
|
||||
copy: Option<Vec<u16>>,
|
||||
}
|
||||
|
||||
impl Changes {
|
||||
fn new(len: usize) -> Self {
|
||||
Changes { len, few: vec![], copy: None }
|
||||
}
|
||||
|
||||
/// Changes the label of the pixel `at` to `to`. `labels` gives the part's labels as they were
|
||||
/// before any changed, for the copy.
|
||||
fn set(&mut self, at: usize, to: u16, labels: impl FnOnce() -> Vec<u16>) {
|
||||
if let Some(copy) = &mut self.copy {
|
||||
if let Some(l) = copy.get_mut(at) {
|
||||
*l = to;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The most pairs that take no more memory than a copy of the part's labels. The pairs grow
|
||||
// by doubling up to that many, and no further.
|
||||
let most = self.len * size_of::<u16>() / size_of::<(usize, u16)>();
|
||||
if self.few.len() < most {
|
||||
if self.few.len() == self.few.capacity() {
|
||||
self.few.reserve_exact(self.few.capacity().max(4).min(most - self.few.len()));
|
||||
}
|
||||
self.few.push((at, to));
|
||||
return;
|
||||
}
|
||||
let mut copy = labels();
|
||||
for (at, to) in std::mem::take(&mut self.few).into_iter().chain([(at, to)]) {
|
||||
if let Some(l) = copy.get_mut(at) {
|
||||
*l = to;
|
||||
}
|
||||
}
|
||||
self.copy = Some(copy);
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.few.is_empty() && self.copy.is_none()
|
||||
}
|
||||
|
||||
/// `f` of each changed pixel and its new label (of every pixel of the part, once it is a copy).
|
||||
fn each(self, mut f: impl FnMut(usize, u16)) {
|
||||
match self.copy {
|
||||
Some(copy) => copy.into_iter().enumerate().for_each(|(at, to)| f(at, to)),
|
||||
None => self.few.into_iter().for_each(|(at, to)| f(at, to)),
|
||||
}
|
||||
}
|
||||
if changed { compact(q.labels, q.palette) } else { q }
|
||||
}
|
||||
|
||||
/// The most colors (Color) or grays (Grayscale) a palette may ask for to have its blend pixels
|
||||
@@ -122,12 +200,47 @@ const ON_THE_LINE: f64 = 0.15;
|
||||
/// their distance apart. Only the [`NEAR_COLORS`] colors nearest to its color are paired. It keeps
|
||||
/// its own color when that color is one of the pair, or when one color is all there is around it.
|
||||
/// With `gray` (Grayscale), a pixel's color is its gray (see [`luma`]).
|
||||
pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized {
|
||||
pub(super) fn settled_wide(q: Quantized, img: &Raster, own: &[bool], gray: bool) -> Quantized {
|
||||
settled_wide_by(q, img, own, gray, PIXELS_PER_SHARE)
|
||||
}
|
||||
|
||||
/// [`settled_wide`], with its strips of columns worked on in groups of about `per_share` pixels
|
||||
/// (at least one strip), spread over the available cores (one thread on the web).
|
||||
fn settled_wide_by(mut q: Quantized, img: &Raster, own: &[bool], gray: bool, per_share: usize) -> Quantized {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
if q.labels.len() != w * h || own.len() != w * h {
|
||||
if q.labels.len() != w * h || own.len() != w * h || w == 0 || h == 0 {
|
||||
return q;
|
||||
}
|
||||
let before = q.labels.clone();
|
||||
// Each pixel is settled by the labels as they were before any label changed. Each group of
|
||||
// strips keeps its changes (see [`Changes`]), and they are made once every group is done.
|
||||
let strip = BLEND_STRIP.min(w).max(1);
|
||||
let per_group = (per_share / (strip * h).max(1)).max(1);
|
||||
let cols = strip.saturating_mul(per_group);
|
||||
let groups: Vec<std::ops::Range<usize>> = (0..w).step_by(cols).map(|x| x..x.saturating_add(cols).min(w)).collect();
|
||||
let settle = |cols: &std::ops::Range<usize>| (cols.clone(), settled_wide_strips(&q, img, own, gray, cols.clone()));
|
||||
let mut settled: Vec<(std::ops::Range<usize>, Changes)> =
|
||||
if groups.len() <= 1 { groups.iter().map(settle).collect() } else { parallel_map(&groups, settle) };
|
||||
settled.retain(|(_, changes)| !changes.is_empty());
|
||||
if settled.is_empty() {
|
||||
return q;
|
||||
}
|
||||
for (cols, group) in settled {
|
||||
let gw = cols.len().max(1);
|
||||
group.each(|at, to| {
|
||||
if let Some(l) = q.labels.get_mut((at / gw) * w + cols.start + at % gw) {
|
||||
*l = to;
|
||||
}
|
||||
});
|
||||
}
|
||||
compact(q.labels, q.palette)
|
||||
}
|
||||
|
||||
/// The labels that [`settled_wide`] changes in the columns `cols` (whole strips of [`BLEND_STRIP`]
|
||||
/// columns, or the picture's last columns), each pixel counted row by row across those columns.
|
||||
fn settled_wide_strips(q: &Quantized, img: &Raster, own: &[bool], gray: bool, cols: std::ops::Range<usize>) -> Changes {
|
||||
let (w, h) = (img.width as usize, img.height as usize);
|
||||
let gw = cols.len();
|
||||
let before = &q.labels;
|
||||
let colors: Vec<[f64; 3]> = q.palette.iter().map(|c| c.map(f64::from)).collect();
|
||||
let dist2 = |a: [f64; 3], b: [f64; 3]| (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::<f64>();
|
||||
// The colors of the own pixels within reach of each pixel, as bits (one word per 64 palette
|
||||
@@ -135,7 +248,9 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b
|
||||
// sliding along each row, kept for the last 2 × reach + 1 rows), then across those rows.
|
||||
let words = q.palette.len().div_ceil(64).max(1);
|
||||
let span = 2 * BLEND_REACH + 1;
|
||||
let strip = BLEND_STRIP.min(w);
|
||||
let strip = BLEND_STRIP.min(w).max(1);
|
||||
let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] };
|
||||
let mut settled = Changes::new(gw * h);
|
||||
let mut rows = vec![0u64; span * strip * words];
|
||||
let mut counts = vec![0u32; q.palette.len()];
|
||||
let mut window = vec![0u64; words];
|
||||
@@ -178,12 +293,10 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut changed = false;
|
||||
let mut near: Vec<u16> = Vec::new();
|
||||
let mut by_distance: Vec<(f64, u16)> = Vec::new();
|
||||
let pixel = |p: &[u8]| if gray { [f64::from(luma([p[0], p[1], p[2], p[3]])); 3] } else { [f64::from(p[0]), f64::from(p[1]), f64::from(p[2])] };
|
||||
for (x0, y) in (0..w).step_by(strip.max(1)).flat_map(|x0| (0..h).map(move |y| (x0, y))) {
|
||||
let x1 = (x0 + strip).min(w);
|
||||
for (x0, y) in cols.clone().step_by(strip).flat_map(|x0| (0..h).map(move |y| (x0, y))) {
|
||||
let x1 = (x0 + strip).min(cols.end);
|
||||
if y == 0 {
|
||||
for yy in 0..BLEND_REACH.min(h) {
|
||||
row_colors(yy, x0, x1, &mut rows);
|
||||
@@ -265,12 +378,13 @@ pub(super) fn settled_wide(mut q: Quantized, img: &Raster, own: &[bool], gray: b
|
||||
&& !pair.contains(&mine_l)
|
||||
&& (off2 < BETTER_FIT * dist2(p, mine) || (between && off2 < ON_THE_LINE * ON_THE_LINE * apart2))
|
||||
{
|
||||
q.labels[i] = to;
|
||||
changed = true;
|
||||
settled.set(y * gw + x - cols.start, to, || {
|
||||
(0..h).flat_map(|yy| before.get(yy * w + cols.start..yy * w + cols.end).unwrap_or_default()).copied().collect()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed { compact(q.labels, q.palette) } else { q }
|
||||
settled
|
||||
}
|
||||
|
||||
/// Whether `img` is continuous tone (a photo, a gradient) rather than flat art, however noisy
|
||||
@@ -660,6 +774,109 @@ fn edge_blend_of(own: [f64; 3], beside: [u8; 3], alpha: u8) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Rings of four colors around a point off the center, blended over three pixels at each edge,
|
||||
/// with a little noise, and a band of half-transparent pixels along the bottom.
|
||||
fn rings(w: u32, h: u32) -> Raster {
|
||||
let colors = [[200.0, 30.0, 40.0], [20.0, 60.0, 180.0], [240.0, 210.0, 50.0], [25.0, 25.0, 25.0]];
|
||||
Raster::from_fn(w, h, |x, y| {
|
||||
let r = (f64::from(x) - 0.4 * f64::from(w)).hypot(f64::from(y) - 0.55 * f64::from(h)) / 9.0;
|
||||
let (ring, t) = (r.floor() as usize, ((r.fract() - 0.8) / 0.2).clamp(0.0, 1.0));
|
||||
let (a, b) = (colors[ring % 4], colors[(ring + 1) % 4]);
|
||||
let noise = f64::from((x * 7 + y * 13) % 5) - 2.0;
|
||||
let c = [0, 1, 2].map(|k| (a[k] + t * (b[k] - a[k]) + noise).clamp(0.0, 255.0) as u8);
|
||||
[c[0], c[1], c[2], if y + 2 >= h { 160 } else { 255 }]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changes_are_copied_out_once_their_pairs_would_take_more_memory_than_the_labels() {
|
||||
let labels: Vec<u16> = (0..64).collect();
|
||||
let pairs = labels.len() * size_of::<u16>() / size_of::<(usize, u16)>();
|
||||
let mut changes = Changes::new(labels.len());
|
||||
for at in 0..pairs {
|
||||
changes.set(at * 3, 99, || labels.clone());
|
||||
}
|
||||
assert!(changes.copy.is_none());
|
||||
assert!(changes.few.capacity() <= pairs, "the pairs take no more memory than a copy of the labels");
|
||||
changes.set(63, 98, || labels.clone());
|
||||
assert!(changes.few.is_empty() && changes.copy.is_some());
|
||||
changes.set(1, 97, || labels.clone());
|
||||
let mut out = labels.clone();
|
||||
changes.each(|at, to| out[at] = to);
|
||||
let mut expected = labels.clone();
|
||||
(0..pairs).for_each(|at| expected[at * 3] = 99);
|
||||
(expected[63], expected[1]) = (98, 97);
|
||||
assert_eq!(out, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_pixels_settle_the_same_however_the_picture_is_shared_out() {
|
||||
// Odd sizes, so that the last band of rows and the last strip of columns are short.
|
||||
let img = rings(173, 97);
|
||||
let all = img.width as usize * img.height as usize;
|
||||
let opaque: Vec<bool> = img.rgba.as_chunks::<4>().0.iter().map(|p| p[3] >= 128).collect();
|
||||
let own = super::super::own_colours_among(&img, &opaque);
|
||||
// Two colors for each blend, and an olive that the blends lie nearer to than to their sides,
|
||||
// each pixel labeled with the nearest of them (none for a transparent one).
|
||||
let palette = vec![[200, 30, 40], [20, 60, 180], [240, 210, 50], [25, 25, 25], [120, 120, 60]];
|
||||
let labels = img
|
||||
.rgba
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let d = |c: &[u8; 3]| (0..3).map(|k| (i32::from(p[k]) - i32::from(c[k])).pow(2)).sum::<i32>();
|
||||
(0..palette.len()).min_by_key(|&i| d(&palette[i])).map_or(TRANSPARENT, |i| i as u16)
|
||||
})
|
||||
.collect();
|
||||
let q = Quantized { labels, palette };
|
||||
// One row (or one strip) per share, a few rows per share, and the whole picture as one.
|
||||
let shares = [1, 3 * 173 + 5, all];
|
||||
for (name, settle) in [
|
||||
("settled", &(|s| settled_by(q.clone(), &img, &own, s)) as &dyn Fn(usize) -> Quantized),
|
||||
("settled_wide", &|s| settled_wide_by(q.clone(), &img, &own, false, s)),
|
||||
("settled_wide gray", &|s| settled_wide_by(q.clone(), &img, &own, true, s)),
|
||||
] {
|
||||
let out: Vec<Quantized> = shares.iter().map(|&s| settle(s)).collect();
|
||||
assert_ne!(out[0], q, "{name} settles some blend pixels");
|
||||
assert!(out.iter().all(|o| *o == out[0]), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_pixels_settle_the_same_when_most_of_a_share_changes() {
|
||||
// Columns of red and blue own pixels, two blend pixels between each pair: one nearer to red
|
||||
// and one nearer to blue, both labeled with an olive. Half the pixels of each band of rows and
|
||||
// of each group of strips change, more than the eighth at which a share's changes are copied
|
||||
// out (see [`Changes`]). 44 columns, so that the last strip of 7 is short.
|
||||
let (w, h) = (44u32, 5u32);
|
||||
let (red, blue) = ([200u8, 30, 40], [20u8, 60, 180]);
|
||||
let mix = |t: f64| [0, 1, 2].map(|k| (f64::from(red[k]) + t * (f64::from(blue[k]) - f64::from(red[k]))).round() as u8);
|
||||
let img = Raster::from_fn(w, h, |x, _| {
|
||||
let c = [red, mix(0.3), mix(0.7), blue][x as usize % 4];
|
||||
[c[0], c[1], c[2], 255]
|
||||
});
|
||||
let own: Vec<bool> = (0..w * h).map(|i| matches!(i % w % 4, 0 | 3)).collect();
|
||||
let labels = (0..w * h).map(|i| [0, 2, 2, 1][(i % w % 4) as usize]).collect();
|
||||
let q = Quantized { labels, palette: vec![red, blue, [120, 120, 60]] };
|
||||
let expected = Quantized { labels: (0..w * h).map(|i| [0, 0, 1, 1][(i % w % 4) as usize]).collect(), palette: vec![red, blue] };
|
||||
let all = (w * h) as usize;
|
||||
for s in [1, 3 * 7 * h as usize, all] {
|
||||
assert_eq!(settled_by(q.clone(), &img, &own, s), expected, "settled, {s} pixels per share");
|
||||
assert_eq!(settled_wide_by(q.clone(), &img, &own, false, s), expected, "settled_wide, {s} pixels per share");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_with_no_pixels_settles_to_itself() {
|
||||
let q = Quantized { labels: vec![], palette: vec![[0, 0, 0], [255, 255, 255]] };
|
||||
for (w, h) in [(u32::MAX, 0), (0, u32::MAX)] {
|
||||
let img = Raster::new(w, h, vec![]).unwrap();
|
||||
assert_eq!(settled_by(q.clone(), &img, &[], PIXELS_PER_SHARE), q, "settled, {w} x {h}");
|
||||
assert_eq!(settled_wide_by(q.clone(), &img, &[], false, PIXELS_PER_SHARE), q, "settled_wide, {w} x {h}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gray_blend_takes_the_closer_of_two_pairs_it_lies_between_despite_rounding() {
|
||||
// Gray 105 in the middle, with own pixels of gray 50 to its left, 200 to its right and black
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::stylus::StylusFeed;
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{CacheKey, VectorcraftApp, now_ms, widgets};
|
||||
|
||||
mod alternates_bar;
|
||||
mod artboards;
|
||||
mod context;
|
||||
mod contour_bar;
|
||||
@@ -441,6 +442,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
}
|
||||
if app.ui.screen_mode < 3 {
|
||||
contour_bar::contour_bar(app, ui, &xf);
|
||||
// After the task bar, which the row of character alternates keeps clear of.
|
||||
alternates_bar::alternates_bar(app, ui, &xf);
|
||||
}
|
||||
if app.ui.screen_mode < 3 {
|
||||
crate::free_transform::show(app, ui, xf.rect);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Character alternates on the canvas: with a Type tool and exactly one character selected, a
|
||||
//! small row of that character's alternate glyphs shows beside it. A click on a glyph draws the
|
||||
//! character with it, as a double-click on the glyph in the Glyphs panel's Show › Alternates for
|
||||
//! Current Selection does, through the same command and so as one undo step. An arrow at the end
|
||||
//! of the row opens the Glyphs panel when the character has more alternates than the row shows.
|
||||
//! Preferences › Type › Show Character Alternates (`showCharacterAlternates`) turns the row off.
|
||||
//!
|
||||
//! Public behavior: selecting a character shows its alternate glyphs next to it, a click replaces
|
||||
//! the character's glyph, at most five alternates show, and a control to their right opens the
|
||||
//! Glyphs panel for the rest ("Replace a character with on-canvas alternate glyphs",
|
||||
//! <https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf#page=479>;
|
||||
//! [Replace characters with alternate glyphs](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/replace-characters-with-alternate-glyphs.html)).
|
||||
//! The row's place, its size and the preference are VectorCraft's own.
|
||||
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use super::{CornerRadius, Pos2, Sense, Shape, Stroke, StrokeKind, Tokens, Ui, Vec2, VectorcraftApp, Xf, pos2, vec2};
|
||||
use crate::panels::glyphs;
|
||||
|
||||
/// The side of one glyph cell, in points.
|
||||
const CELL: f32 = 28.0;
|
||||
/// The width of the arrow that opens the Glyphs panel.
|
||||
const MORE: f32 = 16.0;
|
||||
/// The space between the row's edge and its cells: the frame's 1-point line and 3 points inside it.
|
||||
const PAD: f32 = 4.0;
|
||||
/// The space between the selected character and the row.
|
||||
const GAP: f32 = 6.0;
|
||||
|
||||
fn bar_id() -> egui::Id {
|
||||
egui::Id::unique("character-alternates-bar")
|
||||
}
|
||||
|
||||
/// Where the row was last drawn (none while it isn't shown).
|
||||
#[cfg(test)]
|
||||
pub(super) fn bar_rect(ctx: &egui::Context) -> Option<egui::Rect> {
|
||||
let id = bar_id();
|
||||
ctx.memory(|m| m.areas().is_visible(&egui::LayerId::new(egui::Order::Middle, id)).then(|| m.area_rect(id)).flatten())
|
||||
}
|
||||
|
||||
/// The size of a row of `count` glyphs, with the arrow when `more`.
|
||||
fn bar_size(count: usize, more: bool) -> Vec2 {
|
||||
let cells = count as f32 * CELL + if more { MORE } else { 0.0 };
|
||||
vec2(cells + 2.0 * PAD, CELL + 2.0 * PAD)
|
||||
}
|
||||
|
||||
/// The document-space highlight quads of bytes `a..b` of text `id`, and whether the text is set
|
||||
/// vertically. Kept until the document or the selection changes, so the text is laid out once for
|
||||
/// each, not in every frame. Empty for a character the text does not draw (overset area type).
|
||||
fn character_quads(app: &VectorcraftApp, ui: &Ui, id: NodeId, (a, b): (usize, usize)) -> Option<(Vec<[Point; 4]>, bool)> {
|
||||
type Quads = (Vec<[Point; 4]>, bool);
|
||||
let st = app.session.active()?;
|
||||
let key = (st.uid, st.revision, id, a, b);
|
||||
let slot = bar_id().with("quads");
|
||||
if let Some((cached_key, quads)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize, usize), Quads)>(slot))
|
||||
&& cached_key == key
|
||||
{
|
||||
return Some(quads);
|
||||
}
|
||||
let NodeKind::Text(tx) = &st.doc.node(id)?.kind else { return None };
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), tx);
|
||||
let quads: Vec<[Point; 4]> = vectorcraft_text::selection_quads(&lay, a, b).into_iter().map(|q| q.map(|c| tx.xf * c)).collect();
|
||||
let found = (quads, tx.vertical);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(slot, (key, found.clone())));
|
||||
Some(found)
|
||||
}
|
||||
|
||||
/// The top-left corner of a row of `size` beside the selected character's screen box `sel`, inside
|
||||
/// `area`. The row goes under a character of horizontal type, or over it when there is no room
|
||||
/// under it; beside a character of vertical type, on its right, or on its left when there is no
|
||||
/// room on the right. A side is also given up when the row would cover `avoid` there (the
|
||||
/// Contextual Task Bar). With no room on either side the row takes the first one, moved into
|
||||
/// `area`.
|
||||
fn place(sel: egui::Rect, size: Vec2, area: egui::Rect, vertical: bool, avoid: Option<egui::Rect>) -> Pos2 {
|
||||
let sides = if vertical {
|
||||
[pos2(sel.right() + GAP, sel.center().y - size.y / 2.0), pos2(sel.left() - GAP - size.x, sel.center().y - size.y / 2.0)]
|
||||
} else {
|
||||
[pos2(sel.center().x - size.x / 2.0, sel.bottom() + GAP), pos2(sel.center().x - size.x / 2.0, sel.top() - GAP - size.y)]
|
||||
};
|
||||
let last = (area.max - size).max(area.min);
|
||||
// Along the character's line the row may slide to stay on the canvas; across it, it may not.
|
||||
let slide = |at: Pos2| if vertical { pos2(at.x, at.y.clamp(area.min.y, last.y)) } else { pos2(at.x.clamp(area.min.x, last.x), at.y) };
|
||||
let fits = |at: Pos2| {
|
||||
let r = egui::Rect::from_min_size(at, size);
|
||||
area.contains_rect(r) && avoid.is_none_or(|bar| !bar.intersects(r))
|
||||
};
|
||||
let sides = sides.map(slide);
|
||||
sides.into_iter().find(|at| fits(*at)).unwrap_or_else(|| sides[0].clamp(area.min, last))
|
||||
}
|
||||
|
||||
/// The row of alternate glyphs beside the Type tool's one selected character.
|
||||
pub(super) fn alternates_bar(app: &mut VectorcraftApp, ui: &mut Ui, xf: &Xf) {
|
||||
// Not while a press or a drag is choosing the selection: the row would follow the pointer.
|
||||
if !app.session.prefs.show_character_alternates || !app.session.tool_wants_text() || app.session.tool_busy() {
|
||||
return;
|
||||
}
|
||||
let Some(found) = glyphs::on_canvas(app, ui) else { return };
|
||||
let Some((quads, vertical)) = character_quads(app, ui, found.id, found.bytes) else { return };
|
||||
let corners: Vec<Pos2> = quads.iter().flatten().map(|p| xf.to_screen(*p)).collect();
|
||||
if corners.is_empty() {
|
||||
return;
|
||||
}
|
||||
let sel = egui::Rect::from_points(&corners);
|
||||
// A character scrolled off the canvas has no row.
|
||||
if !sel.intersects(xf.rect) {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let count = found.count();
|
||||
let at = place(sel, bar_size(count, found.more), xf.rect.shrink(4.0), vertical, super::task_bar_rect(ui.ctx()));
|
||||
let px = (CELL * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32;
|
||||
let (mut pick, mut more) = (None, false);
|
||||
egui::Area::new(bar_id()).order(egui::Order::Middle).fixed_pos(at).show(ui.ctx(), |ui| {
|
||||
egui::Frame::NONE
|
||||
.fill(t.panel)
|
||||
.stroke(Stroke::new(1.0, t.border))
|
||||
.corner_radius(CornerRadius::same(4))
|
||||
.inner_margin(egui::Margin::same(PAD as i8 - 1))
|
||||
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: egui::Color32::from_black_alpha(70) })
|
||||
.show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
for i in 0..count {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 2, t.hover);
|
||||
}
|
||||
ui.painter().rect_stroke(rect.shrink(0.5), 0, Stroke::new(0.5, t.border), StrokeKind::Inside);
|
||||
if let Some(tex) = found.texture(ui.ctx(), i, px) {
|
||||
let uv = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
|
||||
ui.painter().image(tex.id(), rect, uv, t.text_strong);
|
||||
}
|
||||
let tip = found.tip(i);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &tip));
|
||||
if resp.on_hover_text(&tip).clicked() {
|
||||
pick = Some(i);
|
||||
}
|
||||
}
|
||||
if found.more {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(MORE, CELL), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, 2, t.hover);
|
||||
}
|
||||
let c = rect.center();
|
||||
let arrow = vec![pos2(c.x - 2.5, c.y - 5.0), pos2(c.x + 3.5, c.y), pos2(c.x - 2.5, c.y + 5.0)];
|
||||
ui.painter().add(Shape::convex_polygon(arrow, t.text_strong, Stroke::NONE));
|
||||
let tip = tl!("Show all alternates in the Glyphs panel");
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tip));
|
||||
more = resp.on_hover_text(tip).clicked();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if let Some(i) = pick {
|
||||
found.pick(app, i);
|
||||
} else if more {
|
||||
glyphs::show_alternates(app, ui.ctx());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,247 @@
|
||||
use egui::{Pos2, Rect};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// The Type tool editing `text` in a new document.
|
||||
fn editing(text: &str, vertical: bool) -> (VectorcraftApp, Value) {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 400, "height": 200})).unwrap();
|
||||
app.session.select_tool("type", Default::default()).unwrap();
|
||||
let id = app.run("text.create", json!({"x": 120, "y": 80, "text": text, "vertical": vertical})).unwrap()["id"].clone();
|
||||
app.session.apply_actions(vec![vectorcraft_tools::Action::Notify("text.editNew".into())]).unwrap();
|
||||
(app, id)
|
||||
}
|
||||
|
||||
fn select(app: &mut VectorcraftApp, start: usize, end: usize) {
|
||||
app.session.set_tool_option("select", &json!({"start": start, "end": end}));
|
||||
}
|
||||
|
||||
const SCREEN: Rect = Rect::from_min_max(Pos2::ZERO, Pos2::new(800.0, 600.0));
|
||||
|
||||
/// One frame of the canvas with `events`; where the row of alternates is afterwards, if it shows.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, events: Vec<egui::Event>) -> Option<Rect> {
|
||||
egui_extras::install_image_loaders(ctx);
|
||||
let raw = egui::RawInput { screen_rect: Some(SCREEN), events, ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| super::super::show(app, ui));
|
||||
out.textures_delta.clear();
|
||||
bar_rect(ctx)
|
||||
}
|
||||
|
||||
/// The glyphs the row offers and whether it has the arrow, as the canvas finds them.
|
||||
fn offered(app: &mut VectorcraftApp, ctx: &egui::Context) -> Option<(Vec<u32>, bool)> {
|
||||
let mut found = None;
|
||||
let raw = egui::RawInput { screen_rect: Some(SCREEN), ..Default::default() };
|
||||
let mut out = ctx.run_ui(raw, |ui| {
|
||||
found = glyphs::on_canvas(app, ui).map(|f| ((0..f.count()).filter_map(|i| f.gid(i)).collect(), f.more));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
found
|
||||
}
|
||||
|
||||
/// A click at `at`, with a frame before it that lays the row out and moves the pointer there.
|
||||
fn click(app: &mut VectorcraftApp, ctx: &egui::Context, at: Pos2) {
|
||||
let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(app, ctx, vec![egui::Event::PointerMoved(at)]);
|
||||
frame(app, ctx, vec![button(true), button(false)]);
|
||||
}
|
||||
|
||||
/// The center of cell `i` of the row at `bar`.
|
||||
fn cell(bar: Rect, i: usize) -> Pos2 {
|
||||
bar.min + vec2(PAD + CELL * (i as f32 + 0.5), PAD + CELL / 2.0)
|
||||
}
|
||||
|
||||
/// The center of the arrow of a row of `count` cells at `bar`.
|
||||
fn arrow(bar: Rect, count: usize) -> Pos2 {
|
||||
bar.min + vec2(PAD + CELL * count as f32 + MORE / 2.0, PAD + CELL / 2.0)
|
||||
}
|
||||
|
||||
fn glyph_ids(app: &VectorcraftApp, id: &Value) -> Vec<u32> {
|
||||
let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
let mut glyphs = vectorcraft_text::layout(FontDb::global(), text).glyphs;
|
||||
glyphs.sort_by_key(|g| g.byte);
|
||||
glyphs.iter().map(|g| g.gid).collect()
|
||||
}
|
||||
|
||||
fn undo_steps(app: &VectorcraftApp) -> usize {
|
||||
app.session.active().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
/// The screen box of bytes `a..b` of the text, as the canvas of [`frame`] draws it.
|
||||
fn character_box(app: &VectorcraftApp, id: &Value, a: usize, b: usize) -> Rect {
|
||||
let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
let lay = vectorcraft_text::layout(FontDb::global(), text);
|
||||
let xf = app.canvas_xf().expect("a frame was drawn");
|
||||
let corners: Vec<Pos2> = vectorcraft_text::selection_quads(&lay, a, b).iter().flatten().map(|p| xf.to_screen(text.xf * *p)).collect();
|
||||
Rect::from_points(&corners)
|
||||
}
|
||||
|
||||
/// With the Type tool and exactly one character selected, the canvas shows up to five of that
|
||||
/// character's alternates beside it, without the glyph it is drawn with, and an arrow when it has
|
||||
/// more. A click draws the character with the glyph as one undo step and keeps the text. A caret,
|
||||
/// a longer selection, a character without alternates, another tool and the preference turned off
|
||||
/// each show no row.
|
||||
#[test]
|
||||
fn one_selected_character_shows_its_alternates_and_a_click_replaces_its_glyph() {
|
||||
use vectorcraft_text::test_fonts::{alternates_family, alternates_font, unencoded_glyphs};
|
||||
FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
|
||||
let [u1, ..] = unencoded_glyphs().expect("unencoded glyphs");
|
||||
let (mut app, id) = editing("xay", false);
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
let drawn = glyph_ids(&app, &id);
|
||||
|
||||
select(&mut app, 1, 1);
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), None, "a caret selects no character");
|
||||
select(&mut app, 0, 2);
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), None, "two characters are selected");
|
||||
select(&mut app, 0, 1);
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), None, "x has no alternates");
|
||||
|
||||
select(&mut app, 1, 2);
|
||||
let bar = frame(&mut app, &ctx, vec![]).expect("the a has alternates");
|
||||
let task_bar = super::super::task_bar_rect(&ctx).expect("the task bar shows for type being edited");
|
||||
let (gids, more) = offered(&mut app, &ctx).expect("offered");
|
||||
assert_eq!(gids.len(), glyphs::ON_CANVAS_ALTERNATES, "five of the six alternates");
|
||||
assert!(more, "the sixth is in the Glyphs panel");
|
||||
assert_eq!(gids[0], u1);
|
||||
assert!(!gids.contains(&drawn[1]), "the glyph the a is drawn with is not offered");
|
||||
assert_eq!(bar.size(), bar_size(5, true), "five cells and the arrow");
|
||||
// The Contextual Task Bar is under the type, so the row goes over the character.
|
||||
// The row is placed on whole pixels, so it is within one of where the character's box puts it.
|
||||
let sel = character_box(&app, &id, 1, 2);
|
||||
assert!(task_bar.intersects(Rect::from_min_size(pos2(bar.left(), sel.bottom() + GAP), bar.size())), "{task_bar:?} {sel:?}");
|
||||
assert!((sel.top() - bar.bottom() - GAP).abs() < 1.0, "over the character: {bar:?} {sel:?}");
|
||||
assert!((bar.center().x - sel.center().x).abs() < 1.0, "centered on it");
|
||||
// Without the task bar the row is under the character.
|
||||
app.ui.task_bar = false;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let under = frame(&mut app, &ctx, vec![]).expect("the row");
|
||||
assert!((under.top() - sel.bottom() - GAP).abs() < 1.0, "under the character: {under:?} {sel:?}");
|
||||
app.ui.task_bar = true;
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), Some(bar));
|
||||
|
||||
// A click on the first glyph.
|
||||
let undo = undo_steps(&app);
|
||||
click(&mut app, &ctx, cell(bar, 0));
|
||||
assert_eq!(undo_steps(&app), undo + 1, "one undo step; status: {}", app.ui.status);
|
||||
assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "the a is drawn with the glyph");
|
||||
let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(text.plain_text(), "xay", "the text keeps its characters");
|
||||
|
||||
// The row stays for the same character: its default glyph first, the drawn glyph left out.
|
||||
let bar = frame(&mut app, &ctx, vec![]).expect("still one character selected");
|
||||
let (gids, _) = offered(&mut app, &ctx).expect("offered");
|
||||
assert_eq!(gids[0], drawn[1], "the default glyph comes first");
|
||||
assert!(!gids.contains(&u1));
|
||||
click(&mut app, &ctx, cell(bar, 0));
|
||||
assert_eq!(undo_steps(&app), undo + 2);
|
||||
assert_eq!(glyph_ids(&app, &id), drawn, "the default glyph again");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(glyph_ids(&app, &id), [drawn[0], u1, drawn[2]], "undo takes one pick back");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(glyph_ids(&app, &id), drawn);
|
||||
|
||||
// Preferences › Type › Show Character Alternates.
|
||||
select(&mut app, 1, 2);
|
||||
assert!(frame(&mut app, &ctx, vec![]).is_some());
|
||||
app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": false})).unwrap();
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), None, "the preference is off");
|
||||
app.run("prefs.set", json!({"key": "showCharacterAlternates", "value": true})).unwrap();
|
||||
assert!(frame(&mut app, &ctx, vec![]).is_some());
|
||||
|
||||
app.session.select_tool("select", Default::default()).unwrap();
|
||||
assert_eq!(frame(&mut app, &ctx, vec![]), None, "the Selection tool edits no text");
|
||||
}
|
||||
|
||||
/// The arrow at the end of the row opens the Glyphs panel on Show › Alternates for Current
|
||||
/// Selection and changes nothing in the document.
|
||||
#[test]
|
||||
fn the_arrow_opens_the_glyphs_panel_on_the_alternates() {
|
||||
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
|
||||
FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
|
||||
let (mut app, id) = editing("xay", false);
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap();
|
||||
select(&mut app, 1, 2);
|
||||
let ctx = egui::Context::default();
|
||||
app.run("window.panel.close", json!({"panel": "glyphs"})).ok();
|
||||
assert!(!crate::panel_docking::visible(&app.ui, "glyphs"));
|
||||
let bar = frame(&mut app, &ctx, vec![]).expect("the row");
|
||||
let (undo, drawn) = (undo_steps(&app), glyph_ids(&app, &id));
|
||||
click(&mut app, &ctx, arrow(bar, 5));
|
||||
assert!(crate::panel_docking::visible(&app.ui, "glyphs"), "status: {}", app.ui.status);
|
||||
// The Show menu's entry after its seven character subsets.
|
||||
assert_eq!(crate::panels::pstate::<usize>(&ctx, "gl-subset"), 7);
|
||||
assert_eq!(undo_steps(&app), undo);
|
||||
assert_eq!(glyph_ids(&app, &id), drawn);
|
||||
}
|
||||
|
||||
/// A medial letter of a joined Arabic word gets the alternates of its medial form, and a click
|
||||
/// applies the feature that draws one to that letter alone: its neighbors keep their joining
|
||||
/// forms. Three alternates need no arrow.
|
||||
#[test]
|
||||
fn a_letter_of_a_joined_word_keeps_the_word_joined() {
|
||||
use vectorcraft_text::test_fonts::{JOINING_ALTERNATES_FAMILY, joining_alternates_font, source_sans_gid};
|
||||
FontDb::global().add_font(joining_alternates_font().expect("the test font builds"));
|
||||
let g = |c| u32::from(source_sans_gid(c).expect("a glyph"));
|
||||
let (mut app, id) = editing("\u{628}\u{62A}\u{646}", false);
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": JOINING_ALTERNATES_FAMILY})).unwrap();
|
||||
assert_eq!(glyph_ids(&app, &id), [g('B'), g('u'), g('M')]);
|
||||
select(&mut app, 2, 4);
|
||||
let ctx = egui::Context::default();
|
||||
let bar = frame(&mut app, &ctx, vec![]).expect("the medial teh has alternates");
|
||||
let (gids, more) = offered(&mut app, &ctx).expect("offered");
|
||||
assert_eq!((gids.len(), more), (3, false), "the medial form's three alternates: {gids:?}");
|
||||
assert_eq!(bar.size(), bar_size(3, false), "three cells and no arrow");
|
||||
let at = gids.iter().position(|gid| *gid == g('x')).expect("the alternate salt=2 draws");
|
||||
let undo = undo_steps(&app);
|
||||
click(&mut app, &ctx, cell(bar, at));
|
||||
assert_eq!(undo_steps(&app), undo + 1, "status: {}", app.ui.status);
|
||||
assert_eq!(glyph_ids(&app, &id), [g('B'), g('x'), g('M')], "the neighbors keep their initial and final forms");
|
||||
let NodeKind::Text(text) = &app.session.active().unwrap().doc.node(NodeId(id.as_u64().unwrap())).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(text.plain_text(), "\u{628}\u{62A}\u{646}");
|
||||
assert!(text.runs.iter().all(|r| r.style.glyph_alternate.is_none()), "a feature draws it, not a glyph choice");
|
||||
}
|
||||
|
||||
/// The row stands on the right of a character of vertical type, level with it.
|
||||
#[test]
|
||||
fn the_row_stands_beside_a_character_of_vertical_type() {
|
||||
use vectorcraft_text::test_fonts::{alternates_family, alternates_font};
|
||||
FontDb::global().add_font(alternates_font(true).expect("the test font builds"));
|
||||
let (mut app, id) = editing("xay", true);
|
||||
app.run("text.setRangeStyle", json!({"id": id, "font": alternates_family(true)})).unwrap();
|
||||
select(&mut app, 1, 2);
|
||||
let ctx = egui::Context::default();
|
||||
app.ui.task_bar = false;
|
||||
// The first frame fits the view to the canvas.
|
||||
frame(&mut app, &ctx, vec![]);
|
||||
let bar = frame(&mut app, &ctx, vec![]).expect("the row");
|
||||
let sel = character_box(&app, &id, 1, 2);
|
||||
assert!((bar.left() - sel.right() - GAP).abs() < 1.0, "on the right of the character: {bar:?} {sel:?}");
|
||||
assert!((bar.center().y - sel.center().y).abs() < 1.0, "level with it");
|
||||
}
|
||||
|
||||
/// [`place`]: under the character, over it when the canvas ends below or the task bar is there,
|
||||
/// on the right or left of vertical type, and inside the canvas when neither side has room.
|
||||
#[test]
|
||||
fn the_row_is_placed_on_the_side_that_has_room() {
|
||||
let area = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 300.0));
|
||||
let size = vec2(100.0, 34.0);
|
||||
let sel = Rect::from_min_max(pos2(190.0, 100.0), pos2(210.0, 130.0));
|
||||
assert_eq!(place(sel, size, area, false, None), pos2(150.0, 136.0), "under");
|
||||
let low = sel.translate(vec2(0.0, 150.0));
|
||||
assert_eq!(place(low, size, area, false, None), pos2(150.0, 210.0), "over: no room under");
|
||||
let task_bar = Rect::from_min_max(pos2(100.0, 140.0), pos2(300.0, 180.0));
|
||||
assert_eq!(place(sel, size, area, false, Some(task_bar)), pos2(150.0, 60.0), "over: the task bar is under");
|
||||
let edge = sel.translate(vec2(-185.0, 0.0));
|
||||
assert_eq!(place(edge, size, area, false, None), pos2(0.0, 136.0), "moved along the line to stay on the canvas");
|
||||
assert_eq!(place(sel, size, area, true, None), pos2(216.0, 98.0), "vertical type: on the right");
|
||||
let right = sel.translate(vec2(150.0, 0.0));
|
||||
assert_eq!(place(right, size, area, true, None), pos2(234.0, 98.0), "vertical type: on the left");
|
||||
let small = Rect::from_min_max(pos2(0.0, 0.0), pos2(400.0, 60.0));
|
||||
let inside = Rect::from_min_max(pos2(190.0, 20.0), pos2(210.0, 40.0));
|
||||
assert_eq!(place(inside, size, small, false, None), pos2(150.0, 26.0), "no room on either side: kept inside the canvas");
|
||||
}
|
||||
@@ -276,26 +276,27 @@ pub(super) fn blend_widgets_overlay(app: &VectorcraftApp, p: &egui::Painter, xf:
|
||||
/// Objects on Path group selected). The Rotate All widget is joined by a line to the first object's
|
||||
/// attachment point. It stays while dragged.
|
||||
fn rotate_all_widget(p: &egui::Painter, xf: &Xf, tool: &str, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, RotateAllWidget};
|
||||
use vectorcraft_tools::objects_on_path::{ROTATE_ALL_RADIUS_PX, Widgets};
|
||||
if !matches!(tool, "selection" | "objectsOnPath") {
|
||||
return;
|
||||
}
|
||||
member_widgets(p, xf, doc, selection);
|
||||
if let Some(w) = RotateAllWidget::of(doc, selection, xf.zoom) {
|
||||
let widgets = Widgets::of(doc, selection, xf.zoom);
|
||||
member_widgets(p, xf, doc, &widgets.members);
|
||||
if let Some(w) = widgets.rotate_all {
|
||||
let (on, c, color) = (xf.to_screen(w.pivot), xf.to_screen(w.at), c32(doc.layer_color(w.group)));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
p.circle_filled(c, ROTATE_ALL_RADIUS_PX, Color32::WHITE);
|
||||
p.circle_stroke(c, ROTATE_ALL_RADIUS_PX, Stroke::new(1.0, color));
|
||||
}
|
||||
space_widget(p, xf, doc, selection);
|
||||
move_all_widget(p, xf, doc, selection);
|
||||
space_widget(p, xf, doc, widgets.space);
|
||||
move_all_widget(p, xf, doc, widgets.move_all);
|
||||
}
|
||||
|
||||
/// The Select/Move widgets: a ring on each object's attachment point, filled blue for the chosen
|
||||
/// object. They are drawn under the other widgets, which a press takes first.
|
||||
fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{MEMBER_RADIUS_PX, MemberWidget};
|
||||
for w in MemberWidget::all(doc, selection, xf.zoom) {
|
||||
fn member_widgets(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, widgets: &[vectorcraft_tools::objects_on_path::MemberWidget]) {
|
||||
use vectorcraft_tools::objects_on_path::MEMBER_RADIUS_PX;
|
||||
for w in widgets {
|
||||
let c = xf.to_screen(w.at);
|
||||
if w.chosen {
|
||||
p.circle(c, MEMBER_RADIUS_PX, MEMBER_CHOSEN, Stroke::new(1.0, Color32::WHITE));
|
||||
@@ -310,9 +311,9 @@ pub(super) const MEMBER_CHOSEN: Color32 = Color32::from_rgb(0x1f, 0x6f, 0xeb);
|
||||
|
||||
/// The Space widget (beside the Rotate All widget, with two or more objects on the path): a square
|
||||
/// joined by a line to the last object's attachment point. It stays while dragged.
|
||||
fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{SPACE_HALF_PX, SpaceWidget};
|
||||
let Some(w) = SpaceWidget::of(doc, selection, xf.zoom) else { return };
|
||||
fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option<vectorcraft_tools::objects_on_path::SpaceWidget>) {
|
||||
use vectorcraft_tools::objects_on_path::SPACE_HALF_PX;
|
||||
let Some(w) = w else { return };
|
||||
let (on, c, color) = (xf.to_screen(w.last), xf.to_screen(w.at), c32(doc.layer_color(w.group)));
|
||||
let r = egui::Rect::from_center_size(c, vec2(2.0 * SPACE_HALF_PX, 2.0 * SPACE_HALF_PX));
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
@@ -323,9 +324,9 @@ fn space_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, sel
|
||||
/// The Move All widget (beside the Rotate All widget, when the objects have room to move along the
|
||||
/// path): a diamond below the first object joined by a line to its attachment point. It stays while
|
||||
/// dragged.
|
||||
fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, selection: &vectorcraft_doc::Selection) {
|
||||
use vectorcraft_tools::objects_on_path::{MOVE_ALL_HALF_PX, MoveAllWidget};
|
||||
let Some(w) = MoveAllWidget::of(doc, selection, xf.zoom) else { return };
|
||||
fn move_all_widget(p: &egui::Painter, xf: &Xf, doc: &vectorcraft_doc::Document, w: Option<vectorcraft_tools::objects_on_path::MoveAllWidget>) {
|
||||
use vectorcraft_tools::objects_on_path::MOVE_ALL_HALF_PX;
|
||||
let Some(w) = w else { return };
|
||||
let (on, c, color) = (xf.to_screen(w.first), xf.to_screen(w.at), c32(doc.layer_color(w.group)));
|
||||
let h = MOVE_ALL_HALF_PX;
|
||||
p.line_segment([on, c], Stroke::new(1.0, color));
|
||||
|
||||
@@ -140,7 +140,10 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
|
||||
}
|
||||
// Nothing to export keeps the dialog open too (before the save dialog, not after it). An
|
||||
// exporter plug-in decides for itself what it can write.
|
||||
if !use_artboards && plugin.is_none() && app.session.active().is_some_and(|s| vectorcraft_render::encode::art_bounds(&s.doc).is_none()) {
|
||||
if !use_artboards
|
||||
&& plugin.is_none()
|
||||
&& app.session.active().is_some_and(|s| vectorcraft_engine::cmd::fileio::export_art_bounds(&s.doc, f.id).is_none())
|
||||
{
|
||||
return Err("nothing to export: the document has no visible art (or turn on Use Artboards)".into());
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
|
||||
@@ -9,8 +9,10 @@
|
||||
//! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`,
|
||||
//! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading`
|
||||
//! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`,
|
||||
//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and,
|
||||
//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on).
|
||||
//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on; with
|
||||
//! the Selection tool, every frame of each story selected when the dialog opened, locked and hidden
|
||||
//! frames included) and, after Select All in threaded text, `__story` (the edited frame, whose
|
||||
//! story it acts on).
|
||||
//!
|
||||
//! Behaviour reference (public documentation):
|
||||
//! <https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/adjust-word-and-letterspacing-in-justified-text.html>.
|
||||
@@ -95,6 +97,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
targets = json!({"ids": [id.0], "start": a, "end": b});
|
||||
// After Select All in threaded text: every paragraph of every frame ([`params`]).
|
||||
story = crate::panels::character::text_story(app).map(|_| id.0);
|
||||
} else {
|
||||
// With the Selection tool: every frame of each selected story.
|
||||
crate::panels::character::target_selected_stories(app, &mut targets);
|
||||
}
|
||||
let mut values = Map::new();
|
||||
for (_, prefix, _) in ROWS {
|
||||
@@ -423,6 +428,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A threaded story of three frames, its second frame locked and its third hidden, a type object
|
||||
/// of its own, and the first frame selected with the Selection tool → the app, the frames and
|
||||
/// the other type object.
|
||||
fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 900, "height": 300})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog.\n".repeat(12);
|
||||
let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}});
|
||||
let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap();
|
||||
let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap();
|
||||
let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap();
|
||||
app.run("select.set", json!({"ids": [b]})).unwrap();
|
||||
app.run("object.lock", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [c]})).unwrap();
|
||||
app.run("object.hide", json!({})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
(app, [a, b, c], other)
|
||||
}
|
||||
|
||||
/// Every paragraph's Auto Leading in `id`.
|
||||
fn auto_leadings(app: &VectorcraftApp, id: u64) -> Vec<f64> {
|
||||
match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).justification.auto_leading).collect(),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
|
||||
/// With the Selection tool a selected frame of threaded text stands for its whole story: the
|
||||
/// dialog opens blank where a locked or hidden frame differs, previews on every frame, Cancel
|
||||
/// restores them, and OK changes every frame of the story as it was when the dialog opened, in
|
||||
/// one undo step, leaving other type and a selection made later alone.
|
||||
#[test]
|
||||
fn selection_tool_justification_reaches_every_frame_of_the_story() {
|
||||
let (mut app, frames, other) = story_selected_in_its_first_frame();
|
||||
let [a, _, c] = frames;
|
||||
for id in frames {
|
||||
assert!(!auto_leadings(&app, id).is_empty(), "frame {id} holds text");
|
||||
}
|
||||
app.run("text.setFormat", json!({"ids": [c], "autoLeading": 150})).unwrap();
|
||||
app.run("select.set", json!({"ids": [a]})).unwrap();
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key("autoLeading"), "the hidden frame differs");
|
||||
set(&mut app, "autoLeading", json!(200));
|
||||
set(&mut app, "preview", json!(true));
|
||||
frame(&mut app);
|
||||
for id in frames {
|
||||
assert!(auto_leadings(&app, id).iter().all(|l| *l == 200.0), "the preview shows on frame {id}");
|
||||
}
|
||||
super::super::cancel(&mut app);
|
||||
assert!(auto_leadings(&app, a).iter().all(|l| *l == 120.0));
|
||||
assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "Cancel restores the hidden frame");
|
||||
|
||||
app.run("ui.justification", json!({})).unwrap();
|
||||
// A selection made while the dialog is open does not change what it acts on.
|
||||
app.run("select.set", json!({"ids": [other]})).unwrap();
|
||||
set(&mut app, "autoLeading", json!(140));
|
||||
let undo = app.session.active().unwrap().history.undo.len();
|
||||
super::super::confirm(&mut app).unwrap();
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");
|
||||
for id in frames {
|
||||
assert!(auto_leadings(&app, id).iter().all(|l| *l == 140.0), "frame {id} takes the change");
|
||||
}
|
||||
assert!(auto_leadings(&app, other).iter().all(|l| *l == 120.0), "other type keeps its paragraphs");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(auto_leadings(&app, c).iter().all(|l| *l == 150.0), "undo restores the hidden frame");
|
||||
}
|
||||
|
||||
/// When paragraphs in scope disagree, only shared justification values are filled in.
|
||||
#[test]
|
||||
fn justification_dialog_mixed_paragraph_fields_show_blank() {
|
||||
|
||||
@@ -304,6 +304,23 @@ fn nothing_to_export_keeps_export_as_open_before_the_save_dialog() {
|
||||
assert!(written.borrow().is_empty());
|
||||
}
|
||||
|
||||
/// Art that a plot leaves out (here at 0% opacity) is nothing to export as HP-GL of the art's
|
||||
/// bounds, before the save dialog; other formats still export it.
|
||||
#[test]
|
||||
fn art_a_plot_leaves_out_keeps_hpgl_export_as_open_before_the_save_dialog() {
|
||||
let (mut app, _) = app(1);
|
||||
app.services.pick_save = Some(Box::new(|_: &crate::FilePick| panic!("no save dialog without art the plot draws")));
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
app.run("transparency.set", json!({"opacity": 0})).unwrap();
|
||||
app.run("file.exportAs", json!({"format": "hpgl"})).unwrap();
|
||||
assert!(confirm(&mut app).unwrap_err().contains("no visible art"));
|
||||
assert_eq!(kind(&app), Some("exportAs"));
|
||||
app.services.pick_save = Some(Box::new(|p: &crate::FilePick| Some(format!("/out/{}", p.name))));
|
||||
set(&mut app, "format", json!("png"));
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), Some("pngOptions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_artboards_continue_in_the_save_pdf_dialog() {
|
||||
let (mut app, written) = app(3);
|
||||
|
||||
@@ -889,6 +889,9 @@ font-technology Variable Променливи
|
||||
The selected character's font is not available. Шрифтът на избрания знак не е наличен.
|
||||
No alternates for the selected character. Няма алтернативи за избрания знак.
|
||||
Double-click to replace Двойно щракване за замяна
|
||||
Click to replace Щракване за замяна
|
||||
Show all alternates in the Glyphs panel Показване на всички алтернативи в панела „Глифи“
|
||||
Show Character Alternates Показване на алтернативите на знака
|
||||
Select one character with the Type tool first. Първо изберете един знак с инструмента „Текст“.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} е глиф на {font}, а не на шрифта, с който е зададен избраният текст ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} принадлежи на {base} (U+{base_code}) {place}; избраният от вас знак е {sel} (U+{code}).
|
||||
@@ -6860,6 +6863,9 @@ breakText Glyphs Глифи
|
||||
Open Tutorial Sample Отваряне на примерния файл на урока
|
||||
Finish Tutorial Завършване на урок
|
||||
Next Tutorial Следващ урок
|
||||
Show Learn Tab Показване на раздел в Обучение
|
||||
Show Skill Показване на умение
|
||||
Back One Level Едно ниво назад
|
||||
Back to Skills Обратно към уменията
|
||||
@msg tutorial `{id}` has no practice sample урокът `{id}` няма примерен файл за упражнение
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) първо отворете примерния файл за упражнение на `{id}` (вижте learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1701,6 +1701,9 @@ digits Malayalam Malajalam
|
||||
The selected character's font is not available. Font odabranog znaka nije dostupan.
|
||||
No alternates for the selected character. Nema alternativa za odabrani znak.
|
||||
Double-click to replace Dvostruki klik za zamjenu
|
||||
Click to replace Klik za zamjenu
|
||||
Show all alternates in the Glyphs panel Prikaži sve alternative u panelu Glifovi
|
||||
Show Character Alternates Prikaži alternative znaka
|
||||
Select one character with the Type tool first. Prvo odaberite jedan znak alatom za tekst.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glif fonta {font}, a ne fonta kojim je postavljen odabrani tekst ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pripada znaku {base} (U+{base_code}) {place}; vaš odabir je {sel} (U+{code}).
|
||||
@@ -6170,7 +6173,7 @@ axis Y Axis Y osa
|
||||
@plural Result: {n}|Results: {n} Rezultat: {n}|Rezultati: {n}|Rezultati: {n}
|
||||
@msg select one object odaberite jedan objekat
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Globalno uređivanje radi na putanjama, složenim putanjama, instancama simbola i njihovim grupama
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (rotirane ili zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nisu pronađeni slični objekti (zrcaljene kopije se ne podudaraju); promijenite opcije globalnog uređivanja i pokušajte ponovo
|
||||
@msg Global Edit is off Globalno uređivanje je isključeno
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out iz globalnog uređivanja može se isključiti samo objekt koji nije onaj od kojeg je počelo
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ili raspon poput 1, 3-5, a ne {v}
|
||||
@@ -6913,6 +6916,9 @@ shape Open Otvoren
|
||||
Open Tutorial Sample Otvori uzorak tutorijala
|
||||
Finish Tutorial Završi tutorijal
|
||||
Next Tutorial Sljedeći tutorijal
|
||||
Show Learn Tab Prikaži karticu u Učenju
|
||||
Show Skill Prikaži vještinu
|
||||
Back One Level Jedan nivo nazad
|
||||
Back to Skills Nazad na vještine
|
||||
@msg tutorial `{id}` has no practice sample tutorijal `{id}` nema uzorak za vježbu
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) prvo otvorite uzorak za vježbu tutorijala `{id}` (vidi learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1307,6 +1307,9 @@ antiAlias Sharp Nítid
|
||||
The selected character's font is not available. El tipus de lletra del caràcter seleccionat no està disponible.
|
||||
No alternates for the selected character. No hi ha alternatives per al caràcter seleccionat.
|
||||
Double-click to replace Feu doble clic per substituir
|
||||
Click to replace Feu clic per substituir
|
||||
Show all alternates in the Glyphs panel Mostra totes les alternatives al tauler Glifs
|
||||
Show Character Alternates Mostra les alternatives del caràcter
|
||||
Select one character with the Type tool first. Selecciona primer un caràcter amb l’eina de text.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} és un glif de {font}, no del tipus de lletra amb què està escrit el text seleccionat ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertany a {base} (U+{base_code}) {place}; la teva selecció és {sel} (U+{code}).
|
||||
@@ -5866,7 +5869,7 @@ digits Malayalam Malaiàlam
|
||||
@msg no segment {segment} on line {line} no hi ha el segment {segment} a la línia {line}
|
||||
@msg no selected slice in the image: select slices, or save all of them no hi ha cap tall seleccionat a la imatge: seleccioneu talls o deseu-los tots
|
||||
@msg no selection bounds no hi ha límits de selecció
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies girades o reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no s’ha trobat cap objecte similar (les còpies reflexades no coincideixen); canvieu les opcions d’edició global i torneu-ho a provar
|
||||
@msg Global Edit is off L’edició global no està activa
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out només es pot excloure un objecte de l’edició global que no sigui aquell amb què va començar
|
||||
@msg no slice selected no hi ha cap tall seleccionat
|
||||
@@ -7448,6 +7451,9 @@ breakText Glyphs Glifs
|
||||
Open Tutorial Sample Obre la mostra del tutorial
|
||||
Finish Tutorial Acaba el tutorial
|
||||
Next Tutorial Tutorial següent
|
||||
Show Learn Tab Mostra una pestanya d'Aprèn
|
||||
Show Skill Mostra l'habilitat
|
||||
Back One Level Torna un nivell enrere
|
||||
Back to Skills Torna a les habilitats
|
||||
@msg tutorial `{id}` has no practice sample el tutorial `{id}` no té cap mostra de pràctica
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) obriu primer la mostra de pràctica de `{id}` (vegeu learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1730,6 +1730,9 @@ digits Malayalam Malajálamské
|
||||
The selected character's font is not available. Písmo vybraného znaku není k dispozici.
|
||||
No alternates for the selected character. Vybraný znak nemá žádné alternativy.
|
||||
Double-click to replace Dvojklikem nahradíte
|
||||
Click to replace Kliknutím nahradíte
|
||||
Show all alternates in the Glyphs panel Zobrazit všechny alternativy v panelu Glyfy
|
||||
Show Character Alternates Zobrazovat alternativy znaku
|
||||
Select one character with the Type tool first. Nejprve nástrojem Text vyberte jeden znak.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} je glyf písma {font}, ne písma, kterým je vybraný text nastaven ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} patří znaku {base} (U+{base_code}) {place}; váš výběr je {sel} (U+{code}).
|
||||
@@ -6674,7 +6677,7 @@ axis Y Axis osa Y
|
||||
@plural Result: {n}|Results: {n} Výsledek: {n}|Výsledky: {n}|Výsledky: {n}
|
||||
@msg select one object vyberte jeden objekt
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Globální úpravy pracují s cestami, složenými cestami, instancemi symbolů a jejich skupinami
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (otočené a zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again nebyly nalezeny žádné podobné objekty (zrcadlené kopie se nevyhledávají); změňte možnosti globálních úprav a zkuste to znovu
|
||||
@msg Global Edit is off Globální úpravy nejsou zapnuté
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out z globálních úprav lze vyřadit jen jiný objekt než ten, od kterého začaly
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all nebo rozsah, například 1, 3-5, ne {v}
|
||||
@@ -7583,6 +7586,9 @@ breakText Glyphs Glyfy
|
||||
Open Tutorial Sample Otevřít ukázku výukového kurzu
|
||||
Finish Tutorial Dokončit výukový kurz
|
||||
Next Tutorial Další výukový kurz
|
||||
Show Learn Tab Zobrazit kartu Výuky
|
||||
Show Skill Zobrazit dovednost
|
||||
Back One Level O úroveň zpět
|
||||
Back to Skills Zpět na dovednosti
|
||||
@msg tutorial `{id}` has no practice sample výukový kurz `{id}` nemá cvičnou ukázku
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) nejprve otevřete cvičnou ukázku kurzu `{id}` (viz learn.openSample)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -1636,6 +1636,9 @@ digits Malayalam Malayalam
|
||||
The selected character's font is not available. Skrifttypen for det markerede tegn er ikke tilgængelig.
|
||||
No alternates for the selected character. Ingen alternativer til det markerede tegn.
|
||||
Double-click to replace Dobbeltklik for at erstatte
|
||||
Click to replace Klik for at erstatte
|
||||
Show all alternates in the Glyphs panel Vis alle alternativer i Glyffer-panelet
|
||||
Show Character Alternates Vis tegnalternativer
|
||||
Select one character with the Type tool first. Markér først ét tegn med tekstværktøjet.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} er et glyf i {font}, ikke i den skrifttype, den markerede tekst er sat i ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} hører til {base} (U+{base_code}) {place}; din markering er {sel} (U+{code}).
|
||||
@@ -6875,6 +6878,9 @@ breakText Glyphs Glyffer
|
||||
Open Tutorial Sample Åbn eksempel til vejledning
|
||||
Finish Tutorial Afslut vejledning
|
||||
Next Tutorial Næste vejledning
|
||||
Show Learn Tab Vis fane i Lær
|
||||
Show Skill Vis færdighed
|
||||
Back One Level Ét niveau tilbage
|
||||
Back to Skills Tilbage til færdigheder
|
||||
@msg tutorial `{id}` has no practice sample vejledningen `{id}` har intet øvelseseksempel
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) åbn først øvelseseksemplet til `{id}` (se learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -891,6 +891,9 @@ font-technology Variable Variabel
|
||||
The selected character's font is not available. Die Schrift des ausgewählten Zeichens ist nicht verfügbar.
|
||||
No alternates for the selected character. Keine Alternativen für das ausgewählte Zeichen.
|
||||
Double-click to replace Doppelklicken zum Ersetzen
|
||||
Click to replace Klicken zum Ersetzen
|
||||
Show all alternates in the Glyphs panel Alle Alternativen im Bedienfeld „Glyphen“ anzeigen
|
||||
Show Character Alternates Zeichenalternativen anzeigen
|
||||
Select one character with the Type tool first. Wählen Sie zuerst mit dem Text-Werkzeug ein Zeichen aus.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} ist eine Glyphe von {font}, nicht der Schrift, in der der ausgewählte Text gesetzt ist ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} gehört zu {base} (U+{base_code}) {place}; Ihre Auswahl ist {sel} (U+{code}).
|
||||
@@ -6266,7 +6269,7 @@ axis Y Axis Y-Achse
|
||||
@plural Result: {n}|Results: {n} Ergebnis: {n}|Ergebnisse: {n}
|
||||
@msg select one object wählen Sie ein Objekt aus
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Globale Bearbeitung funktioniert mit Pfaden, zusammengesetzten Pfaden, Symbolinstanzen und Gruppen daraus
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gedrehte oder gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again keine ähnlichen Objekte gefunden (gespiegelte Kopien werden nicht erkannt); ändern Sie die Optionen für globale Bearbeitung und versuchen Sie es erneut
|
||||
@msg Global Edit is off Globale Bearbeitung ist nicht aktiv
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out nur ein Objekt der globalen Bearbeitung außer dem, mit dem sie begonnen hat, kann ausgeschlossen werden
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all oder ein Bereich wie 1, 3-5, nicht {v}
|
||||
@@ -7063,6 +7066,9 @@ breakText Glyphs Glyphen
|
||||
Open Tutorial Sample Übungsdatei des Lernprogramms öffnen
|
||||
Finish Tutorial Lernprogramm abschließen
|
||||
Next Tutorial Nächstes Lernprogramm
|
||||
Show Learn Tab Registerkarte in Lernen anzeigen
|
||||
Show Skill Fertigkeit anzeigen
|
||||
Back One Level Eine Ebene zurück
|
||||
Back to Skills Zurück zu den Fertigkeiten
|
||||
@msg tutorial `{id}` has no practice sample das Lernprogramm `{id}` hat keine Übungsdatei
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) öffnen Sie zuerst die Übungsdatei von `{id}` (siehe learn.openSample)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -862,6 +862,9 @@ font-technology Variable Μεταβλητές
|
||||
The selected character's font is not available. Η γραμματοσειρά του επιλεγμένου χαρακτήρα δεν είναι διαθέσιμη.
|
||||
No alternates for the selected character. Δεν υπάρχουν εναλλακτικοί για τον επιλεγμένο χαρακτήρα.
|
||||
Double-click to replace Διπλό κλικ για αντικατάσταση
|
||||
Click to replace Κλικ για αντικατάσταση
|
||||
Show all alternates in the Glyphs panel Εμφάνιση όλων των εναλλακτικών στον πίνακα Γλύφοι
|
||||
Show Character Alternates Εμφάνιση εναλλακτικών χαρακτήρα
|
||||
Select one character with the Type tool first. Επιλέξτε πρώτα έναν χαρακτήρα με το εργαλείο κειμένου.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). Ο γλύφος {glyph} ανήκει στη γραμματοσειρά {font}, όχι σε αυτήν του επιλεγμένου κειμένου ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). Ο γλύφος {glyph} ανήκει στο {base} (U+{base_code}) {place}· η επιλογή σας είναι {sel} (U+{code}).
|
||||
@@ -6626,7 +6629,7 @@ axis Y Axis Άξονας Y
|
||||
@plural Result: {n}|Results: {n} Αποτέλεσμα: {n}|Αποτελέσματα: {n}
|
||||
@msg select one object επιλέξτε ένα αντικείμενο
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Η καθολική επεξεργασία λειτουργεί σε διαδρομές, σύνθετες διαδρομές, παρουσίες συμβόλων και ομάδες τους
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα περιστραμμένα ή κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again δεν βρέθηκαν παρόμοια αντικείμενα (τα κατοπτρισμένα αντίγραφα δεν εντοπίζονται)· αλλάξτε τις επιλογές καθολικής επεξεργασίας και δοκιμάστε ξανά
|
||||
@msg Global Edit is off Η καθολική επεξεργασία δεν είναι ενεργή
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out μπορεί να εξαιρεθεί μόνο ένα αντικείμενο της καθολικής επεξεργασίας εκτός από εκείνο με το οποίο ξεκίνησε
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all ή ένα εύρος όπως 1, 3-5, όχι {v}
|
||||
@@ -7420,6 +7423,9 @@ breakText Glyphs Γλύφοι
|
||||
Open Tutorial Sample Άνοιγμα δείγματος οδηγού εκμάθησης
|
||||
Finish Tutorial Ολοκλήρωση οδηγού εκμάθησης
|
||||
Next Tutorial Επόμενος οδηγός εκμάθησης
|
||||
Show Learn Tab Εμφάνιση καρτέλας της Εκμάθησης
|
||||
Show Skill Εμφάνιση δεξιότητας
|
||||
Back One Level Ένα επίπεδο πίσω
|
||||
Back to Skills Πίσω στις δεξιότητες
|
||||
@msg tutorial `{id}` has no practice sample ο οδηγός εκμάθησης `{id}` δεν έχει δείγμα εξάσκησης
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) ανοίξτε πρώτα το δείγμα εξάσκησης του `{id}` (δείτε learn.openSample)
|
||||
|
||||
|
Can't render this file because it is too large.
|
@@ -904,6 +904,9 @@ font-technology Variable Variables
|
||||
The selected character's font is not available. La fuente del carácter seleccionado no está disponible.
|
||||
No alternates for the selected character. No hay alternativas para el carácter seleccionado.
|
||||
Double-click to replace Doble clic para reemplazar
|
||||
Click to replace Clic para reemplazar
|
||||
Show all alternates in the Glyphs panel Mostrar todas las alternativas en el panel Glifos
|
||||
Show Character Alternates Mostrar alternativas de carácter
|
||||
Select one character with the Type tool first. Primero selecciona un carácter con la herramienta Texto.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} es un glifo de {font}, no de la fuente del texto seleccionado ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} pertenece a {base} (U+{base_code}) {place}; tu selección es {sel} (U+{code}).
|
||||
@@ -6617,7 +6620,7 @@ axis Y Axis Eje Y
|
||||
@plural Result: {n}|Results: {n} Resultado: {n}|Resultados: {n}
|
||||
@msg select one object seleccione un objeto
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them La edición global funciona con trazados, trazados compuestos, instancias de símbolos y grupos de ellos
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias giradas o reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again no se encontraron objetos similares (las copias reflejadas no coinciden); cambie las opciones de edición global e inténtelo de nuevo
|
||||
@msg Global Edit is off La edición global no está activa
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out solo se puede excluir un objeto de la edición global distinto de aquel con el que empezó
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all o un intervalo como 1, 3-5, no {v}
|
||||
@@ -7527,6 +7530,9 @@ breakText Glyphs Glifos
|
||||
Open Tutorial Sample Abrir muestra del tutorial
|
||||
Finish Tutorial Terminar tutorial
|
||||
Next Tutorial Tutorial siguiente
|
||||
Show Learn Tab Mostrar pestaña de Aprender
|
||||
Show Skill Mostrar habilidad
|
||||
Back One Level Volver un nivel
|
||||
Back to Skills Volver a las habilidades
|
||||
@msg tutorial `{id}` has no practice sample el tutorial `{id}` no tiene muestra de práctica
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) abre primero la muestra de práctica de `{id}` (consulta learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -1703,6 +1703,9 @@ digits Malayalam Malajalam
|
||||
The selected character's font is not available. Valitun merkin fontti ei ole saatavilla.
|
||||
No alternates for the selected character. Valitulla merkillä ei ole vaihtoehtoja.
|
||||
Double-click to replace Korvaa kaksoisnapsauttamalla
|
||||
Click to replace Korvaa napsauttamalla
|
||||
Show all alternates in the Glyphs panel Näytä kaikki vaihtoehdot Merkit-paneelissa
|
||||
Show Character Alternates Näytä merkin vaihtoehdot
|
||||
Select one character with the Type tool first. Valitse ensin yksi merkki tekstityökalulla.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} on fontin {font} glyfi, ei valitun tekstin fontin ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} kuuluu merkille {base} (U+{base_code}) {place}; valintasi on {sel} (U+{code}).
|
||||
@@ -6704,7 +6707,7 @@ axis Y Axis Y-akseli
|
||||
@plural Result: {n}|Results: {n} Tulos: {n}|Tulokset: {n}
|
||||
@msg select one object valitse yksi objekti
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them Yleismuokkaus toimii poluille, yhdistelmäpoluille, symbolien esiintymille ja niiden ryhmille
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (kierrettyjä tai peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again samankaltaisia objekteja ei löytynyt (peilattuja kopioita ei tunnisteta); muuta yleismuokkauksen asetuksia ja yritä uudelleen
|
||||
@msg Global Edit is off Yleismuokkaus ei ole käynnissä
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out yleismuokkauksesta voi jättää pois vain muun objektin kuin sen, josta se alkoi
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards`: all tai alue, kuten 1, 3-5, ei {v}
|
||||
@@ -7502,6 +7505,9 @@ breakText Glyphs Merkit
|
||||
Open Tutorial Sample Avaa opetusohjelman harjoitustiedosto
|
||||
Finish Tutorial Viimeistele opetusohjelma
|
||||
Next Tutorial Seuraava opetusohjelma
|
||||
Show Learn Tab Näytä Opi-paneelin välilehti
|
||||
Show Skill Näytä taito
|
||||
Back One Level Taso takaisin
|
||||
Back to Skills Takaisin taitoihin
|
||||
@msg tutorial `{id}` has no practice sample opetusohjelmalla `{id}` ei ole harjoitustiedostoa
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) avaa ensin opetusohjelman `{id}` harjoitustiedosto (ks. learn.openSample)
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -907,6 +907,9 @@ font-technology Variable Variables
|
||||
The selected character's font is not available. La police du caractère sélectionné n’est pas disponible.
|
||||
No alternates for the selected character. Aucune variante pour le caractère sélectionné.
|
||||
Double-click to replace Double-cliquez pour remplacer
|
||||
Click to replace Cliquez pour remplacer
|
||||
Show all alternates in the Glyphs panel Afficher toutes les variantes dans le panneau Glyphes
|
||||
Show Character Alternates Afficher les variantes de caractère
|
||||
Select one character with the Type tool first. Sélectionnez d’abord un caractère avec l’outil Texte.
|
||||
{glyph} is a glyph of {font}, not of the font the selected text is set in ({text_font}). {glyph} est un glyphe de {font}, et non de la police du texte sélectionné ({text_font}).
|
||||
{glyph} belongs to {base} (U+{base_code}) {place}; your selection is {sel} (U+{code}). {glyph} appartient à {base} (U+{base_code}) {place} ; votre sélection est {sel} (U+{code}).
|
||||
@@ -6669,7 +6672,7 @@ axis Y Axis Axe Y
|
||||
@plural Result: {n}|Results: {n} Résultat : {n}|Résultats : {n}
|
||||
@msg select one object sélectionnez un seul objet
|
||||
@msg Global Edit works on paths, compound paths, symbol instances and groups of them La modification globale s’applique aux tracés, aux tracés transparents, aux occurrences de symboles et aux groupes de ces objets
|
||||
@msg no similar objects found (rotated or reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies pivotées ou symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez
|
||||
@msg no similar objects found (reflected copies are not matched); change the Global Edit Options and try again aucun objet similaire trouvé (les copies symétriques ne sont pas reconnues) ; modifiez les options de modification globale et réessayez
|
||||
@msg Global Edit is off La modification globale n’est pas active
|
||||
@msg only an object of the Global Edit other than the one it started from can be left out seul un objet de la modification globale autre que celui à partir duquel elle a commencé peut être exclu
|
||||
@msg `artboards`: all or a range such as 1, 3-5, not {v} `artboards` : all ou une plage telle que 1, 3-5, et non {v}
|
||||
@@ -7462,6 +7465,9 @@ breakText Glyphs Glyphes
|
||||
Open Tutorial Sample Ouvrir le fichier d’exercice du tutoriel
|
||||
Finish Tutorial Terminer le tutoriel
|
||||
Next Tutorial Tutoriel suivant
|
||||
Show Learn Tab Afficher un onglet d'Apprendre
|
||||
Show Skill Afficher la compétence
|
||||
Back One Level Revenir d'un niveau
|
||||
Back to Skills Retour aux compétences
|
||||
@msg tutorial `{id}` has no practice sample le tutoriel `{id}` n’a pas de fichier d’exercice
|
||||
@msg open the practice sample of `{id}` first (see learn.openSample) ouvrez d’abord le fichier d’exercice de `{id}` (voir learn.openSample)
|
||||
|
||||
|
Can't render this file because it is too large.
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user