diff --git a/apps/vectorcraft/src/clipboard.rs b/apps/vectorcraft/src/clipboard.rs index bcd37ecce3..ec6256a114 100644 --- a/apps/vectorcraft/src/clipboard.rs +++ b/apps/vectorcraft/src/clipboard.rs @@ -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 { 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, mimes: &[&'static str]) -> Option { + copied_file_within(paths, mimes, MAX_BYTES) +} + +/// [`copied_file`] with a file of more than `limit` bytes skipped. +fn copied_file_within(paths: Vec, mimes: &[&'static str], limit: usize) -> Option { 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, mimes: &[&'static str]) -> Option { 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, limit: usize) -> Option> { + 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 = ""; + 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 { uri_list_paths(list).into_iter().map(|p| p.to_string_lossy().into_owned()).collect() } diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 6ab80c8ba0..95c8ecfb7a 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -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 diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index 9cb4f7d727..60f7cf5b3d 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -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] diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 66a6a36baa..bb752267aa 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -428,7 +428,9 @@ pub fn export(doc: &Document, opts: &DxfOptions) -> Result { if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { return Err("the export region is not finite".into()); } - let baked = vectorcraft_effects::bake_document(doc); + // Symbols' art is placed without clipping, as the scene places it. + let baked = + vectorcraft_effects::bake_document_placing_symbols(doc, false, vectorcraft_effects::WrittenLayers { hidden: true, non_printing: true }); let doc = baked.as_ref().unwrap_or(doc); Ok(scene::Scene::new(doc, opts).run()) } diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index a14af8d929..84e547709d 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -1410,6 +1410,29 @@ fn a_stained_instance_is_written_in_its_stained_color() { assert!(!colors.is_empty() && colors.iter().all(|c| c.as_deref() == Some("255")), "{colors:?}"); } +#[test] +fn symbol_art_effects_are_written_where_they_draw() { + // A line and the symbol's line below it, each moved 100 pt right by a Transform effect, start + // at the same x. + let moved = |mut n: Node| { + n.appearance.effects.push(vectorcraft_doc::Effect { + id: "distort.transform".into(), + params: serde_json::json!({"moveH": 100.0}), + visible: true, + }); + n + }; + let mut d = doc_with(|d| { + let line = path(d, shapes::line((10.0, 10.0).into(), (20.0, 10.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + vec![moved(line), Node::new(d.alloc_id(), NodeKind::SymbolInstance { symbol: "Moved".into(), xf: Affine::IDENTITY })] + }); + let art = path(&mut d, shapes::line((10.0, 40.0).into(), (20.0, 40.0).into()), Paint::None, Paint::solid(Color::BLACK), 1.0); + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: "Moved".into(), art: Arc::new(moved(art)) }); + let starts: Vec = entities(&export(&d, &opts(&d)).unwrap()).iter().map(|e| all(e, 10).into_iter().fold(f64::INFINITY, f64::min)).collect(); + assert_eq!(starts.len(), 2, "{starts:?}"); + assert!((starts[0] - starts[1]).abs() < 1e-6, "{starts:?}"); +} + #[test] fn symbols_fanning_out_into_empty_symbols_stop_at_the_symbol_budget() { // Five levels of 100 instances of the level below, ending in an empty symbol: 10^10 empty diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 0ee88bf3b7..3a5626054b 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -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 } diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index 2a7e115daf..9f9ba151e9 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -756,8 +756,8 @@ fn reorient_dash(d: &mut Dash, o: Orient, lens: &[f64]) { /// A key's stroke read along its subpaths turned as `o` says (`lens`: their lengths): its width /// profile (whose sides swap when reversed), pen pressure, tilt, bearing and rotation, its dashes, -/// a gradient along the path or across it (whose sides swap when reversed), and on a reversed -/// open path its arrowheads. +/// a gradient along the path or across it (whose sides swap when reversed). Its arrowheads stay as +/// they are. fn reorient_stroke(s: &mut StrokeLayer, o: Orient, lens: &[f64]) { if o.is_identity() { return; @@ -819,15 +819,47 @@ fn reorient_stroke(s: &mut StrokeLayer, o: Orient, lens: &[f64]) { let out = reoriented(&p.points, o, |f| p.unwrapped_at(f), |v| v + turns, same); p.points = out; } - if o.reversed && !o.closed { - std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); - s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); +} + +/// `n` with every stroke read along its subpaths turned as `o` says, and on a reversed open path +/// its arrowheads swapped. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + let lens: Vec = + node_path(n).map(|(p, ..)| p.subpaths.iter().map(|sp| PathData::new(vec![sp.clone()]).length()).collect()).unwrap_or_default(); + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o, &lens); + if o.reversed && !o.closed { + std::mem::swap(&mut s.start_arrow, &mut s.end_arrow); + s.arrow_scale = (s.arrow_scale.1, s.arrow_scale.0); + } + } } } -/// `n` with every stroke read along its subpaths turned as `o` says. -fn reorient_strokes(n: &mut Node, o: Orient) { - if o.is_identity() { +/// Turn the strokes of path `n` for Reverse Path Direction, before its subpaths flagged in +/// `reversed` are reversed with [`SubPath::reverse`] (a closed one then starts at its last +/// anchor): each stroke's width profile (sides swapped), pen pressure, tilt, bearing and rotation +/// then run the new way, so the stroke draws as it did. A stroke reads one profile along every +/// subpath, so it turns only when all of them turn alike. Arrowheads stay at the path's start and +/// end. +pub fn reverse_stroke_records(n: &mut Node, reversed: &[bool]) { + let NodeKind::Path { path, .. } = &n.kind else { return }; + let mut turns = path.subpaths.iter().enumerate().map(|(i, sp)| { + let mut o = Orient::of(sp); + if reversed.get(i).copied().unwrap_or(false) { + if sp.closed { + o.start_at(sp, sp.anchors.len().saturating_sub(1)); + } + o.reverse(); + } + o + }); + let Some(o) = turns.next() else { return }; + if o.is_identity() || !turns.all(|t| t.same_turn(&o)) { return; } let lens: Vec = @@ -1378,13 +1410,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 = 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 { @@ -1403,7 +1443,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 { - #[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 = Vec::new(); diff --git a/crates/doc/src/depth.rs b/crates/doc/src/depth.rs index 63b7e0ce7c..c846dfc1c6 100644 --- a/crates/doc/src/depth.rs +++ b/crates/doc/src/depth.rs @@ -251,6 +251,45 @@ fn undrawable(heights: &Heights, used: Def, depth: usize) -> bool { heights.get(&used).is_some_and(|h| h.is_none_or(|h| depth + h > MAX_DRAWN_DEPTH)) } +/// How deep the art of a document's symbols and patterns draws, to measure objects that use them +/// outside the document's layers ([`Self::deepest`]). +#[derive(Clone, Debug, Default)] +pub struct DrawnHeights { + symbols: HashMap, + patterns: HashMap, +} + +impl DrawnHeights { + /// The heights of `doc`'s symbols and patterns, as [`Document::too_deep`] counts them: the art + /// is 1 deep, and what it uses is counted under it. A symbol or pattern that uses itself, or + /// draws deeper than [`MAX_DRAWN_DEPTH`], has none. + pub fn of(doc: &Document) -> Self { + let mut out = Self::default(); + for ((pattern, name), h) in heights(doc) { + if let Some(h) = h { + let map = if pattern { &mut out.patterns } else { &mut out.symbols }; + map.insert(name.to_string(), h); + } + } + out + } + + /// The deepest `n`, placed `depth` deep, draws: its own objects and the art of their opacity + /// masks, and under the objects the art of the symbols and patterns they use. A use of a + /// symbol or pattern without a height ([`Self::of`]) counts as the object alone. `n`'s own + /// nesting may pass [`MAX_DEPTH`]. Without recursion. + pub fn deepest(&self, n: &Node, depth: usize) -> usize { + let mut out = depth; + let mut stack = vec![(n, depth)]; + while let Some((n, d)) = stack.pop() { + let used = uses(n).filter_map(|(pattern, name)| if pattern { self.patterns.get(name) } else { self.symbols.get(name) }).max(); + out = out.max(d + used.copied().unwrap_or(0)); + stack.extend(n.children().into_iter().flatten().chain(n.extras().mask.as_ref().map(|m| &m.art)).map(|c| (&**c, d + 1))); + } + out + } +} + /// Take out of `nodes` (each `depth` deep) the uses `bad` picks (given the depth of the object): /// an instance goes, a pattern paint becomes no paint. Returns whether anything changed. For trees /// within [`MAX_DEPTH`]. @@ -623,4 +662,23 @@ mod tests { d.patterns.push(crate::PatternDef::new("q", vec![painted(3, "none")])); assert!(!d.too_deep(None) && !d.limit_depth()); } + + #[test] + fn drawn_heights_measure_objects_outside_the_layers() { + // s0 holds s1 two groups deep, which holds s2, which holds an instance of nothing known: + // s2 draws 3 deep, s1 6 and s0 9. + let d = symbols(3, 2, "none"); + let heights = DrawnHeights::of(&d); + let on_layer = instance(1, "s0"); + assert_eq!(heights.deepest(&on_layer, 2), 2 + 9); + // An object's own nesting and its mask's art count past MAX_DEPTH. + let mut deep = Node::group(NodeId(5), vec![nested(MAX_DEPTH + 10)]); + deep.extras_mut().mask = Some(Box::new(OpacityMask::new((*instance(6, "s2")).clone(), true))); + assert_eq!(heights.deepest(&deep, 1), 1 + 1 + MAX_DEPTH + 10); + let shallow = Node::group(NodeId(5), vec![instance(7, "s1")]); + assert_eq!(heights.deepest(&shallow, 100), 101 + 6); + // A symbol that holds itself has no height, so its instance counts alone. + let looped = DrawnHeights::of(&symbols(2, 1, "s0")); + assert_eq!(looped.deepest(&instance(1, "s0"), 2), 2); + } } diff --git a/crates/doc/src/graph.rs b/crates/doc/src/graph.rs index d561663524..2746390b86 100644 --- a/crates/doc/src/graph.rs +++ b/crates/doc/src/graph.rs @@ -150,6 +150,10 @@ pub struct LabelStyles { pub categories: Option, #[serde(skip_serializing_if = "Option::is_none")] pub legend: Option, + /// 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, #[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 @@ -532,6 +548,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>> { diff --git a/crates/doc/src/hit.rs b/crates/doc/src/hit.rs index fd907a26cc..93b690ccf8 100644 --- a/crates/doc/src/hit.rs +++ b/crates/doc/src/hit.rs @@ -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, 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 { + fn of(doc: &'a Document, inst: &Node) -> Option { 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) -> Option { - whole.or_else(|| self.map.as_ref()?.one_cell(n, doc)) + fn whole(&self, n: &Node, whole: Option) -> Option { + 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) -> Option { + fn place(&self, n: &Node, whole: Option) -> Option { 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) -> Option { + fn place_unclipped(&self, n: &Node, whole: Option) -> Option { 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> { 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) -> 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) } diff --git a/crates/doc/src/hit/tests.rs b/crates/doc/src/hit/tests.rs index 3eac4202b6..a4d1cbbf10 100644 --- a/crates/doc/src/hit/tests.rs +++ b/crates/doc/src/hit/tests.rs @@ -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. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index cb78d96ba9..c286492236 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -61,6 +61,7 @@ pub mod range; pub mod rastersettings; mod reach; pub mod recolor; +pub mod resample; pub mod selection; pub mod separated; pub mod setup; diff --git a/crates/doc/src/links.rs b/crates/doc/src/links.rs index b2f46700eb..80740b7cc2 100644 --- a/crates/doc/src/links.rs +++ b/crates/doc/src/links.rs @@ -229,7 +229,8 @@ fn proxy_png(bytes: &[u8]) -> Option> { /// Decoded image `img` shrunk to fit [`PROXY_SIZE`] as a PNG. fn proxy_of(img: &image::DynamicImage) -> Option> { - let small = img.thumbnail(PROXY_SIZE, PROXY_SIZE); + let (w, h) = crate::resample::fit_size(img.width(), img.height(), PROXY_SIZE, PROXY_SIZE); + let small = crate::resample::averaged(img, w, h); let mut png = Vec::new(); small.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?; Some(png) @@ -732,4 +733,19 @@ mod tests { image(&mut d, "c", None); assert_eq!(d.linked_only_images().into_iter().collect::>(), ["a"]); } + + /// The preview of an image with clear pixels keeps the color of its opaque ones: the clear + /// black beside them does not darken them, whichever way the preview is made. + #[test] + fn a_preview_keeps_the_color_beside_clear_pixels() { + let mut full = vec![]; + image::RgbaImage::from_fn(1024, 4, |x, _| if x % 2 == 0 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) }) + .write_to(&mut Cursor::new(&mut full), image::ImageFormat::Png) + .unwrap(); + for blob in [ImageBlob::png(full.clone()).with_proxy(), ImageBlob::png(full.clone()).checked_with_proxy(true).unwrap()] { + let p = image::load_from_memory(blob.proxy.as_ref().unwrap()).unwrap().into_rgba8(); + assert_eq!(p.dimensions(), (PROXY_SIZE, 1)); + assert!(p.pixels().all(|q| q.0 == [200, 30, 30, 128]), "{:?}", p.get_pixel(0, 0)); + } + } } diff --git a/crates/doc/src/objects_on_path.rs b/crates/doc/src/objects_on_path.rs index 35de7cce73..7362f324ea 100644 --- a/crates/doc/src/objects_on_path.rs +++ b/crates/doc/src/objects_on_path.rs @@ -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 { + pub fn new(path: &PathData) -> Option { 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, 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, 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, start: f64) -> Option> { - 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, 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 diff --git a/crates/doc/src/resample.rs b/crates/doc/src/resample.rs new file mode 100644 index 0000000000..bc72cce1db --- /dev/null +++ b/crates/doc/src/resample.rs @@ -0,0 +1,259 @@ +//! Scaling images whose pixels carry straight (not premultiplied) alpha. +//! +//! `image`'s filtered resampling filters each channel on its own, which is correct only for +//! premultiplied pixels, and its thumbnail averages each channel on its own too, so on +//! straight-alpha pixels the color under clear pixels bleeds into the opaque ones next to them +//! (clear black darkens a cut-out's edge). [`resized`], [`fitted`] and [`averaged`] weight each +//! pixel's color by its alpha while scaling, as the streaming shrink in the renderer does. + +use image::imageops::FilterType; +use image::{DynamicImage, RgbaImage}; + +/// How [`resized`] makes each new pixel from the old ones. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Resample { + /// `image`'s filtered resampling with this filter. + Filter(FilterType), + /// The mean of the old pixels each new one covers ([`averaged`]). + Average, +} + +/// `img` scaled to `w` × `h` pixels as `how` says, each pixel's color weighted by its alpha. +/// Filtered or averaged, an image with clear or partly clear pixels comes back as 8-bit RGBA +/// whatever its own pixel format, and a pixel that ends up wholly clear is transparent black. An +/// image without clear pixels is scaled as `image` scales it, in its own pixel format. +/// Nearest-neighbor picking leaves the pixels as they are, in their own format, and is not +/// weighted. Filtered, the colors keep the precision of premultiplied 8-bit pixels, and an 8-bit +/// RGBA image is premultiplied in place. +pub fn resized(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + if (w, h) == (img.width(), img.height()) { + return img; + } + let f = match how { + Resample::Average => return averaged(&img, w, h), + Resample::Filter(f) => f, + }; + if f == FilterType::Nearest || opaque(&img) { + return img.resize_exact(w, h, f); + } + let mut rgba = img.into_rgba8(); + for p in rgba.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = times_alpha(*c, *a); + } + } + let mut out = image::imageops::resize(&rgba, w, h, f); + for p in out.pixels_mut() { + let [r, g, b, a] = &mut p.0; + for c in [r, g, b] { + *c = over_alpha(*c, *a); + } + } + DynamicImage::ImageRgba8(out) +} + +/// [`resized`] to the largest size that fits `w` × `h` pixels and keeps `img`'s proportions, the +/// size `image`'s own `resize` and `thumbnail` pick. +pub fn fitted(img: DynamicImage, w: u32, h: u32, how: Resample) -> DynamicImage { + let (nw, nh) = fit_size(img.width(), img.height(), w, h); + resized(img, nw, nh, how) +} + +/// `img` scaled to `w` × `h` pixels, each new pixel the mean of the old pixels it covers, each old +/// pixel counted by the area of it covered and its color weighted by its alpha. `img` is read one +/// row at a time, so besides the result this takes one row of 8-bit RGBA at most. An image +/// without clear pixels is scaled as `image`'s thumbnail scales it. +pub fn averaged(img: &DynamicImage, w: u32, h: u32) -> DynamicImage { + let (sw, sh) = (img.width(), img.height()); + if opaque(img) || w == 0 || h == 0 || sw == 0 || sh == 0 { + return img.thumbnail_exact(w, h); + } + // Per new pixel of the row being made: red, green and blue times alpha, alpha (all times the + // area covered), and the area covered. + let mut sums = vec![([0f64; 4], 0f64); w as usize]; + let mut out = Vec::with_capacity(w as usize * h as usize * 4); + for ty in 0..h { + for (y, wy) in covered(ty, sh, h) { + let row = rgba_row(img, y as u32); + for ((s, area), tx) in sums.iter_mut().zip(0..w) { + for (x, wx) in covered(tx, sw, w) { + let Some(p) = row.get(x * 4..x * 4 + 4) else { continue }; + let k = wx * wy; + let a = f64::from(p[3]) * k; + s[0] += f64::from(p[0]) * a; + s[1] += f64::from(p[1]) * a; + s[2] += f64::from(p[2]) * a; + s[3] += a; + *area += k; + } + } + } + let byte = |v: f64| v.round().clamp(0.0, 255.0) as u8; + for (s, area) in &mut sums { + out.extend(if s[3] <= 0.0 { [0; 4] } else { [byte(s[0] / s[3]), byte(s[1] / s[3]), byte(s[2] / s[3]), byte(s[3] / *area)] }); + (*s, *area) = ([0.0; 4], 0.0); + } + } + RgbaImage::from_raw(w, h, out).map_or_else(|| img.thumbnail_exact(w, h), DynamicImage::ImageRgba8) +} + +/// Row `y` of `img` as 8-bit RGBA: borrowed from an 8-bit RGBA image, converted from others. +fn rgba_row(img: &DynamicImage, y: u32) -> std::borrow::Cow<'_, [u8]> { + let n = img.width() as usize * 4; + match img { + DynamicImage::ImageRgba8(b) => std::borrow::Cow::Borrowed(b.as_raw().get(y as usize * n..(y as usize + 1) * n).unwrap_or_default()), + other => std::borrow::Cow::Owned(other.crop_imm(0, y, other.width(), 1).into_rgba8().into_raw()), + } +} + +/// The old pixels that new pixel `t` of `tn` covers along a side of `n` old pixels, each with the +/// length of it covered (in old pixels), worked out as they are read so that nothing is stored. +fn covered(t: u32, n: u32, tn: u32) -> impl Iterator { + let scale = f64::from(n) / f64::from(tn); + let (a, b) = (f64::from(t) * scale, (f64::from(t) + 1.0) * scale); + let (first, end) = (a.floor() as u32, (b.ceil() as u32).min(n)); + (first..end.max(first + 1).min(n)).filter_map(move |i| { + let len = b.min(f64::from(i) + 1.0) - a.max(f64::from(i)); + (len > 0.0).then_some((i as usize, len)) + }) +} + +/// Each 4-channel pixel of `px` with all four channels multiplied by its value in `alpha` (one +/// sample a pixel, 0 to 255): ink amounts or colors made ready for scaling beside a separate alpha +/// channel. Pixels past the end of `alpha` are left as they are. +pub fn premultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = times_alpha(*c, a); + } + } +} + +/// [`premultiply`] undone after scaling: each 4-channel pixel of `px` divided by its value in the +/// scaled `alpha`, and 0 where that is 0. +pub fn unpremultiply(px: &mut [u8], alpha: &[u8]) { + for (p, &a) in px.as_chunks_mut::<4>().0.iter_mut().zip(alpha) { + for c in p { + *c = over_alpha(*c, a); + } + } +} + +/// Does `img` have no alpha channel, or is every pixel of it opaque? +fn opaque(img: &DynamicImage) -> bool { + match img { + DynamicImage::ImageRgba8(b) => b.pixels().all(|p| p[3] == u8::MAX), + DynamicImage::ImageLumaA8(b) => b.pixels().all(|p| p[1] == u8::MAX), + DynamicImage::ImageRgba16(b) => b.pixels().all(|p| p[3] == u16::MAX), + DynamicImage::ImageLumaA16(b) => b.pixels().all(|p| p[1] == u16::MAX), + DynamicImage::ImageRgba32F(b) => b.pixels().all(|p| p[3] >= 1.0), + other => !other.color().has_alpha(), + } +} + +/// Channel `c` times alpha `a`, both 0 to 255. +fn times_alpha(c: u8, a: u8) -> u8 { + ((u16::from(c) * u16::from(a) + 127) / 255) as u8 +} + +/// Premultiplied channel `c` divided by alpha `a` (0 when `a` is 0). +fn over_alpha(c: u8, a: u8) -> u8 { + if a == 0 { + return 0; + } + let a = u32::from(a); + ((u32::from(c) * 255 + a / 2) / a).min(255) as u8 +} + +/// The largest size of at most `nw` × `nh` with the proportions of `w` × `h`, at least a pixel a +/// side. +pub fn fit_size(w: u32, h: u32, nw: u32, nh: u32) -> (u32, u32) { + if w == 0 || h == 0 { + return (nw.max(1), nh.max(1)); + } + let ratio = (f64::from(nw) / f64::from(w)).min(f64::from(nh) / f64::from(h)); + let side = |n: u32| ((f64::from(n) * ratio).round() as u32).max(1); + (side(w), side(h)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A 4 × 2 image whose left half is opaque red and whose right half is clear black. + fn half_clear() -> DynamicImage { + DynamicImage::ImageRgba8(RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([200, 30, 30, 255]) } else { image::Rgba([0, 0, 0, 0]) })) + } + + /// Is the color of `p`, premultiplied by its alpha, within a step of `rgb` premultiplied by the + /// same alpha (the precision of premultiplied 8-bit pixels)? + fn close(p: [u8; 4], rgb: [u8; 3]) -> bool { + p[..3].iter().zip(rgb).all(|(&c, want)| times_alpha(c, p[3]).abs_diff(times_alpha(want, p[3])) <= 1) + } + + #[test] + fn averaging_keeps_the_color_of_the_opaque_pixels() { + let p = resized(half_clear(), 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0; + assert!(close(p, [200, 30, 30]) && p[3].abs_diff(128) <= 1, "{p:?}"); + } + + /// Three pixels averaged into two: each new pixel takes one old pixel whole and half of the + /// clear one in the middle. + #[test] + fn averaging_counts_the_part_of_a_pixel_covered() { + let img = RgbaImage::from_fn(3, 1, |x, _| if x == 1 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([200, 30, 30, 255]) }); + let out = averaged(&DynamicImage::ImageRgba8(img), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 30, 30, 170], [200, 30, 30, 170]].concat()); + // Gray with alpha is read the same way. + let gray = image::GrayAlphaImage::from_fn(3, 1, |x, _| image::LumaA(if x == 1 { [0, 0] } else { [200, 255] })); + let out = averaged(&DynamicImage::ImageLumaA8(gray), 2, 1).into_rgba8(); + assert_eq!(out.into_raw(), [[200, 200, 200, 170], [200, 200, 200, 170]].concat()); + } + + #[test] + fn filtering_keeps_the_color_of_the_opaque_pixels() { + for f in [FilterType::Triangle, FilterType::CatmullRom, FilterType::Lanczos3] { + let out = resized(half_clear(), 2, 1, Resample::Filter(f)).into_rgba8(); + for x in 0..2 { + let p = out.get_pixel(x, 0).0; + assert!(p[3] == 0 || close(p, [200, 30, 30]), "{f:?} pixel {x}: {p:?}"); + } + } + } + + #[test] + fn fitted_keeps_proportions() { + let img = DynamicImage::ImageRgba8(RgbaImage::new(400, 100)); + let out = fitted(img, 64, 64, Resample::Average); + assert_eq!((out.width(), out.height()), (64, 16)); + } + + #[test] + fn opaque_and_alpha_free_images_scale_as_image_scales_them() { + let rgb = DynamicImage::ImageRgb8(image::RgbImage::from_fn(5, 3, |x, y| image::Rgb([x as u8 * 40, y as u8 * 60, 7]))); + let ours = resized(rgb.clone(), 2, 2, Resample::Filter(FilterType::Triangle)); + assert_eq!(ours, rgb.resize_exact(2, 2, FilterType::Triangle)); + let opaque = DynamicImage::ImageRgba8(rgb.to_rgba8()); + assert_eq!(resized(opaque.clone(), 2, 2, Resample::Average), opaque.thumbnail_exact(2, 2)); + } + + #[test] + fn a_wholly_clear_result_is_transparent_black() { + let img = DynamicImage::ImageRgba8(RgbaImage::from_pixel(4, 4, image::Rgba([90, 90, 90, 0]))); + assert_eq!(resized(img, 1, 1, Resample::Average).into_rgba8().get_pixel(0, 0).0, [0, 0, 0, 0]); + } + + #[test] + fn premultiply_and_unpremultiply_scale_beside_a_separate_alpha() { + // Two pixels of inks, the second clear: averaged, the first one's inks remain. + let mut px = vec![40, 80, 120, 160, 0, 0, 0, 0]; + premultiply(&mut px, &[255, 0]); + let img = RgbaImage::from_raw(2, 1, px).unwrap(); + let mut out = image::imageops::thumbnail(&img, 1, 1).into_raw(); + let alpha = image::imageops::thumbnail(&image::GrayImage::from_raw(2, 1, vec![255, 0]).unwrap(), 1, 1).into_raw(); + unpremultiply(&mut out, &alpha); + let want = [40, 80, 120, 160]; + assert!(out.iter().zip(want).all(|(&c, w)| times_alpha(c, alpha[0]).abs_diff(times_alpha(w, alpha[0])) <= 1), "{out:?} {alpha:?}"); + } +} diff --git a/crates/doc/src/swatches.rs b/crates/doc/src/swatches.rs index 844aa8c810..63951d8104 100644 --- a/crates/doc/src/swatches.rs +++ b/crates/doc/src/swatches.rs @@ -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 Option { + 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 { 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, _: &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, _: &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(); diff --git a/crates/doc/src/symbol_nine_slice.rs b/crates/doc/src/symbol_nine_slice.rs index 3c8c1b324d..4877ee7b57 100644 --- a/crates/doc/src/symbol_nine_slice.rs +++ b/crates/doc/src/symbol_nine_slice.rs @@ -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 { - 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 { + 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 { - 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 { - 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 { 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 { - 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)), } } diff --git a/crates/doc/src/symbols.rs b/crates/doc/src/symbols.rs index 822dce49ea..944accfe4b 100644 --- a/crates/doc/src/symbols.rs +++ b/crates/doc/src/symbols.rs @@ -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 = 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 { + 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 { 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, 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 { + (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::>(), exact); + let s = d.symbol_boxes().quick(); + assert_eq!(holders.iter().map(|n| n.visual_bounds_with(&s)).collect::>(), 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); diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index f75490557d..d648dd9258 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -17,7 +17,8 @@ //! object of its own, a stroke a path stroked by it alone (brushed strokes their brush art, made by //! the caller). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; use std::sync::Arc; use vectorcraft_doc::color::Paint; @@ -26,7 +27,7 @@ use vectorcraft_geom::{Affine, FillRule, PathData, Rect, Vec2}; use crate::distort::{transform_move, transform_offsets}; use crate::group::{has_own_paint, paints}; -use crate::reshape::{as_art, clipping_text_outline, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; +use crate::reshape::{as_art, clipping_text_outline, dressed, geometry_effects, glyph_outlines, outline_nested_text, reshape_leaves}; use crate::{ GeomContext, adjust_in_document, apply_geometry_carrying, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry, has_instance_row_raster, has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, reshape, @@ -114,13 +115,181 @@ thread_local! { /// The objects of symbol art that [`bake_document`] has baked for instances inside the art of /// other instances, counted against [`crate::SYMBOL_ART_MAX_NODES`]. static NESTED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// While [`bake_document_placing_symbols`] bakes: the symbols whose instances become their + /// placed art ([`placed_symbol_art`]). + static PLACING: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; + /// The objects in the baked art of the instances that [`bake_node`] has replaced by their + /// placed art for one export ([`placed_symbol_art`]), counted against [`Placing::most_nodes`]. + static PLACED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// The objects in the baked art of the instances that [`bake_node`] baked for one export and + /// then left as they are ([`placed_fits`] refused the art), counted with [`PLACED_NODES`] + /// against [`Placing::most_nodes`] before more art is baked. Never given back. + static DROPPED_NODES: std::cell::Cell = const { std::cell::Cell::new(0) }; + /// How deep the object [`bake_node`] is baking sits in the tree it returns: a top-level layer + /// is 1 deep. + static LEVEL: std::cell::Cell = const { std::cell::Cell::new(1) }; + /// Whether [`bake_node`] is baking inside an object the export does not write ([`unwritten`]), + /// where no instance is placed. + static UNWRITTEN: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// `f` run for an object one level deeper ([`LEVEL`]) than the one being baked. +fn one_deeper(f: impl FnOnce() -> T) -> T { + LEVEL.with(|c| c.set(c.get() + 1)); + let out = f(); + LEVEL.with(|c| c.set(c.get().saturating_sub(1))); + out +} + +/// The symbols whose instances [`bake_document_placing_symbols`] replaces by their placed art, and +/// how that art is placed through 9-slice guides. +struct Placing { + /// The names of the symbols whose art draws something that baking changes, directly or through + /// the instances of other such symbols in it ([`drawn_symbols`]). + symbols: HashSet, + /// Whether 9-slice cells are clipped ([`Document::place_symbol_art`]) or not + /// ([`Document::place_symbol_art_unclipped`]). + clip_slices: bool, + /// Most objects the baked art of all placed instances may hold together: + /// [`vectorcraft_doc::MAX_SYMBOL_NODES`] for an export. + most_nodes: u64, + /// How deep the document's symbols and patterns draw, for the instances and pattern paints + /// left in placed art. + heights: vectorcraft_doc::depth::DrawnHeights, + /// The hidden layers and layers that do not print that the export writes. + layers: WrittenLayers, +} + +/// The layers an exporter writes besides the shown, printing ones +/// ([`bake_document_placing_symbols`]). Template layers are never written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct WrittenLayers { + /// Hidden layers (written turned off, as PDF layers and DXF write them). + pub hidden: bool, + /// Layers that do not print. + pub non_printing: bool, +} + +/// Does the export leave `n` and everything in it out, while [`bake_document_placing_symbols`] +/// bakes? A hidden object other than a layer, a template layer, and a hidden layer or a layer +/// that does not print when the export leaves those out ([`WrittenLayers`]). +fn unwritten(n: &Node) -> bool { + PLACING.with(|p| { + p.borrow().as_ref().is_some_and(|p| match n.kind { + NodeKind::Layer { template, printable, .. } => template || !(printable || p.layers.non_printing) || !(n.visible || p.layers.hidden), + _ => !n.visible, + }) + }) +} + +/// Can `baked`, the baked art of a placed instance, take the instance's place [`LEVEL`] deep, +/// when the art placed for the export so far holds `spent` objects and the art baked for +/// instances left as they are holds `dropped` ([`DROPPED_NODES`])? `Some` with the +/// objects placed then, when its objects (opacity masks' art included) fit with those within +/// [`Placing::most_nodes`] and nothing in it, the symbols and patterns it uses included, draws +/// deeper than [`vectorcraft_doc::depth::MAX_DRAWN_DEPTH`]. +fn placed_fits(baked: &Node, spent: u64, dropped: u64) -> Option { + PLACING.with(|p| { + let p = p.borrow(); + let p = p.as_ref()?; + let spent = spent.saturating_add(baked.count_with_masks() as u64); + let deepest = p.heights.deepest(baked, LEVEL.with(|c| c.get())); + (spent.saturating_add(dropped) <= p.most_nodes && deepest <= vectorcraft_doc::depth::MAX_DRAWN_DEPTH).then_some(spent) + }) +} + +/// The names of the symbols of `d` whose art needs baking ([`needs_bake`]), and of those whose art +/// holds an instance of one of them at any depth. +fn drawn_symbols(d: &Document) -> HashSet { + let mut out: HashSet = d.symbols.iter().filter(|s| needs_bake(&s.art)).map(|s| s.name.clone()).collect(); + if out.is_empty() { + return out; + } + let uses: Vec<(&str, Vec)> = d + .symbols + .iter() + .filter(|s| !out.contains(&s.name)) + .map(|s| { + let mut names = vec![]; + s.art.walk(&mut |c| { + if let NodeKind::SymbolInstance { symbol, .. } = &c.kind { + names.push(symbol.clone()); + } + }); + (s.name.as_str(), names) + }) + .collect(); + loop { + let before = out.len(); + for (name, names) in &uses { + if !out.contains(*name) && names.iter().any(|x| out.contains(x)) { + out.insert((*name).to_string()); + } + } + if out.len() == before { + return out; + } + } +} + +/// Does `n`'s subtree hold an instance of a symbol that [`bake_document_placing_symbols`] replaces +/// by its placed art? False outside it, and for objects the export does not write ([`unwritten`]). +fn holds_placed_symbol(n: &Node) -> bool { + fn walk(n: &Node, symbols: &HashSet) -> bool { + !unwritten(n) + && (matches!(&n.kind, NodeKind::SymbolInstance { symbol, .. } if symbols.contains(symbol)) + || n.children().is_some_and(|ch| ch.iter().any(|c| walk(c, symbols)))) + } + !UNWRITTEN.with(|c| c.get()) && PLACING.with(|p| p.borrow().as_ref().is_some_and(|p| walk(n, &p.symbols))) +} + +/// Symbol instance `n`, while [`bake_document_placing_symbols`] bakes and its symbol is one it +/// places, as the art it draws: the symbol's art with the instance's own appearance changes and +/// its stain ([`vectorcraft_doc::symbol_overrides::instance_art`]), placed by the instance as the exporters +/// place it, in a group with the instance's id, name, visibility, transparency, opacity mask, +/// raster effects and attributes (its URL among them). The art is placed before its effects are baked, as the canvas draws it, so an +/// instance that scales its art does not scale what its effects do. `None` for other objects, +/// for instances the export does not write ([`unwritten`], hidden or inside such an object), for +/// instances nested [`vectorcraft_doc::depth::MAX_DEPTH`] deep in such art, and when the art +/// as placed, before its effects are baked, would already take the art placed for the export, +/// with the art baked for instances left as they are ([`DROPPED_NODES`]), past +/// [`Placing::most_nodes`]; those instances stay as they are. `Some` with the objects in the art +/// as placed, before its effects are baked. [`bake_node`] checks the baked art with +/// [`placed_fits`]. +fn placed_symbol_art(d: &Document, n: &Node) -> Option<(Node, u64)> { + let NodeKind::SymbolInstance { symbol, xf } = &n.kind else { return None }; + let (clip, most_nodes) = PLACING.with(|p| p.borrow().as_ref().filter(|p| p.symbols.contains(symbol)).map(|p| (p.clip_slices, p.most_nodes)))?; + if INSTANCE_DEPTH.with(|c| c.get()) >= vectorcraft_doc::depth::MAX_DEPTH || UNWRITTEN.with(|c| c.get()) || unwritten(n) { + return None; + } + let sym = d.symbols.iter().find(|s| s.name == *symbol)?; + let art = vectorcraft_doc::symbol_overrides::instance_art(&sym.art, n); + let most = match d.symbol_slice_map(symbol, *xf) { + Some(m) if clip => m.most_placed(&art, d), + Some(m) => m.most_placed_unclipped(&art, d), + None => art.count_with_masks() as u64, + }; + if PLACED_NODES.with(|c| c.get()).saturating_add(DROPPED_NODES.with(|c| c.get())).saturating_add(most) > most_nodes { + return None; + } + let placed = if clip { d.place_symbol_art(symbol, &art, *xf) } else { d.place_symbol_art_unclipped(symbol, &art, *xf) }?; + let mut g = dressed(Node::group(n.id, vec![Arc::new(placed)]), n); + g.extras_mut().attrs = n.extras().attrs.clone(); + Some((g, most)) } /// `n` with its geometry effects evaluated (`None` = unchanged). fn bake_node(d: &mut Document, n: &Node) -> Option { + // Inside an object the export leaves out, effects are baked but no instance is placed. + if !UNWRITTEN.with(|c| c.get()) && unwritten(n) { + UNWRITTEN.with(|c| c.set(true)); + let out = bake_node(d, n); + UNWRITTEN.with(|c| c.set(false)); + return out; + } // A cancelled export leaves the rest of the document as it is: the caller drops the result. // A ruler guide draws nothing and stays as it is, last in its layer. - if n.is_ruler_guide() || !needs_bake(n) || vectorcraft_doc::cancel::is_cancelled() { + if n.is_ruler_guide() || !(needs_bake(n) || holds_placed_symbol(n)) || vectorcraft_doc::cancel::is_cancelled() { return None; } // Colour adjustments: the object recoloured (inside it too), then baked as it is. @@ -181,6 +350,26 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { } return Some(baked.unwrap_or(m)); } + // Symbol instances whose art draws live effects, when the caller asks for them: the art + // placed, then baked. When the baked art passes the export's budget, or would draw too deep + // where the instance is, it is dropped with the art placed for the instances inside it, and + // the instance stays as it is. While the art bakes, its objects before baking are held in + // the budget, so the instances inside it are placed only within what is left; art baked and + // then dropped still counts against the budget for the instances after it. + if let Some((m, most)) = placed_symbol_art(d, n) { + let spent = PLACED_NODES.with(|c| c.get()); + PLACED_NODES.with(|c| c.set(spent.saturating_add(most))); + INSTANCE_DEPTH.with(|c| c.set(c.get() + 1)); + let baked = bake_node(d, &m); + INSTANCE_DEPTH.with(|c| c.set(c.get() - 1)); + let baked = baked.unwrap_or(m); + if let Some(now) = placed_fits(&baked, spent, DROPPED_NODES.with(|c| c.get())) { + PLACED_NODES.with(|c| c.set(now)); + return Some(baked); + } + PLACED_NODES.with(|c| c.set(spent)); + DROPPED_NODES.with(|c| c.set(c.get().saturating_add(baked.count_with_masks() as u64))); + } // A path whose Transform effect copies it with a gradient, or scales its strokes or moves its // patterns: each copy an object of its own, so its gradient fits it rather than spanning // every copy and its strokes and patterns can differ from the others'. @@ -214,11 +403,11 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { .enumerate() .map(|(i, c)| match (i < clip).then(|| crate::clip::with_clip_effects_applied(c)).flatten() { Some(a) => { - let mut a = bake_node(d, &a).unwrap_or(a); + let mut a = one_deeper(|| bake_node(d, &a)).unwrap_or(a); fresh_ids(d, &mut a, &mut std::collections::HashSet::new()); Arc::new(a) } - None => bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone()), + None => one_deeper(|| bake_node(d, c)).map(Arc::new).unwrap_or_else(|| c.clone()), }) .collect(); if let Some(ch) = m.children_mut() { @@ -295,7 +484,7 @@ fn bake_pieces(d: &mut Document, mut m: Node) -> Node { .into_iter() .map(|c| { let c = Arc::unwrap_or_clone(c); - let mut c = bake_node(d, &c).unwrap_or(c); + let mut c = one_deeper(|| bake_node(d, &c)).unwrap_or(c); fresh_ids(d, &mut c, &mut seen); Arc::new(c) }) @@ -772,8 +961,60 @@ fn bake_appearance_pieces(doc: Option<&Document>, n: &mut Node, outline_strokes: /// resources of its placed documents' art added ([`Document::with_placed_art`]); `None` when /// there is nothing to do (export it as is). pub fn bake_document(doc: &Document) -> Option { + bake(doc, None) +} + +/// [`bake_document`], and every symbol instance whose symbol's art draws something baking changes +/// (live geometry effects, color adjustments and the rest [`needs_bake`] counts, in its own art or +/// in the art of the symbols it places) replaced by that art as the instance places it, baked +/// ([`placed_symbol_art`]), so the art draws as on the canvas wherever the instance is. For +/// exporters that write each instance's art in its place (PDF, print, EPS, DXF), rather than +/// writing each symbol once. `clip_slices`: whether 9-slice cells are clipped +/// ([`Document::place_symbol_art`]) or not ([`Document::place_symbol_art_unclipped`]), as the +/// exporter places other instances. `layers`: the hidden layers and layers that do not print +/// that the exporter writes. Instances the export does not write (hidden ones, those in hidden +/// objects, on template layers, and on the layers `layers` leaves out) stay as they are. The +/// symbols' own art is left as it is. +pub fn bake_document_placing_symbols(doc: &Document, clip_slices: bool, layers: WrittenLayers) -> Option { + bake(doc, Some((clip_slices, vectorcraft_doc::MAX_SYMBOL_NODES, layers))) +} + +/// Restores what [`bake`] changes in the thread's baking state when it ends. +struct Restore(Option>, u64, u64, usize, bool); + +impl Drop for Restore { + fn drop(&mut self) { + PLACING.with(|p| *p.borrow_mut() = self.0.take()); + PLACED_NODES.with(|c| c.set(self.1)); + DROPPED_NODES.with(|c| c.set(self.2)); + LEVEL.with(|c| c.set(self.3)); + UNWRITTEN.with(|c| c.set(self.4)); + } +} + +/// [`bake_document`], or with `placing` [`bake_document_placing_symbols`] with that `clip_slices`, +/// [`Placing::most_nodes`] and [`Placing::layers`]. +fn bake(doc: &Document, placing: Option<(bool, u64, WrittenLayers)>) -> Option { let placed = doc.with_placed_art(); - if !placed.layers.iter().any(|l| needs_bake(l)) { + let placing = placing + .map(|(clip_slices, most_nodes, layers)| (drawn_symbols(&placed), clip_slices, most_nodes, layers)) + .filter(|p| !p.0.is_empty()) + .map(|(symbols, clip_slices, most_nodes, layers)| Placing { + symbols, + clip_slices, + most_nodes, + heights: vectorcraft_doc::depth::DrawnHeights::of(&placed), + layers, + }); + // A bake inside another one (of art an effect makes) places symbols only when it asks to. + let _restore = Restore( + PLACING.with(|p| p.replace(placing.map(Rc::new))), + PLACED_NODES.with(|c| c.replace(0)), + DROPPED_NODES.with(|c| c.replace(0)), + LEVEL.with(|c| c.replace(1)), + UNWRITTEN.with(|c| c.replace(false)), + ); + if !placed.layers.iter().any(|l| needs_bake(l) || holds_placed_symbol(l)) { return match placed { std::borrow::Cow::Owned(d) => Some(d), std::borrow::Cow::Borrowed(_) => None, diff --git a/crates/effects/src/bake/tests.rs b/crates/effects/src/bake/tests.rs index 8181f0e02e..7b353f78f9 100644 --- a/crates/effects/src/bake/tests.rs +++ b/crates/effects/src/bake/tests.rs @@ -1115,3 +1115,345 @@ fn exporting_a_path_with_a_transform_gives_each_copy_its_own_gradient() { // Three squares of their own, not one path spanning all three. assert_eq!(gradient_pieces(d.node(NodeId(50)).unwrap()), vec![10.0; 3]); } + +/// A 10 × 10 square at (10, 40), with id `id`, that a Transform effect moves 100 pt right. +fn moved_square(id: u64) -> Node { + let mut n = Node::path(NodeId(id), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + n.appearance.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + n +} + +fn instance(id: u64, symbol: &str, xf: vectorcraft_geom::Affine) -> Node { + Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf }) +} + +fn add_symbol(d: &mut Document, name: &str, art: Node) { + d.symbols.push(vectorcraft_doc::Symbol { dynamic: false, name: name.into(), art: Arc::new(art) }); +} + +fn bounds(d: &Document, id: u64) -> Rect { + d.node(NodeId(id)).and_then(|n| n.geometric_bounds()).unwrap_or_else(|| panic!("node {id} has bounds")) +} + +fn near_rect(a: Rect, b: Rect) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() < 1e-6) +} + +/// Each instance draws its symbol's art with the art's effects applied where the instance places +/// it: an instance that doubles the art's size moves it the effect's 100 pt, as the canvas draws +/// it, not 200 pt. Plain baking leaves the instances and the symbols' art as they are. +#[test] +fn placing_symbols_bakes_their_art_where_each_instance_places_it() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)) * Affine::scale(2.0))).unwrap(); + add_symbol(&mut d, "S", moved_square(800)); + assert!(bake_document(&d).is_none(), "nothing in the layers needs baking"); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(matches!(out.node(NodeId(900)).unwrap().kind, NodeKind::Group { .. })); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 40.0, 120.0, 50.0)), "{:?}", bounds(&out, 900)); + assert!(near_rect(bounds(&out, 901), Rect::new(120.0, 180.0, 140.0, 200.0)), "{:?}", bounds(&out, 901)); + assert_eq!(out.symbols[0].art.appearance.effects.len(), 1, "the symbol's art keeps its effect"); +} + +/// An instance of a symbol whose art holds an instance of another symbol with effects draws that +/// art with its effects too. +#[test] +fn placing_symbols_reaches_the_art_of_nested_instances() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", moved_square(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::translate((0.0, 100.0))))])); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(near_rect(bounds(&out, 900), Rect::new(110.0, 140.0, 120.0, 150.0)), "{:?}", bounds(&out, 900)); +} + +/// A symbol whose art holds an instance of itself is placed until the depth limit, and the export +/// ends. +#[test] +fn placing_a_symbol_that_holds_itself_stops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Loop", Affine::IDENTITY)); + add_symbol( + &mut d, + "Loop", + Node::group(NodeId(801), vec![Arc::new(moved_square(800)), Arc::new(instance(802, "Loop", Affine::translate((0.0, 1.0))))]), + ); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut lefts = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| lefts.extend(n.path_data().and_then(|p| p.bounds()).map(|b| b.x0))); + assert!(lefts.len() > 1 && lefts.iter().all(|x| (x - 110.0).abs() < 1e-6), "{lefts:?}"); + let mut depth = 0; + let mut n = out.node(NodeId(900)).unwrap(); + while let Some(next) = n.children().and_then(|ch| ch.last()) { + n = next; + depth += 1; + } + assert!(depth <= 3 * (vectorcraft_doc::depth::MAX_DEPTH + 1), "{depth}"); +} + +/// A dynamic symbol instance's own paint for an object of the art applies before the object's +/// effects are baked: a fill whose own Transform effect makes the square a group of pieces still +/// paints in the instance's red, and its sibling keeps the symbol's paint. +#[test] +fn placing_symbols_keeps_dynamic_instances_changes() { + use vectorcraft_geom::Affine; + let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0)); + let mut square = Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::default_art()); + if let Some(AppearanceItem::Fill(f)) = square.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) { + f.effects.push(fx("distort.transform", json!({"moveH": 100.0}))); + } + let sibling = Node::path(NodeId(801), shapes::rectangle(Rect::new(10.0, 60.0, 20.0, 70.0)), Appearance::default_art()); + let mut inst = instance(900, "D", Affine::IDENTITY); + inst.extras_mut().symbol_overrides = vec![vectorcraft_doc::SymbolOverride { path: vec![0], fill: Some(red.clone()), ..Default::default() }]; + let mut d = doc_with(inst); + add_symbol(&mut d, "D", Node::group(NodeId(802), vec![Arc::new(square), Arc::new(sibling)])); + d.symbols[0].dynamic = true; + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() + && let Some(b) = n.geometric_bounds() + { + fills.push((b.x0.round(), b.y0.round(), n.appearance.fill_paint())); + } + }); + assert!(fills.contains(&(110.0, 40.0, red)), "{fills:?}"); + assert!(fills.contains(&(10.0, 60.0, Paint::solid(Color::WHITE))), "{fills:?}"); +} + +/// An instance whose fill stains its symbol's art places the art in its stained colors, with the +/// art's effects applied after the stain: a Transform moves the stained square and an Invert +/// inverts the stained color. +#[test] +fn placing_symbols_keeps_the_instances_stain() { + use vectorcraft_geom::Affine; + let blue = Color::rgb8(30, 90, 220); + let mut inst = instance(900, "Mark", Affine::IDENTITY); + inst.appearance = Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0).into(); + inst.appearance.items.retain(|i| i.is_fill()); + if let AppearanceItem::Fill(f) = &mut inst.appearance.items[0] { + f.opacity = 0.5; + } + let square = + Node::path(NodeId(800), shapes::rectangle(Rect::new(10.0, 40.0, 20.0, 50.0)), Appearance::basic(Paint::solid(blue), Paint::None, 0.0)); + let stained = vectorcraft_doc::symbol_overrides::instance_art(&square, &inst).appearance.fill_paint().color().unwrap(); + assert_ne!(stained.to_hex(), blue.to_hex()); + let [r, g, b] = stained.to_rgb(); + let inverted = Color::rgb(1.0 - r, 1.0 - g, 1.0 - b); + for (id, params, want) in [("distort.transform", json!({"moveH": 100.0}), stained), (crate::addons::color::INVERT, json!({}), inverted)] { + let mut art = square.clone(); + art.appearance.effects.push(fx(id, params)); + let mut d = doc_with(inst.clone()); + add_symbol(&mut d, "Mark", art); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + assert!(!is_instance(&out, 900), "{id}: the instance is placed"); + let mut fills = vec![]; + out.node(NodeId(900)).unwrap().walk(&mut |n| { + if n.path_data().is_some() { + fills.extend(n.appearance.fill_paint().color().map(|c| c.to_hex())); + } + }); + assert_eq!(fills, [want.to_hex()], "{id}"); + } +} + +/// Every layer written, as DXF writes them (template layers are never written). +const ALL: WrittenLayers = WrittenLayers { hidden: true, non_printing: true }; + +fn is_instance(d: &Document, id: u64) -> bool { + d.node(NodeId(id)).is_some_and(|n| matches!(n.kind, NodeKind::SymbolInstance { .. })) +} + +/// The art placed for an export is counted as baked: the copies an effect makes count, so an +/// instance whose baked art passes what is left of the budget stays an instance, and the budget +/// holds the baked art of the instances placed, exactly. +#[test] +fn placing_symbols_counts_the_baked_art() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(901, "S", Affine::translate((0.0, 100.0)))).unwrap(); + add_symbol(&mut d, "S", gradient_copies(800)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let each = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + assert!(each > 2, "the instance's group, and the copies as objects of their own: {each}"); + assert_eq!(all.node(NodeId(901)).map(|n| n.count_with_masks() as u64), Some(each)); + let fits = bake(&d, Some((true, 2 * each, ALL))).unwrap(); + assert!(!is_instance(&fits, 900) && !is_instance(&fits, 901)); + let short = bake(&d, Some((true, 2 * each - 1, ALL))).unwrap(); + assert_eq!(usize::from(is_instance(&short, 900)) + usize::from(is_instance(&short, 901)), 1, "one instance stays as it is"); +} + +/// The baked art of an instance placed inside another's art is counted once: a budget of exactly +/// the outer instance's baked art places both. +#[test] +fn placing_symbols_counts_nested_art_once() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", gradient_copies(800)); + add_symbol(&mut d, "Outer", Node::group(NodeId(801), vec![Arc::new(instance(802, "Inner", Affine::IDENTITY))])); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let total = all.node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let exact = bake(&d, Some((true, total, ALL))).unwrap(); + assert!(!is_instance(&exact, 900) && !is_instance(&exact, 802), "both are placed within {total}"); + let short = bake(&d, Some((true, total - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the outer instance stays as it is"); +} + +/// While an instance's art bakes, the art's own objects are held in the budget: an instance +/// nested in it whose art does not fit in what is left stays as it is, and the outer instance is +/// placed around it. +#[test] +fn placing_symbols_holds_the_outer_art_while_it_bakes() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "Outer", Affine::IDENTITY)); + add_symbol(&mut d, "Inner", Node::group(NodeId(803), vec![Arc::new(gradient_copies(800))])); + let mut art: Vec> = (0..5) + .map(|i| Arc::new(Node::path(NodeId(810 + i), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()))) + .collect(); + art.push(Arc::new(instance(802, "Inner", Affine::IDENTITY))); + let outer = Node::group(NodeId(801), art); + // The outer instance's group around its art, with the inner instance left as it is. + let around = 1 + outer.count_with_masks() as u64; + add_symbol(&mut d, "Outer", outer); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let inner = all.node(NodeId(802)).map_or(0, |n| n.count_with_masks() as u64); + assert!(inner > 2 && inner <= around, "the inner art alone fits: {inner} <= {around}"); + let out = bake(&d, Some((true, around, ALL))).unwrap(); + assert!(!is_instance(&out, 900) && is_instance(&out, 802), "the outer instance is placed around the inner one"); +} + +/// Art baked for an instance and then left out, because it passes the budget, still counts: an +/// instance after it whose art would fit in the budget alone stays as it is, and a budget with +/// room for both places both. +#[test] +fn placing_symbols_counts_the_art_it_drops() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(901, "Small", Affine::IDENTITY)); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, instance(900, "Big", Affine::IDENTITY)).unwrap(); + add_symbol(&mut d, "Big", gradient_copies(800)); + add_symbol(&mut d, "Small", moved_square(810)); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + let count = |id: u64| all.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (big, small) = (count(900), count(901)); + assert!(small < big, "{small} < {big}"); + let short = bake(&d, Some((true, big - 1, ALL))).unwrap(); + assert!(is_instance(&short, 900), "the big art passes {}", big - 1); + assert!(is_instance(&short, 901), "the big art's {big} objects count, so the small art does not fit"); + let both = bake(&d, Some((true, big + small, ALL))).unwrap(); + assert!(!is_instance(&both, 900) && !is_instance(&both, 901)); +} + +/// Instances the export does not write are not placed and do not count against the budget: with +/// room for one instance's baked art, hidden instances, an instance in a hidden group, one on a +/// template layer, one on a layer that does not print and one on a hidden layer (when the export +/// leaves those layers out) come first and stay as they are, and the visible instance after them +/// is placed. An export that writes those layers places their instances first. +#[test] +fn placing_symbols_skips_instances_the_export_leaves_out() { + use vectorcraft_geom::Affine; + let mut d = doc_with(instance(900, "S", Affine::IDENTITY)); + add_symbol(&mut d, "S", gradient_copies(800)); + let each = bake(&d, Some((true, u64::MAX, ALL))).unwrap().node(NodeId(900)).map_or(0, |n| n.count_with_masks() as u64); + let layer = d.layers[0].id; + let mut hidden = instance(901, "S", Affine::IDENTITY); + hidden.visible = false; + let mut group = Node::group(NodeId(902), vec![Arc::new(instance(903, "S", Affine::IDENTITY))]); + group.visible = false; + for (i, n) in [hidden, group].into_iter().enumerate() { + d.insert(Some(layer), i, n).unwrap(); + } + let mut left_out = vec![]; + for (id, template, printable, visible) in [(910, true, true, true), (920, false, false, true), (930, false, true, false)] { + let mut l = Node::layer(NodeId(id), &format!("L{id}"), vectorcraft_doc::LayerColor::Preset(0)); + if let NodeKind::Layer { template: t, printable: p, .. } = &mut l.kind { + *t = template; + *p = printable; + } + l.visible = visible; + if let Some(ch) = l.children_mut() { + ch.push(Arc::new(instance(id + 1, "S", Affine::IDENTITY))); + } + left_out.push(Arc::new(l)); + } + d.layers.splice(0..0, left_out); + let out = bake(&d, Some((true, each, WrittenLayers::default()))).unwrap(); + assert!(!is_instance(&out, 900), "the visible instance is placed within {each}"); + for id in [901, 903, 911, 921, 931] { + assert!(is_instance(&out, id), "{id} stays as it is"); + } + // An export that writes the layers that do not print, or the hidden layers, places that + // layer's instance first; the template layer's instance is never placed. + for (layers, first) in [(WrittenLayers { non_printing: true, hidden: false }, 921), (WrittenLayers { non_printing: false, hidden: true }, 931)] { + let out = bake(&d, Some((true, each, layers))).unwrap(); + assert!(!is_instance(&out, first) && is_instance(&out, 900) && is_instance(&out, 911), "{layers:?}"); + } +} + +/// `leaf` in `levels` groups, with ids from `id` up. +fn in_groups(id: u64, levels: u64, leaf: Node) -> Node { + (0..levels).fold(leaf, |n, i| Node::group(NodeId(id + i), vec![Arc::new(n)])) +} + +/// Placed art is drawn no deeper than the document may draw: an instance whose art's effects add +/// a level past it stays an instance, and the instances around it are placed. +#[test] +fn placing_symbols_keeps_the_baked_art_within_the_drawn_depth() { + use vectorcraft_doc::depth::{DrawnHeights, MAX_DRAWN_DEPTH}; + use vectorcraft_geom::Affine; + // The instance on the layer is 2 deep; A to D each hold the next 31 objects down, and E's + // square is 2 deep in its art: 2 + 4 × 31 + 2 = MAX_DRAWN_DEPTH. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!d.too_deep(None)); + assert_eq!(DrawnHeights::of(&d).deepest(&d.layers[0], 1), MAX_DRAWN_DEPTH); + let out = bake_document_placing_symbols(&d, true, ALL).unwrap(); + // E's square becomes a group of copies, a level past the cap: E's instance stays. + assert!(is_instance(&out, 4100), "E's instance stays as it is"); + assert!(!is_instance(&out, 900) && !is_instance(&out, 3100), "A to D are placed"); + assert!(DrawnHeights::of(&out).deepest(&out.layers[0], 1) <= MAX_DRAWN_DEPTH); + // Without the chain above it, E's art is placed. + let mut shallow = doc_with(instance(900, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + assert!(!is_instance(&bake_document_placing_symbols(&shallow, true, ALL).unwrap(), 900)); +} + +/// Art baked for an instance and then left out because it draws too deep still counts when the +/// baked art of a later instance is checked: that art fits before its effects add copies, but +/// not once they do. +#[test] +fn placing_symbols_counts_art_dropped_for_depth_against_later_baked_art() { + use vectorcraft_geom::Affine; + // The chain of placing_symbols_keeps_the_baked_art_within_the_drawn_depth, then F, whose + // square becomes a group of copies once baked. + let mut d = doc_with(instance(900, "A", Affine::IDENTITY)); + for (i, (name, next)) in [("A", "B"), ("B", "C"), ("C", "D"), ("D", "E")].into_iter().enumerate() { + let base = 1000 * (i as u64 + 1); + add_symbol(&mut d, name, in_groups(base, 30, instance(base + 100, next, Affine::IDENTITY))); + } + add_symbol(&mut d, "E", in_groups(5000, 1, gradient_copies(5100))); + add_symbol(&mut d, "F", gradient_copies(6000)); + let layer = d.layers[0].id; + d.insert(Some(layer), 1, instance(901, "F", Affine::IDENTITY)).unwrap(); + let all = bake(&d, Some((true, u64::MAX, ALL))).unwrap(); + assert!(is_instance(&all, 4100) && !is_instance(&all, 900) && !is_instance(&all, 901)); + let count = |d: &Document, id: u64| d.node(NodeId(id)).map_or(0, |n| n.count_with_masks() as u64); + let (a, f) = (count(&all, 900), count(&all, 901)); + // E's baked art, as placed where nothing above it draws deep. + let mut shallow = doc_with(instance(4100, "E", Affine::IDENTITY)); + add_symbol(&mut shallow, "E", in_groups(5000, 1, gradient_copies(5100))); + let e = count(&bake(&shallow, Some((true, u64::MAX, ALL))).unwrap(), 4100); + assert!(f > 2, "F's copies are objects of their own: {f}"); + let short = bake(&d, Some((true, a + e + f - 1, ALL))).unwrap(); + assert!(!is_instance(&short, 900) && is_instance(&short, 4100), "A to D are placed, E is not"); + assert!(is_instance(&short, 901), "E's dropped {e} objects count, so F's baked {f} do not fit"); + let room = bake(&d, Some((true, a + e + f, ALL))).unwrap(); + assert!(!is_instance(&room, 900) && !is_instance(&room, 901)); +} diff --git a/crates/effects/src/clip.rs b/crates/effects/src/clip.rs index 60cc3f2845..c5e11c648f 100644 --- a/crates/effects/src/clip.rs +++ b/crates/effects/src/clip.rs @@ -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 { - 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 { +/// [`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 { + 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 { let mut m: Option = None; let members: &[std::sync::Arc] = 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 { // 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); diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 20b4f982a3..1b64971a3a 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -904,7 +904,10 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { /// 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); } diff --git a/crates/effects/src/group/tests.rs b/crates/effects/src/group/tests.rs index 877528d0bb..519323e5f0 100644 --- a/crates/effects/src/group/tests.rs +++ b/crates/effects/src/group/tests.rs @@ -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 = 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 = 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. diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index c18a31d842..e7e74972e6 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -68,7 +68,10 @@ pub use adjust::{ ADJUSTMENTS, CHANNELS, ColorMap, ImageHook, adjust, adjust_in_document, color_map, curve_at, curve_points, has_adjustment, is_adjustment, is_channel, }; -pub use bake::{StrokeArt, bake_appearance, bake_document, bake_type_appearance, expand_art, expand_leaf, fresh_ids, needs_bake}; +pub use bake::{ + StrokeArt, WrittenLayers, bake_appearance, bake_document, bake_document_placing_symbols, bake_type_appearance, expand_art, expand_leaf, + fresh_ids, needs_bake, +}; pub use clip::{WarpSeams, clip_outline, knockout_silhouette, warp_pieces_translucent}; pub use group::{ OutlineHook, PATHFINDER_EFFECTS, compound_shape_area, compound_shape_art, evaluate_container, has_container_appearance, has_pathfinder, @@ -81,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, diff --git a/crates/effects/src/lighting.rs b/crates/effects/src/lighting.rs index 8592776a95..257576822a 100644 --- a/crates/effects/src/lighting.rs +++ b/crates/effects/src/lighting.rs @@ -136,8 +136,86 @@ pub(crate) fn facet_art( composed_art(id, path, paint, Ramp::Rgb(light), seam) } -/// Composite smooth solid-fill facets with one clip and three channel groups per surface. -/// Black channel backdrops keep shared edges opaque before the final coverage is applied. +/// The width of the seam stroke of a facet's underlay: a round stroke that reaches 1 pt past each +/// edge, over the pixels that the antialiased edges of two neighboring facets each cover only in +/// part at 1:1. A clip to the surface's outline removes what it paints past the surface. +pub(crate) const UNDERLAY_SEAM: f64 = 2.0; + +/// The area facet `path` and its round seam stroke `seam` wide paint together: the stroke's outer +/// contours and the facet, all wound one way with a positive area. The stroke's inner contours, +/// wound the other way, are left out: the facet covers them, and contours that cancel each other +/// leave partial coverage where a union of footprints meets at a pixel. +pub(crate) fn footprint(path: &vectorcraft_geom::BezPath, seam: f64) -> vectorcraft_geom::BezPath { + use kurbo::{PathEl, Shape}; + let stroke = kurbo::stroke(path.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); + let sign = if stroke.area() < 0.0 { -1.0 } else { 1.0 }; + let mut footprint = vectorcraft_geom::BezPath::new(); + let mut contour = vectorcraft_geom::BezPath::new(); + for el in stroke.elements().iter().chain(std::iter::once(&PathEl::MoveTo(kurbo::Point::ZERO))) { + if matches!(el, PathEl::MoveTo(_)) && !contour.elements().is_empty() { + if contour.area() * sign >= 0.0 { + footprint.extend(contour.iter()); + } + contour = vectorcraft_geom::BezPath::new(); + } + contour.push(*el); + } + let mut boundary = path.clone(); + if boundary.area() * sign < 0.0 { + boundary = boundary.reverse_subpaths(); + } + footprint.extend(boundary.iter()); + if sign < 0.0 { footprint.reverse_subpaths() } else { footprint } +} + +/// Colored-light facets `faces` (in drawing order) drawn over an underlay of themselves, the same +/// facets grown to seams [`UNDERLAY_SEAM`] wide, all clipped once to the union of the facets' +/// footprints. Where two facets' antialiased edges meet, the underlay fills what they leave +/// uncovered with the neighbors' own colors, so the background does not show through. `None` unless +/// every face is a facet a surface draws: a path filled and stroked by its seam, or a facet composed +/// in a clip of its footprint, neither with an opacity mask. +pub(crate) fn sealed(id: vectorcraft_doc::NodeId, faces: &[std::sync::Arc]) -> Option { + use std::sync::Arc; + use vectorcraft_doc::{Appearance, Node, NodeKind}; + let mut outline = vectorcraft_geom::BezPath::new(); + let mut underlay = Vec::with_capacity(faces.len()); + for face in faces { + if face.extras().mask.is_some() { + return None; + } + match &face.kind { + NodeKind::Path { clipping: false, .. } => { + let path = face.path_data()?.to_bezpath(); + let mut seam = None; + let mut grown = (**face).clone(); + for item in &mut grown.appearance.items { + if let AppearanceItem::Stroke(s) = item { + seam = Some(seam.map_or(s.width, |w: f64| w.max(s.width))); + s.width = UNDERLAY_SEAM; + } + } + outline.extend(footprint(&path, seam?).iter()); + underlay.push(Arc::new(grown)); + } + NodeKind::Group { children, clip: true } if children.len() == 2 => { + let clip = children.first()?.path_data()?.to_bezpath(); + let wider = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&clip, UNDERLAY_SEAM)), Appearance::default()); + let colors = children.get(1)?.clone(); + outline.extend(clip.iter()); + underlay.push(Arc::new(Node::new(id, NodeKind::Group { children: vec![Arc::new(wider), colors], clip: true }))); + } + _ => return None, + } + } + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&outline), Appearance::default()); + underlay.extend(faces.iter().cloned()); + Some(Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(Node::group(id, underlay))], clip: true })) +} + +/// Composite smooth solid-fill facets with one clip and three channel groups per surface. In each +/// channel, an underlay of the facets with wide seams lies under the facets themselves, so a +/// shared edge is opaque in that channel's own light before the channels are screened together; +/// the black backdrop shows only outside the surface, which the clip removes. pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node { use kurbo::Shape; use std::sync::Arc; @@ -167,20 +245,36 @@ pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_t if let Some(value) = color.get_mut(channel) { *value = 1.0; } + let paints: Vec = facets + .iter() + .map(|face| { + if let Some(ramp) = face.rgb_gradient { + smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) + } else { + Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) + } + }) + .collect(); + let draw = |seam: fn(&vectorcraft_three_d::Facet) -> f64| -> Vec> { + facets + .iter() + .zip(&paints) + .map(|(face, paint)| { + let edge = seam_stroke(paint.clone(), seam(face)); + Arc::new(Node::path( + id, + face.path(), + Appearance { + items: vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(edge)], + ..Default::default() + }, + )) + }) + .collect() + }; let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))]; - children.extend(facets.iter().map(|face| { - let paint = if let Some(ramp) = face.rgb_gradient { - smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE) - } else { - Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel]))) - }; - let edge = seam_stroke(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75)); - Arc::new(Node::path( - id, - face.path(), - Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() }, - )) - })); + children.extend(draw(|_| UNDERLAY_SEAM)); + children.extend(draw(|face| (face.thickness() / 2.0).clamp(0.05, 0.75))); let mut group = Node::group(id, children); group.isolate = true; group.blend = BlendMode::Screen; @@ -228,24 +322,25 @@ pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option) { } fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node { - use kurbo::Shape; use std::sync::Arc; use vectorcraft_doc::color::BlendMode; use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask}; use vectorcraft_geom::{Rect, shapes}; - let (base, mask) = opaque_paint(paint); - // Clip once to the union of this single-contour face and its round seam stroke. - // Align their winding so the nonzero fill adds coverage instead of cancelling it. - let mut boundary = path.to_bezpath(); - let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05); - if boundary.area() * footprint.area() < 0.0 { - boundary = boundary.reverse_subpaths(); + // An unplaced gradient (and a freeform gradient without points) stays fitted to the face's + // bounds grown by 1 pt, not to the larger cover below. + let mut paint = paint.clone(); + if let Paint::Gradient(g) = &mut paint { + g.pin(path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0)); } - footprint.extend(boundary.iter()); - let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default()); + let (base, mask) = opaque_paint(&paint); + // Clip once to the union of this single-contour face and its round seam stroke. + let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint(&path.to_bezpath(), seam)), Appearance::default()); // Opaque rectangles cover the complete seam stroke, without separately antialiasing - // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. - let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0); + // either ramp. Opaque sources need only vector clipping, not full-frame opacity masks. They + // reach at least half a point past the seam stroke grown for an underlay ([`sealed`]), so + // their own edges stay outside either clip. + let margin = seam.max(1.0) + UNDERLAY_SEAM / 2.0; + let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(margin, margin); let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0)); let mut illumination = match light { Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)), @@ -380,4 +475,30 @@ mod tests { assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]); assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch"); } + + /// Under colored light, a face's unplaced source gradient is fitted to the face's bounds grown by + /// 1 pt, whatever the size of the cover rectangle that draws it; a placed one keeps its placement. + #[test] + fn composed_faces_fit_unplaced_gradients_to_the_face_not_the_cover() { + use vectorcraft_doc::NodeKind; + use vectorcraft_geom::{Rect, shapes}; + use vectorcraft_three_d::{RgbGradient, ShadeGradient}; + let face = shapes::rectangle(Rect::new(10.0, 20.0, 50.0, 40.0)); + let ramp = |b| ShadeGradient { start: Point::new(10.0, 20.0), end: Point::new(50.0, 20.0), brightness: [b, 1.0] }; + let light = RgbGradient { channels: [ramp(0.2), ramp(0.5), ramp(0.8)] }; + let stops = vec![GradientStop::new(0.0, Color::WHITE), GradientStop::new(1.0, Color::BLACK)]; + let unplaced = GradientPaint { gradient: Gradient::new(GradientKind::Linear, stops), geom: None, angle: 0.0, swatch: None, freeform: None }; + let placed = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(80.0, 0.0), aspect: 1.0, focal: None }; + let fitted = unplaced.resolve(Rect::new(9.0, 19.0, 51.0, 41.0)); + for (geom, expected) in [(None, fitted), (Some(placed), placed)] { + let paint = Paint::Gradient(Box::new(GradientPaint { geom, ..unplaced.clone() })); + for seam in [0.05, 0.75] { + let art = facet_art(vectorcraft_doc::NodeId(1), &face, &paint, light, seam); + let NodeKind::Group { children, clip: true } = &art.kind else { panic!("a clipped composite") }; + let base = &children[1].children().unwrap()[0]; + let Paint::Gradient(g) = base.appearance.items[0].paint() else { panic!("the source gradient") }; + assert_eq!(g.geom, Some(expected), "seam {seam}"); + } + } + } } diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index ee7d3f8322..5e86dafe96 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -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 { + 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 { + 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) => { @@ -868,7 +904,7 @@ fn as_art_opts( /// `art`, the art made of `n`, with `n`'s name, visibility, transparency, opacity mask and /// remaining (raster) effects. -fn dressed(mut art: Node, n: &Node) -> Node { +pub(crate) fn dressed(mut art: Node, n: &Node) -> Node { art.name = n.name.clone(); art.visible = n.visible; art.opacity = n.opacity; @@ -882,6 +918,89 @@ 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 { 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 }); diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index 92057f1c7f..e9a8f2a0a7 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -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, 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 { let faces = |path: &PathData, b: Rect, fx: &Effect, options: Revolve| -> Result, 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, n: &Node) -> Result, n: &Node) -> Result &Paint { } } -/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored -/// lights give each facet derived paints (`lighting::solid_surface`, or `lighting::facet_art` for -/// each face). +/// Are `facets` drawn over an underlay of themselves ([`crate::lighting::sealed`], +/// [`crate::lighting::solid_surface`])? Under colored lights, when any facet takes a smooth light +/// ramp. +fn underlaid(facets: &[Facet], colored: bool) -> bool { + colored && facets.iter().any(|f| f.rgb_gradient.is_some()) +} + +/// The most paint copies `surface` makes for one facet ([`facet_copies`]). +const MAX_FACET_COPIES: usize = 12; + +/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), twice that when +/// the facets lie over an underlay of themselves, and [`MAX_FACET_COPIES`] when colored lights also +/// give each facet derived paints in three channels (`lighting::solid_surface`). fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize { - facets.len().saturating_mul(if composites_facets(facets, paint, colored) { 6 } else { 2 }) + let per_facet = match (underlaid(facets, colored), paint.color().is_some()) { + (true, true) => MAX_FACET_COPIES, + (true, false) => 4, + (false, _) => 2, + }; + facets.len().saturating_mul(per_facet) } /// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the @@ -832,14 +847,14 @@ fn rects_overlap(a: Rect, b: Rect) -> bool { /// How many faces [`cut_stroke_faces`] may add to solids painted by `items` that hold `faces` faces /// and whose paint copies take `paint_bytes` so far: as many as keep the faces within -/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as six copies -/// of the costliest stroke's paint. +/// [`MAX_OBJECT_FACES`] and the copies within the allowance, each added face counted as +/// [`MAX_FACET_COPIES`] copies of the costliest stroke's paint. fn cut_room<'a>(items: impl Iterator, faces: usize, paint_bytes: usize) -> usize { let per_face = items .filter(|item| matches!(item, AppearanceItem::Stroke(_))) .map(|item| { let mut copies = 0usize; - crate::revolve::paint_copies_add(&mut copies, 6, paint_ref(item)); + crate::revolve::paint_copies_add(&mut copies, MAX_FACET_COPIES, paint_ref(item)); copies }) .max() @@ -1354,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>, 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 @@ -1693,6 +1714,10 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile let split = split && solids.iter().filter(|(item, _, _, f, _)| composite(item, f)).map(|(.., f, _)| f.len()).sum::() <= MAX_FACE_COMPOSITES; let members: Vec = solids.iter().map(|s| s.4).collect(); + // Which surfaces lie over an underlay of their facets, in one order or whole + // ([`in_one_order`], [`underlaid_surface`]). + let seals: Vec = solids.iter().map(|(.., f, _)| underlaid(f, colored)).collect(); + let seal = |range: std::ops::Range| seals.get(range).is_some_and(|s| s.iter().any(|s| *s)); let surfaces: Vec = solids .into_iter() .map(|(item, member, scopes, f, shape)| { @@ -1728,19 +1753,37 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile run.push(s); } let flat = run.iter().all(|s| plain(s) && s.1.opacity >= 1.0 && s.1.blend == BlendMode::Normal && s.1.extras().mask.is_none()); + let range = first..first + run.len(); if ordered(member) && flat { let scopes = run.first().map_or(&[][..], |s| s.2); - out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed))))); + let seals = seals.get(range).unwrap_or_default(); + out.push((scopes, Arc::new(Node::group(n.id, in_one_order(n.id, run, first, &arts, sealed, seals))))); } else { - out.extend(with_graphics(run.into_iter().map(|(_, s, scopes, _)| (scopes, Arc::new(s))).collect(), first, &arts)); + let run = run.into_iter().zip(range).map(|((_, s, scopes, _), k)| (scopes, Arc::new(underlaid_surface(n.id, s, seal(k..k + 1))))); + out.extend(with_graphics(run.collect(), first, &arts)); } } return Some(masked(n.id, out)); } if !opaque || !surfaces.iter().all(plain) { + let surfaces = + surfaces.into_iter().enumerate().map(|(k, (d, s, scopes, e))| (d, underlaid_surface(n.id, s, seal(k..k + 1)), scopes, e)).collect(); return Some(whole_surfaces(surfaces, &arts).into_iter().map(|(_, s)| s).collect()); } - Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed)))]) + Some(vec![Arc::new(Node::group(n.id, in_one_order(n.id, surfaces, 0, &arts, sealed, &seals)))]) +} + +/// A plain `surface` ([`surface`]: a group of its facets) over an underlay of its facets when +/// `seal`, keeping the surface's opacity, blend mode and opacity mask. +fn underlaid_surface(id: NodeId, surface: Node, seal: bool) -> Node { + let NodeKind::Group { children, clip: false } = &surface.kind else { return surface }; + let Some(mut out) = seal.then(|| crate::lighting::sealed(id, children)).flatten() else { return surface }; + out.opacity = surface.opacity; + out.blend = surface.blend; + if let Some(mask) = surface.extras().mask.clone() { + out.extras_mut().mask = Some(mask); + } + out } /// The faces of `surfaces` (plain groups of their faces, [`surfaces_for`]'s from index `first`) in @@ -1748,7 +1791,16 @@ fn surfaces_for(doc: Option<&Document>, n: &Node, fx: &Effect, shapes: &[Profile /// surfaces right after the last face it is drawn on, of the surfaces that share it. A surface in a gradient with transparent stops /// carries an opacity mask: its faces go in runs ([`in_runs`]), each carrying the mask, and each of /// its graphics carries the mask too ([`with_graphics`]), so a graphic is hidden where its cap is. -fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: &[(usize, PlanarFace, Arc)], sealed: bool) -> Vec> { +/// The faces of the surfaces that `seals` marks lie over underlays of themselves +/// ([`underlaid_runs`], [`underlaid_surface`]). +fn in_one_order( + id: NodeId, + surfaces: Vec>, + first: usize, + arts: &[(usize, PlanarFace, Arc)], + sealed: bool, + seals: &[bool], +) -> Vec> { let masks: Vec>> = surfaces.iter().map(|(_, s, _, _)| s.extras().mask.clone()).collect(); let arts: Vec<&(usize, PlanarFace, Arc)> = arts.iter().filter(|(at, ..)| (first..first + surfaces.len()).contains(at)).collect(); let (mut placed, mut faces, mut ends) = (vec![], vec![], vec![]); @@ -1787,7 +1839,46 @@ fn in_one_order(id: NodeId, surfaces: Vec>, first: usize, arts: (masks.len(), art) })); } - if masks.iter().any(Option::is_some) { in_runs(id, drawn, &masks) } else { drawn.into_iter().map(|(_, face)| face).collect() } + let takes_underlay = |surface: usize| seals.get(surface) == Some(&true); + if masks.iter().any(Option::is_some) { + // The faces of a surface without a mask are sealed run by run before the masked surfaces' + // faces go in runs of their own, each holding one surface's faces, sealed with its mask. + let unmasked = underlaid_runs(id, drawn, |s| takes_underlay(s) && masks.get(s).is_some_and(Option::is_none)); + return in_runs(id, unmasked, &masks) + .into_iter() + .map(|run| match &run.kind { + NodeKind::Group { clip: false, .. } if seals.iter().any(|s| *s) && run.extras().mask.is_some() => { + Arc::new(underlaid_surface(id, (*run).clone(), true)) + } + _ => run, + }) + .collect(); + } + underlaid_runs(id, drawn, takes_underlay).into_iter().map(|(_, face)| face).collect() +} + +/// `faces` in their drawing order, each with the index of its surface. Each run of consecutive +/// faces of one surface that `seal` takes lies over an underlay of its own faces, clipped to their +/// outline ([`crate::lighting::sealed`]), so no underlay reaches under another surface's faces or a +/// graphic drawn between them. A sealed run keeps its surface's index. +fn underlaid_runs(id: NodeId, faces: Vec<(usize, Arc)>, seal: impl Fn(usize) -> bool) -> Vec<(usize, Arc)> { + let mut out = Vec::with_capacity(faces.len()); + let mut run: Vec> = vec![]; + let mut faces = faces.into_iter().peekable(); + while let Some((surface, face)) = faces.next() { + run.push(face); + if faces.peek().is_some_and(|(next, _)| *next == surface) { + continue; + } + match seal(surface).then(|| crate::lighting::sealed(id, &run)).flatten() { + Some(sealed) => { + out.push((surface, Arc::new(sealed))); + run.clear(); + } + None => out.extend(run.drain(..).map(|face| (surface, face))), + } + } + out } /// `faces` in their back-to-front order, each with the index of its surface, where some surfaces diff --git a/crates/effects/src/solids/tests.rs b/crates/effects/src/solids/tests.rs index 8222484c20..ab163e45cd 100644 --- a/crates/effects/src/solids/tests.rs +++ b/crates/effects/src/solids/tests.rs @@ -535,14 +535,53 @@ fn a_colored_inflate_in_gradients_draws_its_faces_in_one_order() { assert_eq!(drawn(&solid, Some(colored)).0, 1); } +/// The faces `run` stands for: a run of faces over an underlay of themselves ([`crate::lighting::sealed`]: +/// a clip and a group of the underlay's copies followed by the faces) gives its faces, any other +/// node gives itself. +fn unsealed(run: &Arc) -> Vec> { + if let NodeKind::Group { clip: true, children } = &run.kind + && let [clip, inner] = children.as_slice() + && matches!(clip.kind, NodeKind::Path { .. }) + && let NodeKind::Group { clip: false, children: kids } = &inner.kind + && !kids.is_empty() + && kids.len() % 2 == 0 + { + let (under, faces) = kids.split_at(kids.len() / 2); + let underlay = |k: &Arc| match &k.kind { + NodeKind::Group { clip, .. } => *clip, + _ => k.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(s) if s.width == crate::lighting::UNDERLAY_SEAM)), + }; + if under.first().is_some_and(underlay) { + return faces.to_vec(); + } + } + vec![run.clone()] +} + +/// The bounds of every face under `n`, a sealed run's faces one by one ([`unsealed`]). +fn face_bounds_under(n: &Arc, out: &mut Vec) { + let faces = unsealed(n); + if faces.len() != 1 || !Arc::ptr_eq(&faces[0], n) { + out.extend(faces.iter().map(|f| drawn_face_bounds(f))); + return; + } + if let NodeKind::Group { clip: true, .. } = &n.kind { + out.push(drawn_face_bounds(n)); + } + for child in n.children().into_iter().flatten() { + face_bounds_under(child, out); + } +} + /// The bounds of face `f` of 3D art: its path's, or for a clipped colored composite -/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 1 pt. +/// ([`crate::lighting::facet_art`]) its color cover's, which is the face's bounds grown by 2 pt (a +/// point past the seam and half the underlay's seam). fn drawn_face_bounds(f: &Node) -> Rect { match &f.kind { NodeKind::Path { .. } => f.path_data().and_then(PathData::bounds).unwrap(), NodeKind::Group { clip: true, children } => { let cover = &children[1].children().unwrap()[0]; - cover.path_data().and_then(PathData::bounds).unwrap().inflate(-1.0, -1.0) + cover.path_data().and_then(PathData::bounds).unwrap().inflate(-2.0, -2.0) } _ => panic!("a face is a path or a clipped composite"), } @@ -587,7 +626,7 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { let art = solid_art(&with).unwrap(); let surfaces = art.children().unwrap(); assert_eq!(surfaces.len(), 1, "{what}: one order"); - let faces = surfaces[0].children().unwrap(); + let faces: Vec> = surfaces[0].children().unwrap().iter().flat_map(unsealed).collect(); assert_eq!(faces.len(), parts.len(), "{what}: one face per facet"); for (k, (face, (facet, ..))) in faces.iter().zip(&parts).enumerate() { let (a, b) = (drawn_face_bounds(face), facet.path().bounds().unwrap()); @@ -616,11 +655,9 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { g.appearance.effects.push(fx(id, params.clone())); let bounds = |art: &Node| { let mut out = vec![]; - art.walk(&mut |n| { - if let NodeKind::Group { clip: true, .. } = &n.kind { - out.push(drawn_face_bounds(n)); - } - }); + for child in art.children().into_iter().flatten() { + face_bounds_under(child, &mut out); + } out }; let (plain, scoped) = (solid_art(&plain).unwrap(), solid_art(&g).unwrap()); @@ -637,6 +674,89 @@ fn colored_solid_fills_and_strokes_share_face_depth_order() { assert!(whole_misdrawn > 0, "whole surfaces show farther faces"); } +/// Under a colored light, the faces of an Inflate in gradients, in one order, lie over underlays +/// run by run: each run of consecutive faces of one surface (the fill's or the dashed stroke's) +/// over an underlay of its own faces, so no underlay reaches under the other surface's faces. +#[test] +fn a_colored_inflate_in_gradients_lies_over_underlays_of_each_surfaces_runs() { + use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop}; + let gradient = |c: Color| { + Paint::Gradient(Box::new(GradientPaint::new(Gradient::new( + GradientKind::Linear, + vec![GradientStop::new(0.0, c), GradientStop::new(1.0, Color::WHITE)], + )))) + }; + let mut n = stroked(&letter_c()); + for item in &mut n.appearance.items { + match item { + AppearanceItem::Fill(f) => f.paint = gradient(RED), + AppearanceItem::Stroke(s) => { + s.paint = gradient(BLUE); + s.dash = Some(vectorcraft_doc::Dash { pattern: vec![12.0, 12.0], offset: 0.0, align_corners: false }); + } + } + } + let p = json!({"rotationX": -20, "rotationY": -40, "rotationZ": 0, "depth": 60}); + let cut = drawn_parts(&n, INFLATE, &p).len(); + let mut lit = p.clone(); + lit["lights"] = json!([{"color": "#ff8080"}]); + n.appearance.effects.push(fx(INFLATE, lit)); + // Which surface a face belongs to, by the first stop of its paint (a composed face's own paint, + // or a path face's shaded one): red for the fill's, blue for the stroke's. + fn surface_of(face: &Node) -> Option { + let item = match &face.kind { + NodeKind::Path { .. } => face.appearance.items.first()?, + _ => face.children()?.get(1)?.children()?.first()?.appearance.items.first()?, + }; + let Paint::Gradient(g) = item.paint() else { return None }; + let [r, _, b] = g.gradient.stops.first()?.color.to_rgb(); + Some(if r > b { RED } else { BLUE }) + } + // The faces in `order`, and the surface of each run over an underlay, looking into the groups + // that carry a surface's opacity mask. + fn runs(order: &[Arc], faces: &mut usize, surfaces: &mut Vec) { + for child in order { + let NodeKind::Group { children, clip } = &child.kind else { + assert!(surface_of(child).is_some(), "a face"); + *faces += 1; + continue; + }; + if !clip { + assert!(child.extras().mask.is_some(), "a masked run"); + runs(children, faces, surfaces); + continue; + } + // A composed face isolates its colors; a run's faces are not isolated together. + if children[1].isolate { + assert!(surface_of(child).is_some(), "a composed face"); + *faces += 1; + continue; + } + let stack = children[1].children().unwrap(); + let (under, own) = stack.split_at(stack.len() / 2); + assert_eq!(under.len(), own.len(), "one underlay face for each face"); + let colors: Vec<_> = own.iter().map(|f| surface_of(f).unwrap()).collect(); + assert!(colors.iter().all(|c| *c == colors[0]), "a run holds one surface's faces"); + surfaces.push(colors[0]); + *faces += own.len(); + } + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut, "{faces} faces, {cut} under a white light, whose cells are not triangulated"); + assert!(surfaces.contains(&RED) && surfaces.contains(&BLUE), "{surfaces:?}"); + // With transparent stops in the fill's gradient, the fill's faces go in masked runs; the + // stroke's faces, without a mask, still lie over underlays of their own runs. + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.iter_mut().find(|i| matches!(i, AppearanceItem::Fill(_))) + && let Paint::Gradient(g) = &mut f.paint + { + g.gradient.stops.iter_mut().for_each(|s| s.opacity = 0.5); + } + let (mut faces, mut surfaces) = (0, vec![]); + runs(solid_art(&n).unwrap().children().unwrap()[0].children().unwrap(), &mut faces, &mut surfaces); + assert!(faces >= cut && surfaces.contains(&RED) && surfaces.contains(&BLUE), "{faces} faces, {surfaces:?}"); +} + /// A face of a stroke that leaves gaps passing through the plane of its fill's wall where they /// overlap is cut along the line where their depths meet, into the piece in front of the wall and /// the piece behind it; a face on one side of the wall, another shape's stroke, a covering stroke @@ -724,7 +844,7 @@ fn cutting_stays_within_the_face_limit_and_the_paint_allowance() { let items = stroked(&letter_c()).appearance.items.clone(); let stroke = items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))).unwrap(); let mut one = 0usize; - crate::revolve::paint_copies_add(&mut one, 6, paint_ref(stroke)); + crate::revolve::paint_copies_add(&mut one, MAX_FACET_COPIES, paint_ref(stroke)); let max = crate::revolve::MAX_PAINT_COPIES; assert_eq!(cut_room(items.iter(), 10, 0), (MAX_OBJECT_FACES - 10).min(max / one)); assert_eq!(cut_room(items.iter(), MAX_OBJECT_FACES + 5, 0), 0); @@ -1595,12 +1715,14 @@ fn a_colored_solid_face_on_its_own_keeps_the_composites_colors() { assert_eq!(one[0], paint, "the paint"); // The composite: its paint cover, then each channel's black backdrop and the face's ramp. let all_fills = fills(&batch); - let ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + let mut ramps: Vec<&Paint> = all_fills.iter().filter(|p| **p != Paint::solid(Color::BLACK)).skip(1).collect(); + // Each channel's ramp is drawn twice, in the underlay and in the facet itself. + ramps.dedup(); assert_eq!(all_fills.first(), Some(&paint)); assert_eq!(ramps, one.iter().skip(1).collect::>(), "the same light ramps"); // The single face's clip holds its seam: the clip reaches past the facet by half the seam. let seam = (face.thickness() / 2.0).clamp(0.05, 0.75); - let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0).collect(); + let widths: Vec = paints(&batch).into_iter().filter_map(|(_, w)| w).filter(|w| *w > 0.0 && *w != crate::lighting::UNDERLAY_SEAM).collect(); assert!(widths.len() == 3 && widths.iter().all(|w| (w - seam).abs() < 1e-12), "{widths:?}"); let NodeKind::Group { children, .. } = &single.kind else { panic!("a clip group") }; let (clip, facet) = (children[0].path_data().and_then(PathData::bounds).unwrap(), face.path().bounds().unwrap()); diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs index 1e19a21c15..410d51443f 100644 --- a/crates/effects/src/solids/wireframe.rs +++ b/crates/effects/src/solids/wireframe.rs @@ -53,8 +53,8 @@ pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result = 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 = 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, n: &Node, faces: Vec, revolve: bool) -> Vec { +/// ([`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, shadow: Vec>, revolve: bool) -> Vec { 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, later: &[Effect], ctx: &GeomContext) -> Vec { + 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, shadow: Vec>, later: &[Effect], ctx: &GeomContext) -> Vec { 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, 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) { + 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, Vec>); + /// 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, 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 { 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, 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, 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 diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs index 3d92d3d277..735be60d6b 100644 --- a/crates/effects/src/solids/wireframe/tests.rs +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -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 = 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] diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs index a610ae7c71..1ebcab785a 100644 --- a/crates/effects/src/tests/rasterfollow.rs +++ b/crates/effects/src/tests/rasterfollow.rs @@ -584,8 +584,9 @@ fn image(id: u64, effects: Vec) -> 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 { + 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); +} diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 19695fd4ff..a2d0316913 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -892,6 +892,10 @@ pub use buildcmds::live_paint_gaps; /// Control bar enable it when it changes one). pub use brushsym::reset_transformation_changes; +/// The byte order of UTF-16 text without a byte-order mark, by Import Data Sets' rule (the Graph +/// Data window reads files by it too). +pub use variablecmds::utf16_order_without_mark; + /// Command ids kept only so older scripts keep working: `(alias, the command it duplicates)`. /// They run as before but the command palette leaves them out (it lists the command once). pub const ALIASES: &[(&str, &str)] = &[("object.convertDocumentColorMode", "file.documentColorMode")]; diff --git a/crates/engine/src/cmd/addons/arrange.rs b/crates/engine/src/cmd/addons/arrange.rs index 3dd2e0e43b..b6b0669f12 100644 --- a/crates/engine/src/cmd/addons/arrange.rs +++ b/crates/engine/src/cmd/addons/arrange.rs @@ -518,16 +518,18 @@ fn sort(s: &mut Session, p: &Value) -> Result { } let wanted: Vec<(&Sorted, f64)> = rank.iter().filter_map(|i| items.get(*i)).zip(values).collect(); if apply == "opacity" { - if wanted.iter().all(|(o, v)| (o.opacity - v).abs() < 1e-6) { + // The opacity each object is given, as stored; an object whose stored opacity it already is has not changed. + let given: Vec<(NodeId, f32)> = wanted.iter().map(|(o, v)| (o.id, (v / 100.0) as f32)).collect(); + let changed: Vec = given.iter().filter(|(id, a)| d.node(*id).is_some_and(|n| n.opacity != *a)).map(|(id, _)| *id).collect(); + if changed.is_empty() { return Ok(count); } s.edit("Sort Objects", |d, _| { - for (o, v) in &wanted { - if let Some(n) = d.node_mut(o.id) { - n.opacity = (v / 100.0) as f32; + for (id, a) in &given { + if let Some(n) = d.node_mut(*id) { + n.opacity = *a; } } - let changed: Vec = wanted.iter().filter(|(o, v)| (o.opacity - v).abs() >= 1e-6).map(|(o, _)| o.id).collect(); keep_series_opacity(d, &changed); Ok(()) })?; diff --git a/crates/engine/src/cmd/addons/cleanup/text.rs b/crates/engine/src/cmd/addons/cleanup/text.rs index 00d75e9888..a13110cf67 100644 --- a/crates/engine/src/cmd/addons/cleanup/text.rs +++ b/crates/engine/src/cmd/addons/cleanup/text.rs @@ -871,13 +871,17 @@ fn remove_breaks(s: &mut Session, p: &Value) -> Result { let named: std::collections::HashSet = 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 { // 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))); } } diff --git a/crates/engine/src/cmd/addons/cleanup/text/tests.rs b/crates/engine/src/cmd/addons/cleanup/text/tests.rs index c9ed79122c..9671bb3306 100644 --- a/crates/engine/src/cmd/addons/cleanup/text/tests.rs +++ b/crates/engine/src/cmd/addons/cleanup/text/tests.rs @@ -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 = 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::() 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, _| { diff --git a/crates/engine/src/cmd/artboardalign.rs b/crates/engine/src/cmd/artboardalign.rs index 541ec94a4e..a3e585a8a0 100644 --- a/crates/engine/src/cmd/artboardalign.rs +++ b/crates/engine/src/cmd/artboardalign.rs @@ -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 { @@ -63,13 +63,7 @@ fn boards(s: &mut Session, p: &Value, cmd: &str) -> Result { 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> { - 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 { (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()) { diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 06ec06e348..038a4202a6 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/buildcmds/live_paint.rs b/crates/engine/src/cmd/buildcmds/live_paint.rs index 9c3c750996..1554ef7328 100644 --- a/crates/engine/src/cmd/buildcmds/live_paint.rs +++ b/crates/engine/src/cmd/buildcmds/live_paint.rs @@ -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 { 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 { + 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 { 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 { .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 = 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(); diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 4e4da57f9c..8bf6841e17 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -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() { diff --git a/crates/engine/src/cmd/compositefonts/tests.rs b/crates/engine/src/cmd/compositefonts/tests.rs index cac6f4bdcb..da310a89b9 100644 --- a/crates/engine/src/cmd/compositefonts/tests.rs +++ b/crates/engine/src/cmd/compositefonts/tests.rs @@ -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); diff --git a/crates/engine/src/cmd/draw2/commands.rs b/crates/engine/src/cmd/draw2/commands.rs index 53949fdaf0..ffa11e362c 100644 --- a/crates/engine/src/cmd/draw2/commands.rs +++ b/crates/engine/src/cmd/draw2/commands.rs @@ -11,7 +11,7 @@ pub fn specs() -> Vec { "Pencil", [], None, - "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end; with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", + "{points: [[x,y]…] or [[x,y,pressure]…] or [[x,y,pressure,tiltX,tiltY,rotation]…] (pen pressure 0..1: the stroke keeps it, for Calligraphic and Scatter brushes with Pressure variation; a point without one presses fully. Pen tilt in degrees, each −90..90, x positive leaning right and y leaning down the page, and barrel rotation in degrees clockwise: the stroke keeps how far the pen leaned, which way and how far its barrel turned, for brushes with Tilt, Bearing and Rotation variation; null for what a point lacks), fidelity?: pt (1.5; from 0.000015625 (0.01 / 640) to 100 / 0.0313 (about 3194.89), the Pencil's and the Paintbrush's 0.01 to 100 px Fidelity at the highest and the lowest zoom; a value outside that range is an error. The tools send their Fidelity in pixels divided by the zoom, kept within the range), fit?: bool (true; false: the points are joined by straight segments, as the Pencil previews a stroke with Live curve fitting off), closed?, style?: \"pencil\"|\"brush\", fill?: bool, keepSelected?: bool (true: the drawn or continued path is selected; false: a new path leaves nothing selected, and a continued or redrawn one is deselected while the rest of the selection stays), extend?: {id, end: \"start\"|\"end\"}, connect?: {id, end: \"start\"|\"end\"}, redraw?: {id, within?: pt (12)}} fit a freehand stroke → {id}. extend continues the path from that end, and closes it when `closed` is true (left out, when the stroke ends within four Fidelities of the path's other end, 6 to 400 pt); with connect, the stroke ends on that end of another path's last open subpath and joins it on, in one path with the extended path's appearance (the other path is removed when nothing of it is left); redraw (Edit Selected Paths) replaces the section of the path between where the stroke starts and ends, each within `within` of one subpath (the shorter way round a closed one), and draws a new path when the stroke doesn't end on it", has_doc, freehand ), @@ -110,7 +110,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/cmd/draw2/freehand.rs b/crates/engine/src/cmd/draw2/freehand.rs index 0439aaa081..401bdd2977 100644 --- a/crates/engine/src/cmd/draw2/freehand.rs +++ b/crates/engine/src/cmd/draw2/freehand.rs @@ -217,11 +217,18 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { } None => None, }; - // An extending stroke that ends within four Fidelities of the path's other end closes it: - // at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. + // `closed`, when given, says whether the stroke closes the path: the Pencil and the + // Paintbrush send it, by their Close Paths When Ends Are Within and Alt at the release. + // Left out, an extending stroke that ends within four Fidelities of the path's other end + // closes it: at least 6 pt and at most 400 pt, whatever the Fidelity it is fitted at. let close_tol = (tol * 4.0).clamp(6.0, 400.0); - let closing = connect.is_none() && sp.anchors.len() >= 2 && pts.last().is_some_and(|p| p.distance(first) <= close_tol); - if closing && let Some(l) = pts.last_mut() { + let near_first = pts.last().is_some_and(|p| p.distance(first) <= close_tol); + let wants_close = if p.get("closed").is_some_and(|v| !v.is_null()) { closed } else { near_first }; + let closing = connect.is_none() && sp.anchors.len() >= 2 && wants_close; + // A stroke ending near the other end ends on it; one ending farther away closes with a + // straight segment back to it. + let ends_on_first = closing && near_first; + if ends_on_first && let Some(l) = pts.last_mut() { *l = first; } if let Some((_, _, y, _)) = &connect @@ -238,13 +245,23 @@ pub(super) fn freehand(s: &mut Session, p: &Value) -> Result { add.remove(0); } sp.anchors.extend(add); - if closing + if ends_on_first && sp.anchors.len() > 2 && let Some(l) = sp.anchors.pop() && let Some(a0) = sp.anchors.first_mut() { a0.h_in = l.h_in; sp.closed = true; + } else if closing && !ends_on_first && sp.anchors.len() > 2 { + // Closed from away from the other end: a straight segment back to it, whatever + // handles the open ends carried. + sp.closed = true; + if let Some(l) = sp.anchors.last_mut() { + l.h_out = l.p; + } + if let Some(f) = sp.anchors.first_mut() { + f.h_in = f.p; + } } for a in &mut sp.anchors { *a = Anchor::with_handles(a.p, a.h_in, a.h_out); diff --git a/crates/engine/src/cmd/draw2/smooth.rs b/crates/engine/src/cmd/draw2/smooth.rs index 9f119fca86..be7db9a1ae 100644 --- a/crates/engine/src/cmd/draw2/smooth.rs +++ b/crates/engine/src/cmd/draw2/smooth.rs @@ -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 { + 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 = (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 = (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 { + 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 { + 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; 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; 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 { + let last = anchors.len().saturating_sub(1); + let mut cuts: Vec = (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 = 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 { +/// 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 { 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 { + 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 = { + 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]); + } } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 9ecb5c3984..00b6a338f2 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -808,11 +808,11 @@ fn new_document_from_state(s: &mut Session, p: &Value) -> Result { 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>> { 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::>>() - .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::>>(); + 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")); diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 84fb121d0c..6995075d2f 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -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}; diff --git a/crates/engine/src/cmd/fileio/commands.rs b/crates/engine/src/cmd/fileio/commands.rs index ca74243699..24a71cc883 100644 --- a/crates/engine/src/cmd/fileio/commands.rs +++ b/crates/engine/src/cmd/fileio/commands.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the visible art), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", + "{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|hpgl|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin., which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; pdf/ai: of the layers the file draws, without editing data, with a warning, and ai refuses pdfCompatible: false; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts, variableData} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), …hpgl writes a plot of the artboard as pen moves for plotters and cutters (each visible path once along its line in plotter units; useArtboards: true one file per chosen artboard, false the bounds of the art it plots, without placed images, unpainted paths and art at 0% opacity), hpglVersion?: hpgl (default: IN, SP, PU, PD) | hpgl2 (also NP and PC pen colors, each pen's paths as one encoded polyline PE), curveTolerance?: 0.1 (points, 0.01 to 10), pens?: [\"#rrggbb\"…] (pen 1 first, up to 255; each path takes the nearest; default a pen per color in the order the art first draws in it, numbered alike in every file), origin?: bottomLeft (default) | center, sortPaths?: false (each pen's next path the nearest), insideFirst?: false (a path after the pen's paths that lie inside it), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is not used, and warnings name it. Under automation read roots, svg and svgz are refused while the document links a JavaScript file outside them (svgInteractivity; a URL is not checked). Never changes the document's path", has_doc, export::export ), diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 0fbffcea82..0bb18483cb 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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> { /// 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> { +/// 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> { 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 { + 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 { + // 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)> = 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, left_out: &dyn Fn(&Node) -> bool) -> Option> { + 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> = 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], left_out: &dyn Fn(&Node) -> bool) -> Option>> { + let mut out: Option>> = 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(r: std::result::Result) -> Result { r.map_err(|e| bad(C, e)) } @@ -636,8 +770,9 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result { { 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); } diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index bb9dc3711a..1a3c4c6e2c 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -550,9 +550,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result // 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() { diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index a891a81ff2..da6da069e9 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,7 @@ pub fn specs() -> Vec { "Export PDF", [], None, - "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art, as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), @@ -289,7 +289,7 @@ pub const EDITING_NEEDS_THE_ARTBOARDS: &str = /// Editing on, the notes end with [`EDITING_NEEDS_THE_ARTBOARDS`]. `None` when `p` asks for the /// artboards. pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result, Vec)>> { - let Some((art, mut q)) = super::encode::art_page(cmd, doc, p)? else { return Ok(None) }; + let Some((art, mut q)) = art_page(cmd, doc, p)? else { return Ok(None) }; let left_out = settings(cmd, &q)?.preserve_editing; if left_out && let Some(o) = q.as_object_mut() { o.insert("preserveEditing".into(), json!(false)); @@ -301,6 +301,18 @@ pub(super) fn encode_art_page(cmd: &str, doc: &Document, p: &Value) -> Result Result> { + if super::encode::use_artboards(cmd, p)? != Some(false) { + return Ok(None); + } + let set = settings(cmd, p)?; + let keeps = set.include_non_printing || set.create_layers; + super::encode::art_page(cmd, doc, p, |l| !keeps && matches!(l.kind, NodeKind::Layer { printable: false, .. })) +} + /// The warning for a PDF/X file that leaves out the document's bleed /// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]). pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings"; @@ -477,29 +489,7 @@ fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result]) -> Option>> { - let mut out: Option>> = None; - for (i, l) in layers.iter().enumerate() { - let NodeKind::Layer { printable, children, .. } = &l.kind else { continue }; - let new = if !printable { - l.visible.then(|| { - let mut n = (**l).clone(); - n.visible = false; - Arc::new(n) - }) - } else { - without_non_printing(children).map(|c| { - let mut n = (**l).clone(); - if let NodeKind::Layer { children, .. } = &mut n.kind { - *children = c; - } - Arc::new(n) - }) - }; - if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) { - *slot.0 = slot.1; - } - } - out + super::encode::hidden_nodes(layers, &|l| matches!(l.kind, NodeKind::Layer { printable: false, .. })) } fn export_pdf(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index af9ed8e58a..e4a72ffc35 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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> { @@ -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 { +/// `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 { + 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)?; diff --git a/crates/engine/src/cmd/fileio/tests_hpgl.rs b/crates/engine/src/cmd/fileio/tests_hpgl.rs index f4767fd81d..a7fe16690e 100644 --- a/crates/engine/src/cmd/fileio/tests_hpgl.rs +++ b/crates/engine/src/cmd/fileio/tests_hpgl.rs @@ -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(); diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs index f57e02fc2d..f5b1b8e34d 100644 --- a/crates/engine/src/cmd/fileio/tests_pdf.rs +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -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 diff --git a/crates/engine/src/cmd/fileio/tests_svg.rs b/crates/engine/src/cmd/fileio/tests_svg.rs index eb3989597f..121cc0b279 100644 --- a/crates/engine/src/cmd/fileio/tests_svg.rs +++ b/crates/engine/src/cmd/fileio/tests_svg.rs @@ -352,20 +352,8 @@ fn svg_darkness_preserves_source_model_and_gradient_appearance() { #[test] fn svg_darkness_uses_rendered_shadow_in_non_srgb_working_space() { - const CHILD: &str = "VECTORCRAFT_TEST_SHADOW_RGB_PROFILE"; - if std::env::var_os(CHILD).is_none() { - // Color settings are process-wide: exercise this profile in a separate test process. - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space", "--nocapture"]) - .env(CHILD, "1") - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!( - output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), - "isolated color profile test failed:\n{stdout}\n{}", - String::from_utf8_lossy(&output.stderr), - ); + // Color settings are process-wide: exercise this profile in a separate test process. + if !crate::tests::in_own_process("cmd::fileio::tests_svg::svg_darkness_uses_rendered_shadow_in_non_srgb_working_space") { return; } use vectorcraft_color::{Color, Paint, cms}; diff --git a/crates/engine/src/cmd/flatten/apply.rs b/crates/engine/src/cmd/flatten/apply.rs index ef8425b362..b159fa7361 100644 --- a/crates/engine/src/cmd/flatten/apply.rs +++ b/crates/engine/src/cmd/flatten/apply.rs @@ -797,22 +797,47 @@ fn place_of(d: &Document, roots: &[NodeId]) -> Option { } /// 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) -> 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> = 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. diff --git a/crates/engine/src/cmd/globaledit.rs b/crates/engine/src/cmd/globaledit.rs index f454d7133b..5cbdd8f291 100644 --- a/crates/engine/src/cmd/globaledit.rs +++ b/crates/engine/src/cmd/globaledit.rs @@ -6,20 +6,34 @@ //! (), 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" //! (): //! 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" +//! () 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 { "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>, @@ -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 { + 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> { } 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> { }; // 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 { 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 = members.iter().map(|(id, _)| id.0).collect(); let st = s.doc_mut()?; @@ -1027,6 +1097,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); diff --git a/crates/engine/src/cmd/graph/series.rs b/crates/engine/src/cmd/graph/series.rs index b5acb1ba66..5f6d602ad5 100644 --- a/crates/engine/src/cmd/graph/series.rs +++ b/crates/engine/src/cmd/graph/series.rs @@ -631,19 +631,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 } @@ -872,12 +886,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. diff --git a/crates/engine/src/cmd/graph/tests.rs b/crates/engine/src/cmd/graph/tests.rs index 0a788c4330..04eff93a6c 100644 --- a/crates/engine/src/cmd/graph/tests.rs +++ b/crates/engine/src/cmd/graph/tests.rs @@ -840,6 +840,24 @@ fn an_opacity_sort_objects_stores_the_frontmost_mark_it_changes() { assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.6)); } +/// A Sort Objects that gives series marks the opacities they already have changes nothing: the value the graph stores +/// stays and no undo step is made, even when the evenly stepped percentage differs from a mark's in the last digits. +#[test] +fn an_opacity_sort_objects_that_changes_no_mark_keeps_the_stored_opacity() { + let (mut s, id, _, marks) = column_marks(); + s.execute("object.setProps", &json!({"id": marks[1].0, "opacity": 65})).unwrap(); + s.execute("object.setProps", &json!({"id": marks[0].0, "opacity": 30})).unwrap(); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + // From left to right the columns have 30%, 65% and 100%, the values an even step from 30% to 100% gives them. + let undo = s.doc().unwrap().history.undo.len(); + let ids = [marks[0].0, marks[1].0, marks[2].0]; + s.execute("addon.arrange.sort", &json!({"ids": ids, "by": "x", "apply": "opacity", "distribute": true})).unwrap(); + let opacities: Vec = marks[..3].iter().map(|m| s.doc().unwrap().doc.node(*m).unwrap().opacity).collect(); + assert_eq!(opacities, [0.3, 0.65, 1.0]); + assert_eq!(spec(&s, id).series_paints[0].opacity, Some(0.3)); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "no undo step"); +} + /// An object opacity, blend mode, fill, stroke and weight that Global Edit gives a mark of another series from the mark /// that was edited are stored on the graph for that series in the same undo step, and every mark of the series has them /// when the graph is drawn again. @@ -6343,6 +6361,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(); diff --git a/crates/engine/src/cmd/opacitymask.rs b/crates/engine/src/cmd/opacitymask.rs index 75d5852ea2..ac3c780c11 100644 --- a/crates/engine/src/cmd/opacitymask.rs +++ b/crates/engine/src/cmd/opacitymask.rs @@ -472,7 +472,7 @@ impl Session { pub fn transparency_info(&self) -> TransparencyInfo { match (self.active(), transparency_targets(self, &Value::Null)) { (Some(st), Ok(ids)) => { - let points = super::paint::picked_mesh_points_of(self, &ids); + let points = self.mesh_points_picked(); TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points) } _ => TransparencyInfo::default(), @@ -483,7 +483,8 @@ impl Session { fn info(s: &mut Session, p: &Value) -> Result { let st = s.doc()?; let ids = transparency_targets(s, p)?; - let points = super::paint::picked_mesh_points_of(s, &ids); + // The points `transparency.set` with the same `ids` (or none) fades. + let points = if p.get("ids").is_none() && p.get("id").is_none() { s.mesh_points_picked() } else { super::paint::picked_mesh_points_of(s, &ids) }; let i = TransparencyInfo::of(&st.doc, ids).with_mesh_points(&st.doc, points); Ok(json!({ "ids": i.ids.iter().map(|id| id.0).collect::>(), diff --git a/crates/engine/src/cmd/paint.rs b/crates/engine/src/cmd/paint.rs index e7b89ade16..4f822da5db 100644 --- a/crates/engine/src/cmd/paint.rs +++ b/crates/engine/src/cmd/paint.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "Fill", [], None, - "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid colour recolours only the gradient mesh point last clicked with the Mesh tool or Direct Selection; while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", + "{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}|{l,a,b} (CIE Lab; #rrggbbaa and #rgba are refused), none?: true, swatch?: name (a global or spot colour stays linked, so swatch edits recolour it; a tint swatch links to its base at its tint; a gradient swatch is recorded as the gradient's swatch and fits each object, keeping its aspect; the built-in \"[Registration]\" prints on every plate), tint?: 0..100 (% of a global or spot `swatch`; default 100, or a tint swatch's own), gradient?: {kind?: linear|radial|conic|diamond|freeform (conic, also accepted as angle or conical: the stops sweep once around the center `start`, clockwise from the vector; diamond: the stops run from the center `start` out to a square with a corner at `end`), method?: classic|perceptual|hue-clockwise|hue-counterclockwise (as paint.editGradient; default classic), spread?: pad|repeat|reflect (as paint.editGradient; default pad), repeats?: 1..100 (as paint.editGradient; default 1), stops?: [{offset 0..1, color, opacity? 0..1 (or 0..100), midpoint? 0.13..0.87, swatch?: global or spot colour (or tint) swatch the stop links to (its colour comes from the swatch), tint?: 0..100}] (at least 2; default white→black), angle?: deg counter-clockwise (0: the gradient runs left to right), start?: [x,y], end?: [x,y] (the vector in document coordinates, both or neither; type objects keep it in text space), aspect?: % (radial, conic and diamond; a negative one mirrors a conic's sweep, counterclockwise, and is an error for the other kinds; without start/end the gradient is placed on each object's bounds), focal?: [x,y] (radial, with start/end: the focal point, where the first stop sits; an error with a conic or diamond), dither?: bool (linear, radial, conic and diamond fills: drawn with fine noise that hides banding, and as pixels in SVG and PDF exports; strokes ignore it), swatch?: linked gradient swatch name}, item?: appearance item index|null (omitted: the Appearance panel's active item if it is a fill, else the top fill), ids?|id?, focus?: true (false keeps the active proxy), keepModel?: false (in a CMYK document, RGB colours and gradient stops are stored as CMYK unless true; Gray stays Gray), target?: \"object\"|\"contents\", span?: \"object\" (default: each target fits a gradient to its own bounds)|\"selection\" (a linear, radial, conic or diamond `gradient` without start/end is placed once on the union of the targets' bounds, so it runs across them)} sets the selection's fill and the default (new art fits a gradient to itself); a solid color recolors only the gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso (unless an `item` index is given or the Appearance panel's active item is a fill); while the Type tool has characters selected (and no ids or item are given), the paint goes to those characters only; without `item`, a grayscale or 1-bit image takes a solid color as the color it is drawn in (white pixels transparent; `none` takes it off; an image with an alpha channel takes only a spot color, black or white; a colorized image whose link is missing takes a solid color as its new colorize color, and refuses a process color while its color is a spot color, black or white); with an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, that object's fill changes for this instance only (solid, none, gradient or pattern). On a path directly in a graph series, the graph also stores the paint of the topmost visible fill when the command wrote that row or changed it (exceptions: docs/mcp.md); on a graph label, the graph stores the paint for every label of its kind, as a character fill when the characters took it and with the label's rows when a row did → {objects: how many objects took the paint (the objects inside a group, unless an appearance item of the group's own is targeted; 0 when only the default changed)}", has_doc, |s, p| set_paint(s, p, true) ), @@ -81,7 +81,7 @@ pub fn specs() -> Vec { "Transparency", ["Window", "Transparency"], None, - "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; a gradient mesh point last clicked with the Mesh tool or Direct Selection takes the opacity itself). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", + "{ids?|id?, opacity?: 0..100, blend?: name, isolate?, knockout?: \"on\"|\"off\"|\"neutral\"|bool (true = on, false = neutral), knockoutShape?: bool, item?: appearance item index|null, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} for `ids`, the selection, or the object whose opacity mask is being edited; opacity and blend go to the targeted fill/stroke item (omitted: the Appearance panel's active item, else the objects; when every target is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso, those points take the opacity themselves). With an object of a dynamic symbol instance selected by Direct Selection and no ids, id or item, opacity and blend change that object for this instance only (isolate, knockout, knockoutShape, aligned and target are refused). On a path directly in a graph series (for a fill or stroke item, a series group's id stands for those paths), the graph also stores the opacity and blend mode of the object and of the topmost visible fill and stroke (exceptions: docs/mcp.md); on a graph label, the graph stores the label's object opacity and blend mode for every label of its kind, and with `item` its topmost visible fill and stroke rows too", has_doc, transparency ), @@ -154,7 +154,7 @@ pub fn proxy_specs() -> Vec { "Fill and Stroke", [], None, - "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy}", + "{} → {fill, stroke: the paints the Fill/Stroke proxies show (the Appearance panel's active item for the proxy of its kind, else the first selected object's, a group's first painted object's, type: its first run's; else the defaults for new art; when that object is a gradient mesh with points picked by the Mesh tool, Direct Selection or the Lasso and no fill item is active, the fill is its first picked point's color), fillActive, fillMixed, strokeMixed: the selected objects' (or a selected group's contents') fills (strokes) differ, shown as a \"?\" proxy (a gradient mesh with points picked counts with their colors)}", always, proxies ), @@ -179,9 +179,13 @@ impl Session { /// The fill and stroke the Fill/Stroke proxies show: the Appearance panel's active item (in the /// first selected object's own stack) for the proxy of its kind, else the first selected /// object's (a group's first painted object's; type: its first run's), else the defaults for - /// new art. + /// new art. When the object whose fill the proxy shows is a gradient mesh with points picked + /// ([`Session::fill_mesh_points`]), the fill is its first picked point's color, which a solid + /// fill color changes. pub fn proxy_paints(&self) -> (Paint, Paint) { + let points = self.fill_mesh_points(); let show = |stroke: bool| match self.proxy_source(stroke) { + Some((n, None)) if !stroke && let Some(c) = picked_colors(&n, &points).first() => Paint::solid(*c), Some((n, item)) => proxy_paint(&n, stroke, item), None if stroke => self.paint.stroke.clone(), None => self.paint.fill.clone(), @@ -273,6 +277,13 @@ impl DocState { /// each selected object's own stack is compared. One walk of the document, so callers that ask /// every frame cache it by revision. pub fn proxy_mixed(&self, item: Option) -> (bool, bool) { + self.proxy_mixed_with(item, &[]) + } + + /// [`DocState::proxy_mixed`] with the gradient mesh points a solid fill color recolors + /// (`points`, see [`Session::fill_mesh_points`]): a mesh among them counts with the colors of + /// its picked points instead of its fill. + fn proxy_mixed_with(&self, item: Option, points: &[(NodeId, Vec)]) -> (bool, bool) { if self.selection.is_empty() { return (false, false); } @@ -291,7 +302,10 @@ impl DocState { let differ = |stroke: bool| { let item = first.and_then(|f| f.appearance.item_of_kind(item, !stroke)); let set = if item.is_some() { &selected } else { &nodes }; - let mut paints = set.iter().map(|n| proxy_paint(n, stroke, item)); + let mut paints = set.iter().flat_map(|n| match picked_colors(n, if stroke || item.is_some() { &[] } else { points }) { + c if c.is_empty() => vec![proxy_paint(n, stroke, item)], + c => c.into_iter().map(Paint::solid).collect(), + }); let Some(p0) = paints.next() else { return false }; paints.any(|p| !same_in_proxy(&p0, &p)) }; @@ -313,8 +327,9 @@ impl DocState { impl Session { /// [`DocState::proxy_mixed`] of the active document for the Appearance panel's active item. + /// A gradient mesh with points picked ([`Session::fill_mesh_points`]) counts with their colors. pub fn proxy_mixed(&self) -> (bool, bool) { - self.active().map_or((false, false), |st| st.proxy_mixed(self.appearance_item())) + self.active().map_or((false, false), |st| st.proxy_mixed_with(self.appearance_item(), &self.fill_mesh_points())) } } @@ -539,8 +554,8 @@ pub(crate) fn apply_paint(s: &mut Session, p: &Value, paint: Paint, fill: bool) let selected = item.targets(s, p)?; let ids = super::graph::paint_targets(&s.doc()?.doc, &selected); let paint = if span { placed_across(&s.doc()?.doc, &ids, paint, p, fill) } else { paint }; - // The gradient mesh points picked with the Mesh tool or Direct Selection: a solid fill - // recolours just those points of their meshes. + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso: a solid + // fill recolors just those points of their meshes (the proxies show them: `fill_mesh_points`). let mesh_points = if fill && matches!(paint, Paint::Solid { .. }) { picked_mesh_points(s, &ids) } else { vec![] }; // Without `item`, unless the Appearance panel's active fill item takes the paint. let colorized = if fill && p.get("item").is_none() && item == ItemTarget::Top { @@ -871,6 +886,55 @@ pub(crate) fn picked_mesh_points_of(s: &Session, ids: &[NodeId]) -> Option)]) -> Vec { + match (&n.kind, points.iter().find(|(id, _)| *id == n.id)) { + (NodeKind::Mesh(m), Some((_, v))) => v.iter().filter_map(|i| m.points.get(*i)).map(|q| q.color).collect(), + _ => vec![], + } +} + +impl Session { + /// The picked gradient mesh points that a solid fill color (`paint.setFill` without `ids` or + /// `item`) recolors instead of their meshes' fills, each mesh with its points: the targets are + /// resolved as [`set_paint`] resolves them, so this is empty while the Appearance panel's + /// active item is a fill row or an object of a symbol instance is selected, and a mesh with + /// points picked counts even when other objects are selected with it. + pub fn fill_mesh_points(&self) -> Vec<(NodeId, Vec)> { + let Some(st) = self.active() else { return vec![] }; + // Only gradient meshes have points to pick: a selection with no mesh and no container that + // could hold one skips reading the tool's options. + let may_hold_mesh = |id: &NodeId| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Mesh(_)) || n.is_container()); + if st.selection.symbol_part.is_some() || !st.selection.subjects().iter().any(may_hold_mesh) { + return vec![]; + } + let Ok(item) = item_target(self, &Value::Null, "paint.setFill").map(|t| t.of_kind(self, true)) else { return vec![] }; + if item != ItemTarget::Top { + return vec![]; + } + let Ok(selected) = item.targets(self, &Value::Null) else { return vec![] }; + picked_mesh_points(self, &super::graph::paint_targets(&st.doc, &selected)) + } + + /// The picked gradient mesh points that the Transparency panel's opacity (`transparency.set` + /// without `ids` or `item`) fades instead of their objects: `None` unless every target is a + /// gradient mesh with points picked, and while the Appearance panel's active item takes the + /// opacity (outside opacity mask editing). + pub fn mesh_points_picked(&self) -> Option)>> { + let st = self.active()?; + let ids = super::opacitymask::transparency_targets(self, &Value::Null).ok()?; + // Only gradient meshes have points to pick: other targets skip reading the tool's options. + if ids.is_empty() || !ids.iter().all(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Mesh(_)))) { + return None; + } + if st.doc.mask_edit.is_none() && self.appearance_item().is_some() { + return None; + } + picked_mesh_points_of(self, &ids) + } +} + /// A type run's stroke; a painted stroke on a run without one gets 1 pt. pub(super) fn set_run_stroke(style: &mut CharStyle, paint: Paint) { if !paint.is_none() { @@ -1086,8 +1150,8 @@ fn transparency(s: &mut Session, p: &Value) -> Result { q.remove("item"); q.remove("id"); q.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); - // The gradient mesh points picked with the Mesh tool or Direct Selection take the opacity - // themselves, so only the mesh around them fades (#4206, #4170, #4208). + // The gradient mesh points picked with the Mesh tool, Direct Selection or the Lasso take the + // opacity themselves, so only the mesh around them fades (#4206, #4170, #4208). if matches!(item, ItemTarget::Top) && let Some(o) = q.get("opacity").and_then(Value::as_f64) && let Some(points) = picked_mesh_points_of(s, &ids) diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 2383d05a3a..f475aae760 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -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 { @@ -137,19 +137,16 @@ pub(crate) fn named_or_selected(s: &Session, p: &Value, cmd: &str) -> Result = 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 = + 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 { 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 { - 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> { + 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 { + 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> { - 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> { + 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::>>(); + 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::>>()?; indices.sort_unstable(); indices.dedup(); Ok(indices) diff --git a/crates/engine/src/cmd/path.rs b/crates/engine/src/cmd/path.rs index b497a9f096..2ad2b70049 100644 --- a/crates/engine/src/cmd/path.rs +++ b/crates/engine/src/cmd/path.rs @@ -77,7 +77,7 @@ pub fn specs() -> Vec { "Reverse Path Direction", ["Object", "Path"], None, - "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step → {changed: paths}", + "{ids?|id?, subpath?: usize (reverse only this subpath of each path, 0 included; default: every subpath), reversed?: bool (true: every subpath runs counter-clockwise on screen, false: clockwise, the Attributes panel's Reverse Path Direction On / Off; omitted: flip each path; with subpath, the direction is not read: that subpath is flipped)} the paths in ids or the selection (groups and compound paths: the paths inside), as one undo step; when every subpath of a path turns alike, its strokes' width profiles (sides swapped) and pen pressure, tilt, bearing and rotation turn with it, so they draw as before → {changed: paths}", has_selected_path, reverse ), @@ -462,22 +462,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result { let mut changed = 0; for id in &ids { let path = path_mut(d, *id)?; - if let Some(si) = subpath { - if let Some(sp) = path.subpaths.get_mut(si) { + let flip: Vec = match subpath { + Some(si) => (0..path.subpaths.len()).map(|i| i == si).collect(), + None => path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(), + }; + if !flip.contains(&true) { + continue; + } + // The stroke's width profile and pen recordings turn with the path, read before it turns. + if let Some(n) = d.node_mut(*id) { + vectorcraft_doc::blend::reverse_stroke_records(n, &flip); + } + let path = path_mut(d, *id)?; + for (sp, f) in path.subpaths.iter_mut().zip(flip) { + if f { sp.reverse(); - changed += 1; - } - } else { - let flip: Vec = path.subpaths.iter().map(|sp| want.is_none_or(|ccw| (sp.signed_area() < 0.0) != ccw)).collect(); - if flip.contains(&true) { - for (sp, f) in path.subpaths.iter_mut().zip(flip) { - if f { - sp.reverse(); - } - } - changed += 1; } } + changed += 1; } Ok(changed) })?; diff --git a/crates/engine/src/cmd/place.rs b/crates/engine/src/cmd/place.rs index a558545e4e..dc95f925b6 100644 --- a/crates/engine/src/cmd/place.rs +++ b/crates/engine/src/cmd/place.rs @@ -75,7 +75,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", + "{path | name | dataBase64 (a file's path, or its name with its bytes), link?: true (a raster image keeps its file's path; a VectorCraft document at path becomes one locked placed document showing artboard `page`, linked to the file and read again when it changes (links.update; saving it in this app updates it at once), drawn as vectors in every output; so does a PDF/.ai page at path, showing its `crop` box, unless password or textAs: \"outlines\" is given; false: an editable copy of its art, as from dataBase64; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark, or without one by dataset.import's rule: an even length, at least half of its two-byte units holding a NUL byte, the byte order that reads a line break, valid UTF-16 with no NUL character; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect; refused while an object of a symbol instance's art is selected), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside; aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default; an SVG, only when given: framed and clipped by its viewBox, else by its art's bounds) | \"bounding\" (the bounds of the art that shows, with its strokes and effects, as an export measures them: hidden objects and guides left out) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings, image?: the id of the image of a DCS file placed in a clip group, which the Links commands take}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, HP-GL (.plt, .hpgl, .hgl, .hpg), PDF/.ai, EMF/WMF and PICT (.pct, .pic, .pict; clipped to the picture's frame), EPS (clipped to its bounding box; as document.open reads it), a .ai saved without PDF compatibility (from its editing data, as document.open reads it: the art of the layers that show on artboard `page`, clipped to that artboard unless crop is \"bounding\") and native documents as one group, with the images, symbols, patterns, swatches and brushes they use. A DCS file (an EPS file with one program per printing plate, DCS 1.0 or 2.0) is placed only from its path with link (else an error, as are document.open and links.embed): one linked image of its plates mixed into CMYK, clipped by the clipping path its composite is drawn through, its spot plates added as spot swatches in the process equivalents the file gives (a swatch of the same name is kept, with a warning); print separations and Separations Preview show each plate on its own ink, and plate files of their own are read from its folder. One undo step; selects what it placed (unless on a template layer); never touches the clipboard. A PNG, JPEG, GIF or WebP image whose pixels can't be read (a file cut short) is an error, and nothing is placed", has_doc, place ), diff --git a/crates/engine/src/cmd/place/text.rs b/crates/engine/src/cmd/place/text.rs index 25f5f36dbd..7d8afc92d4 100644 --- a/crates/engine/src/cmd/place/text.rs +++ b/crates/engine/src/cmd/place/text.rs @@ -3,6 +3,7 @@ use serde_json::Value; +use super::super::utf16_order_without_mark; use super::{Point, Rect, Result, bad, bool_or, fileio, shapes, str_param}; /// Text files as `file.place` reports them (not a format `document.open` reads). @@ -76,8 +77,10 @@ pub(super) fn import(bytes: &[u8], o: TextOptions, cmd: &str) -> Result Ok(clean(&decode(bytes, o), o)) } -/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark; bytes that aren't UTF-8 are -/// read as the platform's 8-bit set), or the platform's 8-bit character set. +/// `bytes` as text: Unicode (UTF-8, or UTF-16 with a byte-order mark, or without one when the +/// file has an even length, reads as valid UTF-16 with no NUL character in the byte order +/// [`utf16_order_without_mark`] gives; bytes that aren't UTF-8 are read as the platform's 8-bit +/// set), or the platform's 8-bit character set. fn decode(bytes: &[u8], o: TextOptions) -> String { let eight_bit = |b: &[u8]| -> String { b.iter().map(|&c| if o.mac { mac_roman(c) } else { windows_1252(c) }).collect() }; if o.ansi { @@ -91,7 +94,17 @@ fn decode(bytes: &[u8], o: TextOptions) -> String { [0xEF, 0xBB, 0xBF, rest @ ..] => String::from_utf8_lossy(rest).into_owned(), [0xFF, 0xFE, rest @ ..] => utf16(rest, true), [0xFE, 0xFF, rest @ ..] => utf16(rest, false), - _ => std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string), + _ => { + let without_mark = (bytes.len().is_multiple_of(2) && bytes.contains(&0)) + .then(|| utf16_order_without_mark(bytes)) + .flatten() + .and_then(|le| { + let units = bytes.as_chunks::<2>().0.iter().map(|&c| if le { u16::from_le_bytes(c) } else { u16::from_be_bytes(c) }); + char::decode_utf16(units).collect::>().ok() + }) + .filter(|text| !text.contains('\0')); + without_mark.unwrap_or_else(|| std::str::from_utf8(bytes).map_or_else(|_| eight_bit(bytes), str::to_string)) + } } } diff --git a/crates/engine/src/cmd/prefscmds.rs b/crates/engine/src/cmd/prefscmds.rs index 7a948bfc87..b6c500749f 100644 --- a/crates/engine/src/cmd/prefscmds.rs +++ b/crates/engine/src/cmd/prefscmds.rs @@ -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), diff --git a/crates/engine/src/cmd/threads.rs b/crates/engine/src/cmd/threads.rs index 35f67f64c2..ad41d1d220 100644 --- a/crates/engine/src/cmd/threads.rs +++ b/crates/engine/src/cmd/threads.rs @@ -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 = 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 = 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 { @@ -420,6 +428,8 @@ pub(crate) fn story_of(d: &Document, id: NodeId) -> Vec { /// [`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 { + #[cfg(test)] + STORY_BUILDS.with(|c| c.set(c.get() + 1)); let frames: Vec = thread.iter().copied().filter(|f| text_of(d, *f).is_some()).collect(); if frames.len() >= 2 && frames.contains(&id) { frames } else { vec![] } } diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs index 73e2a78b31..05b45cc051 100644 --- a/crates/engine/src/cmd/typecmd.rs +++ b/crates/engine/src/cmd/typecmd.rs @@ -86,7 +86,7 @@ pub fn specs() -> Vec { "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 { "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 { "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 { "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 { "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 { "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 { "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 { } /// 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 { const C: &str = "type.ruby"; let ids = text_targets(s, p, C)?; @@ -293,7 +293,8 @@ fn ruby(s: &mut Session, p: &Value) -> Result { } let overhang = switch_or(p, "overhang", true, C)?; let mut first: Option = 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 { 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::>(), "ruby": first.unwrap_or(Value::Null)})) } @@ -755,6 +756,19 @@ fn create_outlines(s: &mut Session, _: &Value) -> Result { Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) } +/// [`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(s: &mut Session, label: &str, ids: &[NodeId], f: impl FnOnce(&mut Document) -> Result<(T, bool)>) -> Result { + 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> { 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, mut f: i fn set_text(s: &mut Session, p: &Value) -> Result { 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 { t.ruby.clear(); refresh_bounds(t); } - Ok(()) + Ok(((), false)) })?; Ok(with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() }))) } @@ -933,7 +948,8 @@ fn set_style(s: &mut Session, p: &Value) -> Result { 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 { } // 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 { } }); } - Ok(()) + Ok(((), labels_changed)) })?; let mut out = with_overflow(s, &ids, json!({ "ids": ids.iter().map(|i| i.0).collect::>() })); if let Some(w) = super::fonts::set_font_warning(set) { @@ -1350,14 +1367,16 @@ fn reshape_area(s: &mut Session, p: &Value) -> Result { 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 = 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 { 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 { 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 { 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 { 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 { 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 = 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 { refresh_bounds(t); } } - Ok(()) + Ok(((), false)) })?; Ok(json!({ "tracking": first })) } diff --git a/crates/engine/src/cmd/typecmd/noop_tests.rs b/crates/engine/src/cmd/typecmd/noop_tests.rs new file mode 100644 index 0000000000..02481d8bee --- /dev/null +++ b/crates/engine/src/cmd/typecmd/noop_tests.rs @@ -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, 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) -> (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); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index 9acd31e664..5f8d6605b3 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -26,6 +26,7 @@ use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids mod csv; mod library; +pub use csv::utf16_order_without_mark; use csv::{MAX_GRAPH_CSV, dataset_export, dataset_import}; pub(crate) use library::{give_svg_library, svg_library}; use library::{load_library, save_library}; diff --git a/crates/engine/src/cmd/variablecmds/csv.rs b/crates/engine/src/cmd/variablecmds/csv.rs index 38dd42d922..be9db3b841 100644 --- a/crates/engine/src/cmd/variablecmds/csv.rs +++ b/crates/engine/src/cmd/variablecmds/csv.rs @@ -59,25 +59,35 @@ fn utf16(bytes: &[u8], le: bool) -> Option { char::decode_utf16(units).collect::>().ok().filter(|_| odd.is_empty()) } -/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16, or `None`. At least half of -/// the two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with -/// fewer, such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is -/// the one that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or -/// U+0D00, so a file that reads one in both orders is refused. A file that reads none in either -/// order has no data rows, and is read little-endian so that the import reports that. The text -/// must be valid UTF-16 in that order and hold no NUL character. -fn utf16_without_mark(bytes: &[u8]) -> Option { - let (units, odd) = bytes.as_chunks::<2>(); - if !odd.is_empty() || units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { +/// The byte order of `bytes`, which hold NUL bytes and no byte-order mark, read as UTF-16: +/// `Some(true)` for little-endian, or `None` when they don't read as UTF-16. At least half of the +/// two-byte units must hold a NUL byte, as characters from U+0001 to U+00FF do; a file with fewer, +/// such as UTF-8 or Windows-1252 text with stray NUL bytes, is refused. The byte order is the one +/// that reads a line break (`\n` or `\r`). The other order reads one only from U+0A00 or U+0D00, +/// so a file that reads one in both orders is refused. A file that reads none in either order (one +/// line) is big-endian when more of its units start with a NUL byte than end with one, as +/// characters from U+0001 to U+00FF do in that order, and little-endian otherwise. An odd last +/// byte is left out of the count. +pub fn utf16_order_without_mark(bytes: &[u8]) -> Option { + let units = bytes.as_chunks::<2>().0; + if units.iter().filter(|c| c.contains(&0)).count() * 2 < units.len() { return None; } let is_line_break = |u: u16| matches!(u, 0x0A | 0x0D); let lines_le = units.iter().any(|&c| is_line_break(u16::from_le_bytes(c))); let lines_be = units.iter().any(|&c| is_line_break(u16::from_be_bytes(c))); - if lines_le && lines_be { - return None; + match (lines_le, lines_be) { + (true, true) => None, + (false, false) => Some(units.iter().filter(|c| c[0] == 0).count() <= units.iter().filter(|c| c[1] == 0).count()), + (_, lines_be) => Some(!lines_be), } - utf16(bytes, !lines_be).filter(|text| !text.contains('\0')) +} + +/// `bytes`, which hold NUL bytes and no byte-order mark, as UTF-16 in the byte order +/// [`utf16_order_without_mark`] gives, or `None`. The file must have an even length, and the text +/// must be valid UTF-16 in that order and hold no NUL character. +fn utf16_without_mark(bytes: &[u8]) -> Option { + utf16(bytes, utf16_order_without_mark(bytes)?).filter(|text| !text.contains('\0')) } /// A row of a datasets CSV: the file line it starts on, its cells, and for each cell whether it was quoted. diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 1e4629e448..86525e9214 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -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, diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index a87b47be0e..83e6368f8b 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -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 = diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2f849901a4..57445a36be 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -241,6 +241,25 @@ pub(crate) fn key_guide(s: &Session) -> Option { st.selection.key_guide.and_then(|k| crate::cmd::guide_indexes(&st.doc, &[k]).first().copied()) } +/// Runs the unit test `path` (`tests::newdoc::...`) again in a child process of the test binary +/// → whether this is that child. For a test that changes process-wide state the tests running +/// beside it read, such as the active color settings. +pub(crate) fn in_own_process(path: &str) -> bool { + const CHILD: &str = "VECTORCRAFT_TEST_OWN_PROCESS"; + if std::env::var_os(CHILD).is_some() { + return true; + } + let output = + std::process::Command::new(std::env::current_exe().unwrap()).args(["--exact", path, "--nocapture"]).env(CHILD, "1").output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"), + "{path} failed in its own process:\n{stdout}\n{}", + String::from_utf8_lossy(&output.stderr), + ); + false +} + #[test] fn create_and_undo_redo() { let mut s = session(); diff --git a/crates/engine/src/tests/attributes.rs b/crates/engine/src/tests/attributes.rs index d3edf2615b..8ddfc352a1 100644 --- a/crates/engine/src/tests/attributes.rs +++ b/crates/engine/src/tests/attributes.rs @@ -163,6 +163,61 @@ fn reverse_path_direction_on_and_off_set_the_winding() { assert!(s.execute("path.reverse", &json!({})).is_err(), "nothing to reverse"); } +#[test] +fn reverse_path_direction_turns_the_width_profile_and_pen_records_with_the_path() { + use vectorcraft_doc::{AngleProfile, PressureProfile, WidthProfile}; + let mut s = session(); + let open = + NodeId(run(&mut s, "path.create", json!({"anchors": [{"x": 0, "y": 50}, {"x": 40, "y": 50}, {"x": 100, "y": 50}]}))["id"].as_u64().unwrap()); + let closed = rect(&mut s, 10.0); + let profile = WidthProfile { points: vec![(0.0, 0.2, 0.6), (0.3, 1.0, 0.4), (1.0, 0.5, 0.9)] }; + let pressure = PressureProfile { points: vec![(0.0, 0.1), (0.25, 0.9), (1.0, 0.4)] }; + let bearing = AngleProfile { points: vec![(0.0, 10.0), (0.6, 80.0), (1.0, 170.0)] }; + s.edit("set", |d, _| { + for id in [open, closed] { + let st = d.node_mut(id).unwrap().appearance.stroke_mut().unwrap(); + st.profile = Some(profile.clone()); + st.pressure = Some(pressure.clone()); + st.tilt = Some(pressure.clone()); + st.bearing = Some(bearing.clone()); + st.rotation = Some(bearing.clone()); + } + Ok(()) + }) + .unwrap(); + // (anchor point, its fraction of the path's length) for each anchor. + let along = |s: &Session, id: NodeId| { + let sp = s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().subpaths[0].clone(); + let pos = sp.anchor_arc_positions(); + let total = *pos.last().unwrap(); + sp.anchors.iter().zip(pos).map(|(a, at)| (a.p, at / total)).collect::>() + }; + let before = [along(&s, open), along(&s, closed)]; + run(&mut s, "select.set", json!({"ids": [open.0, closed.0]})); + assert_eq!(run(&mut s, "path.reverse", json!({}))["changed"], 2); + for ((id, before), closed) in [open, closed].into_iter().zip(before).zip([false, true]) { + let after = along(&s, id); + let st = s.doc().unwrap().doc.node(id).unwrap().appearance.stroke().unwrap().clone(); + // Each anchor keeps the widths it had (its sides swapped, as the path runs the other way) + // and what the pen recorded there. (A closed path's start is where its profile steps.) A + // closed path now starts part way along a span of the width profile, which is resampled + // there, so its smooth curve moves a little. + let near = if closed { 0.02 } else { 1e-6 }; + for (p, t) in before.iter().filter(|(_, t)| !closed || *t > 0.0) { + let u = after.iter().find(|(q, _)| q.distance(*p) < 1e-9).unwrap().1; + let (l, r) = profile.at(*t); + let (l2, r2) = st.profile.as_ref().unwrap().at(u); + assert!((l - r2).abs() < near && (r - l2).abs() < near, "{id:?} at {p:?}: {:?} then {:?}", (l, r), (l2, r2)); + for got in [&st.pressure, &st.tilt] { + assert!((pressure.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} pressure at {p:?}"); + } + for got in [&st.bearing, &st.rotation] { + assert!((bearing.at(*t) - got.as_ref().unwrap().at(u)).abs() < 1e-6, "{id:?} bearing at {p:?}"); + } + } + } +} + #[test] fn attributes_survive_a_native_round_trip() { let mut s = session(); diff --git a/crates/engine/src/tests/build.rs b/crates/engine/src/tests/build.rs index e0d68ee949..dedcf2d8af 100644 --- a/crates/engine/src/tests/build.rs +++ b/crates/engine/src/tests/build.rs @@ -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::>().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] @@ -3878,6 +3948,37 @@ fn live_paint_bucket_alt_shift_gradient_samples_active_stroke_only() { assert!(matches!(node(&s, src).appearance.fill_paint(), Paint::Gradient(_))); } +#[test] +fn live_paint_selection_double_click_selects_the_faces_no_stroked_edge_parts() { + let mut s = session(); + let g = live(&mut s); + // The edge between the middle and right faces draws no stroke. + s.execute("livePaint.strokeEdge", &json!({"group": g.0, "point": [100.0, 50.0], "none": true})).unwrap(); + let v = ViewInfo::default(); + s.select_tool("livePaintSelection", v).unwrap(); + let gn = node(&s, g); + let face = |x: f64| vectorcraft_tools::builder::face_at(&gn, Point::new(x, 50.0)).unwrap().id; + let before = s.doc().unwrap().doc.clone(); + let double_click = |s: &mut Session, x: f64, m: Mods| { + for kind in [PointerKind::Down, PointerKind::Up, PointerKind::Down, PointerKind::Up, PointerKind::DoubleClick] { + s.pointer(&PointerEvent::new(kind, x, 50.0).with_mods(m), v).unwrap(); + } + let mut ids = s.doc().unwrap().selection.objects.clone(); + ids.sort(); + ids + }; + let sorted = |mut ids: Vec| { + ids.sort(); + ids + }; + assert_eq!(double_click(&mut s, 125.0, Mods::default()), sorted(vec![face(75.0), face(125.0)])); + // The stroked edge at x = 50 parts the left face from the others. + assert_eq!(double_click(&mut s, 25.0, Mods::default()), vec![face(25.0)]); + // Shift-double-click adds the faces reached to the selection. + assert_eq!(double_click(&mut s, 75.0, Mods { shift: true, ..Default::default() }), sorted(vec![face(25.0), face(75.0), face(125.0)])); + assert_eq!(s.doc().unwrap().doc, before, "selecting changes no paint"); +} + #[test] fn live_paint_selection_marquee_selects_what_it_touches() { let mut s = session(); diff --git a/crates/engine/src/tests/clipboard.rs b/crates/engine/src/tests/clipboard.rs index 6cbfd909a9..be79004be2 100644 --- a/crates/engine/src/tests/clipboard.rs +++ b/crates/engine/src/tests/clipboard.rs @@ -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}"); } } diff --git a/crates/engine/src/tests/colormgmt.rs b/crates/engine/src/tests/colormgmt.rs index 99d099f68d..2d024f552b 100644 --- a/crates/engine/src/tests/colormgmt.rs +++ b/crates/engine/src/tests/colormgmt.rs @@ -16,7 +16,10 @@ use crate::*; /// Every test here reads or changes the process-wide proof view or colour settings: one at a time. /// Tests that switch the Color Settings away from the defaults live in `tests/color_settings.rs` /// (their own process): every colour shown or rendered goes through them, so no lock could cover -/// all the tests that would see the change. +/// all the tests that would see the change. A unit test that assigns its front document a profile +/// other than the working space changes the active settings the same way until another document +/// comes to the front: it starts with `crate::tests::in_own_process`, which runs it again in a +/// child process. pub(crate) static GLOBAL: Mutex<()> = Mutex::new(()); fn session() -> Session { diff --git a/crates/engine/src/tests/draw2.rs b/crates/engine/src/tests/draw2.rs index d47ca0ccaf..e5e6174af0 100644 --- a/crates/engine/src/tests/draw2.rs +++ b/crates/engine/src/tests/draw2.rs @@ -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 { + 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 { + 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) { + 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 = (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 = (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 = { + 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 = 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 = (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 = (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(); @@ -5269,6 +5523,47 @@ fn pencil_extend_closes_within_400_pt_at_any_fidelity() { } } +/// `closed`, when given, decides whether a stroke continuing an open path closes it (the tools +/// send it by Close Paths When Ends Are Within): false leaves it open even ending on the other end; +/// true closes one ending 50 pt away with a straight segment back, the stroke's end kept, and one +/// ending 2 pt away on the other end itself. +#[test] +fn pencil_extend_closes_as_closed_says() { + let mut s = session(); + for (end, closed, ends_on_start) in [([0i32, 0], false, false), ([0, 50], true, false), ([0, 2], true, true)] { + let l = line(&mut s, 0.0, 0.0, 200.0, 0.0); + let points = json!([[200, 0], [200, 100], [100, 150], end]); + let p = json!({"points": points, "fidelity": 1.5, "closed": closed, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let pd = path(&s, l); + let sp = &pd.subpaths[0]; + assert_eq!(sp.closed, closed, "ending at {end:?}"); + if closed { + let stroke_end = Point::new(f64::from(end[0]), f64::from(end[1])); + let kept = sp.anchors.iter().any(|a| a.p.distance(stroke_end) < 1e-6); + assert_eq!(kept, !ends_on_start, "ending at {end:?}: {:?}", sp.anchors); + let on_start = sp.anchors.iter().filter(|a| a.p.distance(Point::new(0.0, 0.0)) < 1e-6).count(); + assert_eq!((sp.anchors[0].p, on_start), (Point::new(0.0, 0.0), 1), "ending at {end:?}"); + } + } +} + +/// A continued path closed from away from its other end closes with a straight segment, even when +/// that end carries a handle the open path didn't use. +#[test] +fn pencil_extend_closes_far_away_with_a_straight_segment() { + let mut s = session(); + let anchors = json!([{"x": 0, "y": 0, "in": [-40, 30]}, {"x": 200, "y": 0}]); + let l = NodeId(s.execute("path.create", &json!({"anchors": anchors})).unwrap()["id"].as_u64().unwrap()); + let points = json!([[200, 0], [200, 100], [100, 150], [0, 50]]); + let p = json!({"points": points, "fidelity": 1.5, "closed": true, "extend": {"id": l.0}, "keepSelected": false}); + s.execute("path.freehand", &p).unwrap(); + let sp = path(&s, l).subpaths[0].clone(); + assert!(sp.closed); + let (first, last) = (sp.anchors[0], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((first.h_in, last.h_out), (first.p, last.p), "the closing segment has no handles: {:?}", sp.anchors); +} + /// At Fidelity 0.01 short, one-point and repeated-point strokes still draw or are refused, never /// crash, and what they draw has finite anchors. #[test] diff --git a/crates/engine/src/tests/globaledit.rs b/crates/engine/src/tests/globaledit.rs index fa86248cdb..9a4cb7d138 100644 --- a/crates/engine/src/tests/globaledit.rs +++ b/crates/engine/src/tests/globaledit.rs @@ -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:?}"); +} diff --git a/crates/engine/src/tests/history_document.rs b/crates/engine/src/tests/history_document.rs index 3c2c9b9eb4..9e05eabeac 100644 --- a/crates/engine/src/tests/history_document.rs +++ b/crates/engine/src/tests/history_document.rs @@ -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}"); diff --git a/crates/engine/src/tests/journal.rs b/crates/engine/src/tests/journal.rs index 1c4287e9c1..700c17883d 100644 --- a/crates/engine/src/tests/journal.rs +++ b/crates/engine/src/tests/journal.rs @@ -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}"); } diff --git a/crates/engine/src/tests/menucmds.rs b/crates/engine/src/tests/menucmds.rs index abb0bf31eb..e8c4e46af3 100644 --- a/crates/engine/src/tests/menucmds.rs +++ b/crates/engine/src/tests/menucmds.rs @@ -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::>(); 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(); diff --git a/crates/engine/src/tests/newdoc.rs b/crates/engine/src/tests/newdoc.rs index 209d22a42a..094f634799 100644 --- a/crates/engine/src/tests/newdoc.rs +++ b/crates/engine/src/tests/newdoc.rs @@ -683,6 +683,9 @@ fn the_new_document_links_from_the_profile_file() { /// is a warning. #[test] fn a_profile_brings_the_color_profile_its_file_carries() { + if !super::in_own_process("tests::newdoc::a_profile_brings_the_color_profile_its_file_carries") { + return; + } use vectorcraft_color::cms; let (mut s, dir) = with_profiles("icc"); let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); diff --git a/crates/engine/src/tests/objexpand.rs b/crates/engine/src/tests/objexpand.rs index c8b51033fe..a97d414500 100644 --- a/crates/engine/src/tests/objexpand.rs +++ b/crates/engine/src/tests/objexpand.rs @@ -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] diff --git a/crates/engine/src/tests/panelcmds.rs b/crates/engine/src/tests/panelcmds.rs index 8174cf36d1..715bf2b8ce 100644 --- a/crates/engine/src/tests/panelcmds.rs +++ b/crates/engine/src/tests/panelcmds.rs @@ -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 diff --git a/crates/engine/src/tests/prefs.rs b/crates/engine/src/tests/prefs.rs index d88b8250a2..521d2eabde 100644 --- a/crates/engine/src/tests/prefs.rs +++ b/crates/engine/src/tests/prefs.rs @@ -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. /// diff --git a/crates/engine/src/tests/rasterfilters.rs b/crates/engine/src/tests/rasterfilters.rs index 236580d5b7..534f79b1bc 100644 --- a/crates/engine/src/tests/rasterfilters.rs +++ b/crates/engine/src/tests/rasterfilters.rs @@ -5,6 +5,7 @@ //! as images of the effected object. use serde_json::{Value, json}; +use vectorcraft_color::cms; use vectorcraft_doc::NodeKind; use vectorcraft_geom::Rect; use vectorcraft_render::{Rendered, Renderer}; @@ -173,16 +174,22 @@ fn each_effect_changes_the_canvas_and_draws_the_same_twice() { EFFECTS.into_iter().chain(ARTISTIC).chain(BRUSH_STROKES).chain(DISTORT).chain(PIXELATE).chain(SKETCH).chain(TEXTURE).chain(VIDEO) { let (mut s, g) = striped(); + // Every color drawn goes through the process-wide color settings. A test that changes them + // runs in a process of its own (`crate::tests::in_own_process`). + let colors = cms::active(); + assert_eq!(cms::active_settings(), cms::ColorSettings::default(), "{id}: another test left the color settings changed"); let plain = render(&s); let params: Value = serde_json::from_str(params).unwrap(); s.execute("effect.apply", &json!({"effect": id, "ids": [g], "params": params})).unwrap(); let a = render(&s); - assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); - assert_eq!(a.pixels, render(&s).pixels, "{id} is deterministic"); + let again = render(&s); // The multithreaded pipeline draws the same. let mut mt = Renderer::new(); mt.threads = 4; let b = mt.render_region(&s.doc().unwrap().doc, Rect::new(0.0, 0.0, 200.0, 200.0), 1.0, true); + assert!(std::sync::Arc::ptr_eq(&colors, &cms::active()), "{id}: another test changed the color settings while this one drew"); + assert!(mean_diff(&plain, &a) > 0.0005, "{id} shows: {}", mean_diff(&plain, &a)); + assert_eq!(a.pixels, again.pixels, "{id} is deterministic"); assert!(mean_diff(&a, &b) < 0.002, "{id}: multithreaded differs by {}", mean_diff(&a, &b)); } } diff --git a/crates/engine/src/tests/saveoptions.rs b/crates/engine/src/tests/saveoptions.rs index a5b19563a9..0f7205eaae 100644 --- a/crates/engine/src/tests/saveoptions.rs +++ b/crates/engine/src/tests/saveoptions.rs @@ -112,6 +112,9 @@ fn a_linked_pngs_bytes_are_embedded() { #[test] fn an_embedded_profile_is_restored() { + if !super::in_own_process("tests::saveoptions::an_embedded_profile_is_restored") { + return; + } let builtin = cms::profiles().into_iter().find(|p| p.kind == cms::ProfileKind::Rgb && p.builtin && p.name != cms::SRGB).unwrap(); let icc = cms::icc_bytes(&builtin.name).unwrap(); let name = format!("Studio RGB {}", std::process::id()); diff --git a/crates/engine/src/tests/textimport.rs b/crates/engine/src/tests/textimport.rs index 0749964ee6..b369346096 100644 --- a/crates/engine/src/tests/textimport.rs +++ b/crates/engine/src/tests/textimport.rs @@ -68,6 +68,49 @@ fn type_placed_on_an_artboard_edge_takes_that_artboards_frame() { } } +/// `text` as UTF-16 without a byte-order mark, little-endian when `le` is set. +fn utf16(text: &str, le: bool) -> Vec { + text.encode_utf16().flat_map(|u| if le { u.to_le_bytes() } else { u.to_be_bytes() }).collect() +} + +/// Unicode text in UTF-16 without a byte-order mark is read as UTF-16 when at least half of its +/// two-byte units hold a NUL byte, in the byte order that reads a line break, or for one line, the +/// order its NUL bytes give. Other bytes keep today's reading. +#[test] +fn utf16_without_a_byte_order_mark_places_by_its_nul_bytes_and_line_breaks() { + let mut s = session(); + for le in [true, false] { + let r = place_text(&mut s, &utf16("Hello, 世界 café!\r\nSecond line", le), json!({})); + assert_eq!(placed(&s, &r).0, "Hello, 世界 café!\nSecond line", "little-endian: {le}"); + let r = place_text(&mut s, &utf16("One line, 世界", le), json!({})); + assert_eq!(placed(&s, &r).0, "One line, 世界", "little-endian: {le}"); + } + // One line with NUL bytes in both places (U+4E00 ends in a zero byte) takes the order more of + // its units give, and a tie is little-endian. + let r = place_text(&mut s, &utf16("一AB", false), json!({})); + assert_eq!(placed(&s, &r).0, "一AB"); + let r = place_text(&mut s, &utf16("一A", true), json!({})); + assert_eq!(placed(&s, &r).0, "一A"); + // Not read as UTF-16, so read as UTF-8 or else Windows-1252 with NUL bytes dropped: an odd + // length, an unpaired surrogate, a NUL character, and line breaks in both byte orders (U+0A00). + let r = place_text(&mut s, b"A\0B", json!({})); + assert_eq!(placed(&s, &r).0, "AB"); + let r = place_text(&mut s, b"\0a\0b\xd8\0", json!({})); + assert_eq!(placed(&s, &r).0, "abØ"); + let r = place_text(&mut s, b"\0\0\xc3\xa9", json!({})); + assert_eq!(placed(&s, &r).0, "é"); + let r = place_text(&mut s, &utf16("A\u{0A00}\nB", true), json!({})); + assert_eq!(placed(&s, &r).0, "A\n\nB"); + // Fewer than half of the two-byte units hold a NUL byte: UTF-8, and Windows-1252. + let r = place_text(&mut s, "Fünf\0 Zeilen!".as_bytes(), json!({})); + assert_eq!(placed(&s, &r).0, "Fünf Zeilen!"); + let r = place_text(&mut s, b"caf\xe9\0x", json!({})); + assert_eq!(placed(&s, &r).0, "caféx"); + // ANSI reads each byte as a character of the 8-bit set; NUL and other control bytes are dropped. + let r = place_text(&mut s, &utf16("Hi, 世界", true), json!({"text": {"characterSet": "ansi"}})); + assert_eq!(placed(&s, &r).0, "Hi, NLu"); +} + #[test] fn text_files_report_and_queue_with_their_options() { let mut s = session(); diff --git a/crates/engine/src/tests/variables.rs b/crates/engine/src/tests/variables.rs index eea8ae4986..6256088842 100644 --- a/crates/engine/src/tests/variables.rs +++ b/crates/engine/src/tests/variables.rs @@ -2201,6 +2201,16 @@ fn datasets_csv_reads_utf16_files_without_a_byte_order_mark() { let e = import(&mut s, &bytes).unwrap_err().to_string(); assert!(e.contains("CSV has a header but no data rows"), "{e}"); } + // With no line break, the side of each unit that holds the NUL byte gives the byte order, and a + // tie is little-endian. + let order = crate::cmd::utf16_order_without_mark; + assert_eq!(order(&utf16("City", false)), Some(false)); + assert_eq!(order(&utf16("City", true)), Some(true)); + assert_eq!(order(b"\0aa\0"), Some(true)); + assert_eq!(order(&[utf16("City\n", false), vec![0x0A]].concat()), Some(false), "an odd last byte is left out"); + // Read big-endian, this one-line file ends in an unpaired surrogate. + let e = import(&mut s, b"\0a\0b\xd8\0").unwrap_err().to_string(); + assert!(e.contains("CSV holds NUL bytes"), "{e}"); import(&mut s, &utf16("City\tName\n一二三丁目\tKai\n", true)).unwrap(); assert_eq!(value(&s, "Data Set 1"), text_value("一二三丁目")); // Most units hold their NUL byte where a big-endian file would (U+4E00); the line breaks diff --git a/crates/engine/src/tests/xform.rs b/crates/engine/src/tests/xform.rs index f507b765c6..1a41488285 100644 --- a/crates/engine/src/tests/xform.rs +++ b/crates/engine/src/tests/xform.rs @@ -1115,6 +1115,92 @@ fn lasso_picks_gradient_mesh_points() { assert_eq!(red, top, "only the points inside the loop are recoloured"); } +/// With gradient mesh points picked, the Fill proxy (and the Color panel that edits it) shows +/// the color a solid fill gives them, not the mesh object's own fill: "?" when the picked +/// points' colors differ, the object's fill again once no point is picked. +#[test] +fn the_fill_proxy_shows_the_picked_mesh_points_color() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + let object_fill = s.proxy_paints().0; + // Round the top row of points. + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let shown = s.execute("paint.proxies", &json!({})).unwrap(); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into()), "the picked points' color: {shown}"); + assert_eq!(shown["fillMixed"], json!(false)); + assert_eq!(shown["fill"], json!(s.proxy_paints().0)); + // One picked point takes another color: the points differ. + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + let first = m.idx(0, 0); + s.execute("object.mesh.setPointColor", &json!({"id": a.0, "index": first, "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points' colors differ"); + // Picking none (the Selection tool) shows the object's fill again. + s.select_tool("selection", ViewInfo::default()).unwrap(); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + assert_eq!(s.proxy_paints().0, object_fill); + assert!(!s.proxy_mixed().0); +} + +/// With the Appearance panel's stroke row active, a solid fill still recolors the picked mesh +/// points, so the Fill proxy shows them; with its fill row active, the fill goes to that row, and +/// the proxy shows the row. +#[test] +fn the_fill_proxy_follows_where_the_fill_goes_with_an_appearance_row_active() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff8500"})).unwrap(); + s.execute("paint.setStroke", &json!({"color": "#000000"})).unwrap(); + let items = &s.doc().unwrap().doc.node(a).unwrap().appearance.items; + let fill_row = items.iter().position(|it| it.is_fill()).expect("a fill row"); + let stroke_row = items.iter().position(|it| !it.is_fill()).expect("a stroke row"); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + assert_eq!(s.fill_mesh_points().first().map(|(_, v)| v.len()), Some(3), "the top row is picked"); + let point_color = |s: &Session| { + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + m.points[m.idx(0, 0)].color.to_hex() + }; + + s.execute("appearance.setActiveItem", &json!({"index": stroke_row})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the stroke row leaves the fill on the picked points"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0); + + s.execute("appearance.setActiveItem", &json!({"index": fill_row})).unwrap(); + assert!(s.fill_mesh_points().is_empty()); + s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap(); + assert_eq!(point_color(&s), "#3366ff", "the fill row takes the fill"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#00ff00".into())); +} + +/// A gradient mesh with points picked selected with another object: a solid fill recolors the +/// points and fills the other object, and the Fill proxy and its "?" count the points' colors. +#[test] +fn the_fill_proxy_counts_picked_mesh_points_beside_other_objects() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0); + let b = rect(&mut s, 300.0, 300.0, 50.0, 50.0); + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + s.execute("object.mesh.create", &json!({"rows": 2, "cols": 2})).unwrap(); + gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default()); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]); + assert_eq!(s.fill_mesh_points().first().map(|(id, v)| (*id, v.len())), Some((a, 3)), "the top row is still picked"); + s.execute("paint.setFill", &json!({"color": "#3366ff"})).unwrap(); + let Some(NodeKind::Mesh(m)) = s.doc().unwrap().doc.node(a).map(|n| &n.kind) else { panic!("still a gradient mesh") }; + assert_eq!(m.points[m.idx(0, 0)].color.to_hex(), "#3366ff"); + assert_eq!(s.proxy_paints().0.color().map(|c| c.to_hex()), Some("#3366ff".into())); + assert!(!s.proxy_mixed().0, "the points and the other object are the same color"); + s.execute("paint.setFill", &json!({"ids": [b.0], "color": "#00ff00"})).unwrap(); + assert!(s.proxy_mixed().0, "the picked points and the other object's fill differ"); +} + /// Shift-drag a marquee with the Selection tool (#483): the selected objects it reaches are /// deselected and the others selected, the rest of the selection staying as it was. #[test] diff --git a/crates/engine/tests/it/dimensions.rs b/crates/engine/tests/it/dimensions.rs index 240a7fe280..c2d47e9d5e 100644 --- a/crates/engine/tests/it/dimensions.rs +++ b/crates/engine/tests/it/dimensions.rs @@ -1771,8 +1771,9 @@ fn both_points_snap_onto_anchors_with_a_visible_indicator() { } /// Smart Guides for the Linear picks: hovering away from any line, the first point slides into -/// line with another object's corner and its guides show before the click; the second slides onto -/// the construction guide through the first. The dimension measures between the snapped points. +/// line with another object's corner and its guides show before the click; the second slides into +/// line with the other corner, with no construction guide through the first point pulling it +/// sideways. The dimension measures between the snapped points. #[test] fn linear_picks_show_and_snap_to_smart_guides() { use vectorcraft_engine::tools::Overlay; @@ -1788,18 +1789,20 @@ fn linear_picks_show_and_snap_to_smart_guides() { assert!(o.iter().any(|o| matches!(o, Overlay::Line { .. } | Overlay::Label { .. })), "its guides show before the click: {o:?}"); ev(&mut s, PointerKind::Down, 250.0, 202.5); ev(&mut s, PointerKind::Up, 250.0, 202.5); - // Straight below the first point, 1.5 pt off: the rubber band ends on the vertical through it. - ev(&mut s, PointerKind::Move, 251.5, 260.0); + // Below the first point, 1.5 pt to its right and 2.5 pt off the bottom edge's level: the + // rubber band ends on that level, where the pointer is across it. + ev(&mut s, PointerKind::Move, 251.5, 262.5); let end = overlays(&mut s).into_iter().find_map(|o| if let Overlay::Line { b, dashed: true, .. } = o { Some(b) } else { None }).unwrap(); - assert_eq!((end.x, end.y), (250.0, 260.0), "on the construction guide"); - ev(&mut s, PointerKind::Down, 251.5, 260.0); - ev(&mut s, PointerKind::Up, 251.5, 260.0); + assert_eq!((end.x, end.y), (251.5, 260.0), "in line with the bottom edge, not on a vertical through the first point"); + ev(&mut s, PointerKind::Down, 251.5, 262.5); + ev(&mut s, PointerKind::Up, 251.5, 262.5); ev(&mut s, PointerKind::Move, 220.0, 230.0); assert!(!overlays(&mut s).iter().any(|o| matches!(o, Overlay::Label { .. })), "placing: the guides are gone"); ev(&mut s, PointerKind::Down, 220.0, 230.0); ev(&mut s, PointerKind::Up, 220.0, 230.0); let id = s.doc().unwrap().selection.objects[0].0; - assert_eq!(text(&s, id), "60.00 pt"); + // Aligned from (250, 200) to (251.5, 260): √(1.5² + 60²). + assert_eq!(text(&s, id), "60.02 pt"); } /// The page content streams of exported PDF `v`, inflated. diff --git a/crates/engine/tests/it/three_d_light_color.rs b/crates/engine/tests/it/three_d_light_color.rs index 38013a372b..381bc92f15 100644 --- a/crates/engine/tests/it/three_d_light_color.rs +++ b/crates/engine/tests/it/three_d_light_color.rs @@ -481,6 +481,151 @@ fn solid_gradient_opacity_is_applied_once_even_without_shading() { } } +/// How `image`, `d` drawn at 1:1 on 400 × 400 pixels, differs from `d` drawn at 3x and averaged +/// down, on the pixels inside the art: those whose 3x average has some alpha, the same as every +/// pixel within 3 of them. Left out too are pixels where the 3x average bends sharply (a channel's +/// second difference across the pixel, along x or y, over 4 levels), such as the rim where a cap +/// meets a body: a gradient sampled at each pixel's center matches an area average only where the +/// light changes close to linearly across the pixel. Returns (pixels measured, premultiplied RGB +/// channels more than 3 levels off, the largest RGB difference, alphas off: below 255 where the 3x +/// average is opaque, otherwise more than 3 levels off, the first few pixels off). +fn seams_against_supersampled(d: &Document, image: &vectorcraft_render::Rendered) -> (usize, usize, u8, usize, Vec) { + let fine = vectorcraft_render::Renderer::new().render( + d, + 1200, + 1200, + kurbo::Affine::scale(3.0), + &vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() }, + ); + let average = fine.area_resized(400, 400).unwrap(); + let (mut measured, mut color, mut max, mut holes) = (0, 0, 0, 0); + let mut worst = vec![]; + for y in 3..397 { + for x in 3..397 { + let q = average.pixel(x, y); + if q[3] == 0 || !(y - 3..=y + 3).all(|y| (x - 3..=x + 3).all(|x| average.pixel(x, y)[3] == q[3])) { + continue; + } + let bend = |a: [u8; 4], b: [u8; 4]| (0..3).any(|c| (i32::from(a[c]) + i32::from(b[c]) - 2 * i32::from(q[c])).abs() > 4); + if bend(average.pixel(x - 1, y), average.pixel(x + 1, y)) || bend(average.pixel(x, y - 1), average.pixel(x, y + 1)) { + continue; + } + measured += 1; + let p = image.pixel(x, y); + for c in 0..3 { + let diff = p[c].abs_diff(q[c]); + max = max.max(diff); + color += usize::from(diff > 3); + } + let hole = if q[3] == 255 { p[3] < 255 } else { p[3].abs_diff(q[3]) > 3 }; + holes += usize::from(hole); + if (p.into_iter().zip(q).any(|(a, b)| a.abs_diff(b) > 3) || hole) && worst.len() < 4 { + worst.push(format!("({x},{y}) {p:?} vs {q:?}")); + } + } + } + (measured, color, max, holes, worst) +} + +/// Colored lights on smooth 3D surfaces, compared at 1:1 with a 3x render averaged down, on every +/// pixel inside the art ([`seams_against_supersampled`]). A solid fill draws as one batched surface; +/// a source gradient composes each facet on its own, and one with transparent stops applies their +/// opacity once over the surface. Inflate seen straight on, one layer of facets, has no alpha off, and +/// in opaque fills no RGB channel more than 3 levels off. Inflate turned so facets narrow to slivers +/// at the edges, and Revolve with a cap over its body, draw facets over other facets, and have no +/// alpha off. +#[test] +fn colored_light_surfaces_match_supersampling_at_internal_facet_edges() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + let fills = |start: [u32; 2], end: [u32; 2]| { + [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + json!({"gradient":{"kind":"linear","start":start,"end":end,"stops":[ + {"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6} + ]}}), + ] + }; + let mut cases = vec![]; + for rotation in [(0, 0), (50, 35)] { + let (mut s, _) = setup("threeD.inflate"); + let change = json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights,"rotationX":rotation.0,"rotationY":rotation.1}}); + s.execute("effect.setParams", &change).unwrap(); + cases.push((format!("inflate {rotation:?}"), s, fills([100, 150], [200, 150]), rotation == (0, 0))); + } + let (mut s, _) = capped_profile(); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + cases.push(("revolve".into(), s, fills([80, 200], [240, 200]), false)); + let mut failures = vec![]; + for (name, mut s, fills, one_layer) in cases { + for fill in fills { + s.execute("paint.setFill", &fill).unwrap(); + let image = render(doc(&s)); + let (measured, color, max, holes, worst) = seams_against_supersampled(doc(&s), &image); + assert!(measured > 2_000, "{name} {fill}: {measured} pixels inside"); + let opaque = !fill.to_string().contains("opacity"); + if holes > 0 || (one_layer && opaque && color > 0) { + failures.push(format!( + "{name} {fill}: of {measured} pixels, {color} channels off by more than 3 (at most {max}), {holes} alphas off: {worst:?}" + )); + } + } + } + assert!(failures.is_empty(), "{failures:#?}"); +} + +/// The closed facet edges of colored-light surfaces survive SVG and PDF, read back and drawn at +/// 1:1, and Expand Appearance: inside the art, every pixel stays opaque and within 6 levels of the +/// live art. +#[test] +fn colored_light_seams_stay_closed_in_svg_pdf_and_expansion() { + let lights = json!([ + {"color":"#ff0000","rotation":135,"height":35,"intensity":70}, + {"color":"#0000ff","rotation":-35,"height":25,"intensity":65} + ]); + for fill in [ + json!({"color":"#ffcc66"}), + json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[ + {"offset":0,"color":"#ffffff"},{"offset":1,"color":"#808080"} + ]}}), + ] { + let (mut s, _) = setup("threeD.inflate"); + s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"ambient":10,"lights":lights}})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + let live = render(doc(&s)); + let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned(); + assert!(svg.contains("