diff --git a/Cargo.lock b/Cargo.lock index f9c3dacaa3..b42037f540 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,7 +217,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.61.2", + "windows-sys 0.52.0", "wl-clipboard-rs", "x11rb", ] @@ -986,12 +986,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7 [[package]] name = "craft-fonts-build" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1060,7 +1060,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099" +source = "git+https://github.com/storytold/craft-libs?rev=c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6#c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6" dependencies = [ "craft-fonts-build", "craft-i18n", @@ -1081,7 +1081,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "chrono", "dunce", @@ -1108,7 +1108,7 @@ dependencies = [ [[package]] name = "craft-vectorcraft-format" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b" +source = "git+https://github.com/storytold/craft-libs?rev=e98299546f5c5c3c22388d16507f8b5e61a5f280#e98299546f5c5c3c22388d16507f8b5e61a5f280" dependencies = [ "flate2", "serde", @@ -1620,7 +1620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3869,7 +3869,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4391,7 +4391,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4769,7 +4769,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4828,7 +4828,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5687,7 +5687,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.6", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7517,7 +7517,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 29f43866c2..5bed166e17 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,18 +14,18 @@ repository = "https://github.com/storytold/vectorcraft" craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] } craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c4b8405d5fd8f29cf77de528206bd2b38bb0ecb6", features = ["serde"] } craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" } # Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per # desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.) craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] } # The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON. -craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" } +craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280" } # craft-update: app-specific pretend versions apply to startup and manual checks. # Native Affinity documents (craft-libs#914). craft-affinity = { git = "https://github.com/storytold/craft-libs", rev = "228f6d0c50f1fdee4c5124603602993fc79d4c9d" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e98299546f5c5c3c22388d16507f8b5e61a5f280", features = ["egui"] } # internal vectorcraft-geom = { path = "crates/geom" } vectorcraft-color = { path = "crates/color" } diff --git a/apps/vectorcraft-cli/src/main.rs b/apps/vectorcraft-cli/src/main.rs index 57ab103590..8c4c4fffa2 100644 --- a/apps/vectorcraft-cli/src/main.rs +++ b/apps/vectorcraft-cli/src/main.rs @@ -246,6 +246,7 @@ fn with_stores(mut h: Headless) -> Headless { prefs_dir::attach_plugin_store(&mut h.session); prefs_dir::attach_pdf_preset_store(&mut h.session); prefs_dir::attach_custom_dictionary(&mut h.session); + prefs_dir::attach_arrowheads(&mut h.session); h } diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index 39f86f2973..f59d195cd5 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -487,6 +487,8 @@ fn main() -> std::process::ExitCode { prefs_dir::attach_pdf_preset_store(&mut app.session); // The custom dictionary's words, kept in their own file next to the preferences. prefs_dir::attach_custom_dictionary(&mut app.session); + // The custom arrowheads, kept in their own file next to the preferences. + prefs_dir::attach_arrowheads(&mut app.session); // Fit the window to its monitor, or put it back where it was (still hidden). let winit_window = cc.winit_window().cloned(); if let Some(w) = &winit_window { diff --git a/apps/vectorcraft/src/prefs_dir.rs b/apps/vectorcraft/src/prefs_dir.rs index 96e09f5e78..b4c0a41103 100644 --- a/apps/vectorcraft/src/prefs_dir.rs +++ b/apps/vectorcraft/src/prefs_dir.rs @@ -81,6 +81,14 @@ pub fn attach_custom_dictionary(session: &mut vectorcraft_engine::Session) { } } +/// Keep the custom arrowheads (Stroke panel menu › Edit Arrowheads…) in the `Arrowheads.vectorcraft` +/// file next to the settings, and list the heads it holds. +pub fn attach_arrowheads(session: &mut vectorcraft_engine::Session) { + if let Some(path) = store_dir(vectorcraft_engine::cmd::arrowheads::FILE_NAME) { + session.attach_arrowheads_file(path.to_string_lossy().to_string()); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/cad/src/hpgl/write.rs b/crates/cad/src/hpgl/write.rs index 3e20c69a98..6ab80c8ba0 100644 --- a/crates/cad/src/hpgl/write.rs +++ b/crates/cad/src/hpgl/write.rs @@ -187,6 +187,9 @@ pub struct HpglOutput { pub pens: Vec<[u8; 3]>, /// What was approximated or left out. pub warnings: Vec, + /// Notes on the options: sorting the paths or drawing the inner paths first was applied only + /// in part. + pub setting_notes: Vec, } /// Write the visible art of `doc` over `opts.region` as an HP-GL plot. Live geometry effects are @@ -280,10 +283,11 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R if !by_pen.iter().all(Vec::is_empty) { pen_warnings.iter().for_each(|w| warn(&mut warnings, w)); } + let mut setting_notes = vec![]; if opts.sort || opts.inside_first { let (mut at, mut work) = ((0, 0), MAX_SORT_WORK); for paths in &mut by_pen { - at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut warnings); + at = reorder(paths, at, &mut work, opts.sort, opts.inside_first, &mut setting_notes); } } if opts.version == Version::Hpgl && by_pen.iter().flatten().flatten().any(|(x, y)| !CLASSIC_RANGE.contains(x) || !CLASSIC_RANGE.contains(y)) { @@ -293,7 +297,7 @@ pub fn export_regions(doc: &Document, opts: &HpglOptions, regions: &[Rect]) -> R Version::Hpgl => plain(&by_pen), Version::Hpgl2 => encoded(&by_pen, &pens), }; - out.push(HpglOutput { bytes, pens: pens.clone(), warnings }); + out.push(HpglOutput { bytes, pens: pens.clone(), warnings, setting_notes }); } Ok(out) } @@ -366,20 +370,20 @@ fn box_dist2(p: (i64, i64), b: [i64; 4]) -> i128 { /// for a closed path any of its points (where it then starts); without, the first in paint order. /// With `inside_first`, a path is drawn only once every path lying inside it ([`nest::outer`]) is. /// Once `work` is spent, the paths left keep paint order, each after the paths inside it with -/// `inside_first`, with a warning. Returns where the pen stops. +/// `inside_first`, with a note in `notes`. Returns where the pen stops. fn reorder( paths: &mut Vec>, mut at: (i64, i64), work: &mut u64, nearest: bool, inside_first: bool, - warnings: &mut Vec, + notes: &mut Vec, ) -> (i64, i64) { let boxes: Vec<[i64; 4]> = paths.iter().map(|p| nest::bounds(p)).collect(); // The paths each path lies inside, and how many paths inside each are still to draw. let outer = inside_first.then(|| nest::outer(paths, &boxes, work)).flatten(); if inside_first && outer.is_none() { - warn(warnings, UNNESTED); + warn(notes, UNNESTED); } let mut waiting = vec![0usize; paths.len()]; for o in outer.iter().flatten().flatten() { @@ -430,11 +434,11 @@ fn reorder( if *work == 0 || best.0 >= left.len() { match &outer { Some(outer) => { - warn(warnings, UNSORTED_INSIDE); + warn(notes, UNSORTED_INSIDE); inner_first(left.into_iter().map(|(j, p, _)| (j, p)).collect(), outer, &mut waiting, paths); } None => { - warn(warnings, UNSORTED); + warn(notes, UNSORTED); paths.extend(left.into_iter().map(|(_, p, _)| p)); } } diff --git a/crates/cad/src/hpgl/write/tests.rs b/crates/cad/src/hpgl/write/tests.rs index c29ef67532..9cb4f7d727 100644 --- a/crates/cad/src/hpgl/write/tests.rs +++ b/crates/cad/src/hpgl/write/tests.rs @@ -852,7 +852,8 @@ fn sorting_measures_a_closed_path_by_its_nearest_point() { fn sorting_stops_at_its_budget_and_keeps_every_path() { let d = doc_with(|d| (0..600).map(|i| stroked(d, line((1.0 + (i % 100) as f64, 10.0), (1.0 + (i % 100) as f64, 20.0)), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { sort: true, ..opts(&d) }); - assert!(out.warnings.iter().any(|w| w == UNSORTED), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [UNSORTED]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); assert_eq!(text(&out).matches("PD").count(), 600); } @@ -944,18 +945,18 @@ fn inside_first_orders_each_pens_paths() { } /// Finding which of 100 nested rings lie inside which is past the budget: they are drawn in -/// paint order, with a warning. +/// paint order, with a note on the option. #[test] fn inside_first_past_its_budget_draws_in_paint_order() { let d = doc_with(|d| (0..100).map(|i| stroked(d, rect(1.0 + 0.4 * f64::from(i), 10.0, 199.0, 90.0), Color::BLACK)).collect()); let out = plot(&d, &HpglOptions { inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED]); + assert_eq!(out.setting_notes, [UNNESTED]); let l = lefts(&out); assert_eq!(l.len(), 100); assert!(l.windows(2).all(|w| w[0] <= w[1]), "{l:?}"); - // With `sort` too, the budget is spent before sorting starts: paint order, with both warnings. + // With `sort` too, the budget is spent before sorting starts: paint order, with both notes. let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNNESTED, UNSORTED]); + assert_eq!(out.setting_notes, [UNNESTED, UNSORTED]); assert_eq!(lefts(&out), l); } @@ -969,7 +970,7 @@ fn inside_first_past_its_sorting_budget_keeps_the_rings_last() { v }); let out = plot(&d, &HpglOptions { sort: true, inside_first: true, ..opts(&d) }); - assert_eq!(out.warnings, [UNSORTED_INSIDE]); + assert_eq!(out.setting_notes, [UNSORTED_INSIDE]); let l = lefts(&out); assert_eq!(l.len(), 602); assert_eq!(l.get(600..), Some(&[plu(0.7), plu(0.5)][..])); diff --git a/crates/cad/src/lib.rs b/crates/cad/src/lib.rs index 0ef28a4aaa..1443829806 100644 --- a/crates/cad/src/lib.rs +++ b/crates/cad/src/lib.rs @@ -410,6 +410,9 @@ pub struct DxfOutput { pub images: Vec, /// What was approximated or left out. pub warnings: Vec, + /// What was left out because an option turns it off (Include Placed Images or Include Fill + /// Hatches off). + pub setting_notes: Vec, } /// The smallest and largest `scale` (drawing units per unit). diff --git a/crates/cad/src/scene.rs b/crates/cad/src/scene.rs index 7c5d8b6755..0379fc4ea5 100644 --- a/crates/cad/src/scene.rs +++ b/crates/cad/src/scene.rs @@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> { /// Image keys → index into `images` (`None`: left out). by_key: HashMap>, warnings: Vec, + /// What was left out because an option asked for it ([`DxfOutput::setting_notes`]). + setting_notes: Vec, brushes: Option>, extents: Option<[f64; 4]>, /// The opacity of the containers around the current object. @@ -554,6 +556,7 @@ impl<'a> Scene<'a> { files: vec![], by_key: HashMap::new(), warnings: vec![], + setting_notes: vec![], brushes: None, extents: None, opacity: 1.0, @@ -588,15 +591,24 @@ impl<'a> Scene<'a> { metric: matches!(self.o.unit, Unit::Millimeters | Unit::Centimeters | Unit::Meters), next: self.next, }; - DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings } + DxfOutput { bytes: drawing.write().into_bytes(), images: self.files, warnings: self.warnings, setting_notes: self.setting_notes } } + /// Note `w`, once, about something of the document that the drawing leaves out or that reaches + /// it changed. A note about an option goes to [`Self::note_setting`]. fn warn(&mut self, w: &str) { if !self.warnings.iter().any(|x| x == w) { self.warnings.push(w.to_string()); } } + /// Note `w`, once, about something left out because an option turns it off. + fn note_setting(&mut self, w: &str) { + if !self.setting_notes.iter().any(|x| x == w) { + self.setting_notes.push(w.to_string()); + } + } + fn handle(&mut self) -> Handle { let h = Handle(self.next); self.next = self.next.saturating_add(1); @@ -930,7 +942,7 @@ impl<'a> Scene<'a> { // The outlines close as the fill does, so an open path's fill keeps its closing edge. self.lines(&closed_fill(outline), style, 0.0); if !self.o.include_hatches { - self.warn(HATCHES_OFF); + self.note_setting(HATCHES_OFF); return; } let h = self.handle(); @@ -1321,7 +1333,7 @@ impl<'a> Scene<'a> { return; } if !self.o.include_images { - self.warn(IMAGES_OFF); + self.note_setting(IMAGES_OFF); return; } let Some(i) = self.image_def(im) else { return }; diff --git a/crates/cad/src/tests.rs b/crates/cad/src/tests.rs index 9d2a519e1d..96c037a4a7 100644 --- a/crates/cad/src/tests.rs +++ b/crates/cad/src/tests.rs @@ -881,12 +881,13 @@ fn fills_without_hatches_keep_their_outlines() { let d = shapes_doc(); let out = export(&d, &DxfOptions { include_hatches: false, ..opts(&d) }).unwrap(); assert_eq!(kinds(&out), ["LWPOLYLINE", "LWPOLYLINE", "CIRCLE"], "the fill's outline, the stroke, the circle"); - assert_eq!(out.warnings, [HATCHES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [HATCHES_OFF]); let e = entities(&out); assert_eq!((get(&e[0], 70), get(&e[0], 370)), (Some("1"), None), "the closed outline, without a lineweight"); // Hatches stay by default, with no note. let on = export(&d, &opts(&d)).unwrap(); - assert!(kinds(&on).iter().any(|k| k == "HATCH") && !on.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(kinds(&on).iter().any(|k| k == "HATCH") && on.setting_notes.is_empty()); // The outlines are the ones written beside hatches, also for overlapping subpaths, which are // merged first; a fill with no area (a flat rectangle, a diagonal line) writes nothing either way. let compound = doc_with(|d| { @@ -916,13 +917,14 @@ fn fills_without_hatches_keep_their_outlines() { assert!(!kinds(&altered).iter().any(|k| k == "HATCH") && !kinds(&altered).is_empty(), "{:?}", kinds(&altered)); // Nothing filled: no note. let lines = doc_with(|d| vec![path(d, shapes::ellipse(Rect::new(120.0, 20.0, 180.0, 80.0)), Paint::None, Paint::solid(Color::BLACK), 1.0)]); - assert!(export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap().warnings.is_empty()); + let none = export(&lines, &DxfOptions { include_hatches: false, ..opts(&lines) }).unwrap(); + assert!(none.warnings.is_empty() && none.setting_notes.is_empty()); // R12 has no hatches anyway: the same outlines, and its own note alone. let r12 = |include_hatches| export(&d, &DxfOptions { version: DxfVersion::R12, include_hatches, ..opts(&d) }).unwrap(); let (off, on) = (r12(false), r12(true)); assert_eq!(off.bytes, on.bytes); assert_eq!(off.warnings, on.warnings); - assert!(!off.warnings.iter().any(|w| w == HATCHES_OFF)); + assert!(off.setting_notes.is_empty()); } #[test] @@ -957,15 +959,18 @@ fn images_can_be_left_out_with_their_files() { let p = pairs(&out.bytes); assert!(!section(&p, "OBJECTS").iter().any(|o| matches!(kind(o), "IMAGEDEF" | "IMAGEDEF_REACTOR" | "RASTERVARIABLES"))); assert!(!section(&p, "CLASSES").iter().any(|o| get(o, 1) == Some("IMAGE"))); - assert_eq!(out.warnings, [IMAGES_OFF]); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + assert_eq!(out.setting_notes, [IMAGES_OFF]); // An image that can't be decoded is not read when images are left out. let mut broken = d.clone(); broken.images.insert("raster-1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(b"not a png".to_vec()), proxy: None }); - assert_eq!(export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap().warnings, [IMAGES_OFF]); + let left_out = export(&broken, &DxfOptions { include_images: false, ..opts(&broken) }).unwrap(); + assert!(left_out.warnings.is_empty(), "{:?}", left_out.warnings); + assert_eq!(left_out.setting_notes, [IMAGES_OFF]); assert!(export(&broken, &opts(&broken)).unwrap().warnings.iter().any(|w| w.contains("couldn't be decoded"))); // R12 has no images anyway: its own note alone. let r12 = export(&d, &DxfOptions { version: DxfVersion::R12, include_images: false, ..opts(&d) }).unwrap(); - assert!(r12.warnings.iter().any(|w| w.contains("no images")) && !r12.warnings.iter().any(|w| w == IMAGES_OFF)); + assert!(r12.warnings.iter().any(|w| w.contains("no images")) && r12.setting_notes.is_empty()); } #[test] @@ -983,7 +988,7 @@ fn images_and_hatches_are_left_out_independently_and_the_cnc_preset_leaves_out_b let cnc = run(DxfPreset::Cnc.options()); assert_eq!(kinds(&cnc), ["LWPOLYLINE"]); assert!(cnc.images.is_empty()); - assert!(cnc.warnings.iter().any(|w| w == IMAGES_OFF) && cnc.warnings.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.warnings); + assert!(cnc.setting_notes.iter().any(|w| w == IMAGES_OFF) && cnc.setting_notes.iter().any(|w| w == HATCHES_OFF), "{:?}", cnc.setting_notes); let custom = DxfPreset::Custom.options(); assert!(custom.include_images && custom.include_hatches, "Custom keeps both"); } diff --git a/crates/color/src/gradient/spread.rs b/crates/color/src/gradient/spread.rs index e449151d89..8f9933c40c 100644 --- a/crates/color/src/gradient/spread.rs +++ b/crates/color/src/gradient/spread.rs @@ -4,7 +4,8 @@ use kurbo::{Point, Rect, Vec2}; -use super::{Gradient, GradientGeom, GradientKind, GradientSpread, GradientStop}; +use super::{Gradient, GradientGeom, GradientKind, GradientMethod, GradientSpread, GradientStop}; +use crate::Color; /// Most runs of the stops [`Gradient::padded_over`] writes. pub const MAX_SPREAD_RUNS: usize = 256; @@ -33,8 +34,8 @@ pub struct Padded { pub first: f64, /// How many runs are written, evenly spaced from [`Self::first`] to 1. pub runs: usize, - /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or than [`MAX_SPREAD_STOPS`] allows), - /// or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. + /// The area needed more runs than [`MAX_SPREAD_RUNS`] (or more than half the stops allowed, + /// two to a run), or runs past [`MAX_SPREAD_LEVEL`]: the gradient pads past the runs written. pub capped: bool, } @@ -50,7 +51,9 @@ impl Gradient { self.padded_within(geom, area, MAX_SPREAD_STOPS) } - /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). + /// [`Self::padded_over`] with at most `max_stops` stops (and at least one run). When the runs + /// written need more stops than that, each run leaves out the stops that change its colors + /// least ([`fit_run`]), so every run is still written. pub fn padded_within(&self, geom: &GradientGeom, area: Rect, max_stops: usize) -> Option { let kind = self.kind; if !self.is_spread() || matches!(kind, GradientKind::Conic | GradientKind::Freeform) || self.stops.is_empty() { @@ -68,8 +71,8 @@ impl Gradient { // smallest 0 when the area holds the rings' center, else on its edge. let lo = if centered { inner_level(&period, kind, area).floor() } else { ts.iter().copied().fold(f64::INFINITY, f64::min).floor() }; let hi = ts.iter().copied().fold(f64::NEG_INFINITY, f64::max).ceil().max(lo + 1.0); - let one = whole_run(&self.stops); - let most = MAX_SPREAD_RUNS.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let most = MAX_SPREAD_RUNS.min(budget / 2).max(1); let capped = hi - lo > most as f64; // Capped, the runs kept are the area's innermost ones (radial and diamond), or (linear) // those around the vector's start where the area reaches it, else the area's runs nearest @@ -79,6 +82,7 @@ impl Gradient { _ => lo, }; let n = (hi - lo).min(most as f64).max(1.0); + let one = fit_run(whole_run(&self.stops), budget / n as usize, self.method); // A radial or diamond writes its runs among the levels from 0, which its stops' offsets // (single-precision fractions of the outermost level) tell apart only so far out, the // nearer the closer its stops lie: past that, the runs end there and the gradient pads. @@ -112,8 +116,9 @@ impl Gradient { self.unrolled_within(MAX_SPREAD_STOPS) } - /// [`Self::unrolled`] with at most `max_stops` stops (and at least one run): past the runs - /// that fit, the last one pads. + /// [`Self::unrolled`] with at most `max_stops` stops: every run, each run without the stops + /// that change its colors least ([`fit_run`]) when the runs need more stops than that. Only with + /// fewer than two stops for each run do the runs past those that fit pad with the last one. pub fn unrolled_within(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { use std::borrow::Cow; if !self.is_spread() { @@ -123,13 +128,29 @@ impl Gradient { if self.stops.is_empty() { return Cow::Owned(Gradient { spread: GradientSpread::Pad, repeats: 1, ..self.clone() }); } - let one = whole_run(&self.stops); let n = self.repeat_count() as usize; - let kept = n.min(max_stops.min(MAX_SPREAD_STOPS) / one.len().max(1)).max(1); + let budget = max_stops.min(MAX_SPREAD_STOPS); + let kept = n.min(budget / 2).max(1); + let one = fit_run(whole_run(&self.stops), budget / kept, self.method); let stops = self.runs(&one, false, 0.0, kept, n as f64); Cow::Owned(Gradient { stops, spread: GradientSpread::Pad, repeats: 1, ..self.clone() }) } + /// The gradient as [`Self::unrolled`] gives it with at most `max_stops` stops (at least two): + /// a gradient that repeats or reflects as [`Self::unrolled_within`] gives it, and one that + /// pads with more stops than that without the stops that change its colors least + /// ([`fit_run`]). Others as they are. + pub fn fitted(&self, max_stops: usize) -> std::borrow::Cow<'_, Gradient> { + use std::borrow::Cow; + if self.is_spread() { + return self.unrolled_within(max_stops); + } + if self.stops.len() <= max_stops.max(2) { + return Cow::Borrowed(self); + } + Cow::Owned(Gradient { stops: fit_run(whole_run(&self.stops), max_stops, self.method), ..self.clone() }) + } + /// One cycle of a gradient that repeats or reflects, which repeated end to end paints it, as /// a gradient that pads: one run of the stops (Repeat), or a run and its mirror (Reflect), /// between 0 and 1, and how many runs that is (1 or 2). `None` for a gradient that pads and @@ -257,6 +278,91 @@ fn whole_run(stops: &[GradientStop]) -> Vec { out } +/// `one` (a run of a gradient's stops, [`whole_run`]) with at most `budget` stops (at least two): +/// as it is when it fits, else without the stops that change its colors least, left out one at a +/// time. Leaving out a stop costs how far its color lies from the one that mixing the stops still +/// on either side of it by `method` gives there, and how far its CMYK inks, opacity, tint or +/// process plates lie from the line between theirs (a whole unit when its swatch link or color +/// model differs from either of theirs, and a whole unit more when its midpoint or the one before +/// it is off center), so the ends, sharp steps, ink changes and shaped spans stay. A stop whose +/// next stop was left out gets its midpoint centered. The work is the stops times their logarithm. +fn fit_run(one: Vec, budget: usize, method: GradientMethod) -> Vec { + use std::cmp::Reverse; + use std::collections::BinaryHeap; + let budget = budget.max(2); + let n = one.len(); + if n <= budget { + return one; + } + let mix = |x: &Color, y: &Color, v: f32| match method { + GradientMethod::Classic => x.lerp_keep_model(y, v), + m => m.mix(x, y, v), + }; + let cost = |a: &GradientStop, s: &GradientStop, b: &GradientStop| -> u32 { + let span = b.offset - a.offset; + let u = if span > 0.0 { ((s.offset - a.offset) / span).clamp(0.0, 1.0) } else { 0.0 }; + // Left out, the span from `a` to `b` has a centered midpoint, so it mixes them by `u`. + let (want, cs) = (mix(&a.color, &b.color, u).to_rgb(), s.color.to_rgb()); + let color = (0..3).map(|k| (want[k] - cs[k]).abs()).fold(0.0, f32::max); + let opacity = (a.opacity + (b.opacity - a.opacity) * u - s.opacity).abs(); + // Process inks, which colors that look alike can print differently: off their line, or a + // change of color model. + let process = match (&a.color, &s.color, &b.color) { + (Color::Cmyk { c: c0, m: m0, y: y0, k: k0 }, Color::Cmyk { c, m, y, k }, Color::Cmyk { c: c1, m: m1, y: y1, k: k1 }) => { + [(c0, c, c1), (m0, m, m1), (y0, y, y1), (k0, k, k1)].iter().map(|(p, q, r)| (*p + (*r - *p) * u - *q).abs()).fold(0.0, f32::max) + } + _ if a.color.model() == s.color.model() && s.color.model() == b.color.model() => 0.0, + _ => 1.0, + }; + let color = color.max(process); + // Separations: a change of swatch link, or of tint or process plates off their line. + let ink = if s.swatch != a.swatch || s.swatch != b.swatch { + 1.0 + } else { + let tint = (a.tint + (b.tint - a.tint) * u - s.tint).abs(); + let plates = match (a.plates, s.plates, b.plates) { + (None, None, None) => 0.0, + (Some(pa), Some(ps), Some(pb)) => (0..4).map(|k| (pa[k] + (pb[k] - pa[k]) * u - ps[k]).abs()).fold(0.0, f32::max), + _ => 1.0, + }; + tint.max(plates) + }; + // Leaving it out merges two spans, and an off-center midpoint shapes one of them. + let shaped = if [a.midpoint, s.midpoint].iter().any(|m| (m - 0.5).abs() > 1e-3) { 1.0 } else { 0.0 }; + // Non-negative, so the bits order as the numbers do (NaN counts as 0). + (color.max(opacity).max(ink) + shaped).max(0.0).to_bits() + }; + // The stops still kept on either side of each stop, and each stop's latest cost entry. + let mut prev: Vec = (0..n).map(|i| i.saturating_sub(1)).collect(); + let mut next: Vec = (1..=n).collect(); + let mut gone = vec![false; n]; + let mut version = vec![0u32; n]; + let mut heap: BinaryHeap> = (1..n - 1).map(|i| Reverse((cost(&one[i - 1], &one[i], &one[i + 1]), i, 0))).collect(); + let mut left = n; + while left > budget { + let Some(Reverse((_, i, v))) = heap.pop() else { break }; + if gone[i] || v != version[i] { + continue; + } + gone[i] = true; + left -= 1; + let (a, b) = (prev[i], next[i]); + next[a] = b; + prev[b] = a; + for j in [a, b] { + if j != 0 && j != n - 1 { + version[j] += 1; + heap.push(Reverse((cost(&one[prev[j]], &one[j], &one[next[j]]), j, version[j]))); + } + } + } + one.into_iter() + .enumerate() + .filter(|(i, _)| !gone[*i]) + .map(|(i, s)| if next[i] == i + 1 { s } else { GradientStop { midpoint: 0.5, ..s } }) + .collect() +} + /// A run mirrored: the stops in reverse, each midpoint measured from the other side. fn mirror(run: &[GradientStop]) -> Vec { let mut g = Gradient::new(GradientKind::Linear, run.to_vec()); diff --git a/crates/color/src/gradient/spread/tests.rs b/crates/color/src/gradient/spread/tests.rs index 2465039ca5..a88319ccaa 100644 --- a/crates/color/src/gradient/spread/tests.rs +++ b/crates/color/src/gradient/spread/tests.rs @@ -148,11 +148,12 @@ fn radial_and_diamond_gradients_pad_in_rings_from_their_first_level() { } } -/// A run of the stops far too short for the area, or a gradient of many stops, is written with at -/// most so many runs and stops, and pads past them; a conic, a padding gradient and one without -/// stops have nothing to pad. +/// A run of the stops far too short for the area is written with at most so many runs, and pads +/// past them; a gradient of many stops is written with every run the area needs, each run +/// without the stops that change its colors least, within so many stops; a conic, a padding +/// gradient and one without stops have nothing to pad. #[test] -fn far_repeats_and_many_stops_are_capped() { +fn far_repeats_are_capped_and_many_stops_are_sampled() { let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(0.01, 0.0), aspect: 1.0, focal: None }; let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, black_white()) }; let p = g.padded_over(&geom, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); @@ -162,7 +163,13 @@ fn far_repeats_and_many_stops_are_capped() { let g = Gradient { spread: GradientSpread::Reflect, repeats: 50, ..Gradient::new(GradientKind::Linear, many) }; let wide = GradientGeom { end: Point::new(100.0, 0.0), ..geom }; let p = g.padded_over(&wide, Rect::new(0.0, 0.0, 100.0, 1.0)).unwrap(); - assert!(p.capped && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{}", p.gradient.stops.len()); + assert!(!p.capped && p.runs == 50 && p.gradient.stops.len() <= MAX_SPREAD_STOPS, "{} {}", p.runs, p.gradient.stops.len()); + for x in [0.7, 33.3, 66.1, 99.5] { + let q = Point::new(x, 0.5); + let want = g.sample(g.spread_param(wide.param_at(GradientKind::Linear, q)) as f32).0.to_rgb(); + let got = p.gradient.sample(p.geom.param_at(GradientKind::Linear, q) as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{x}: {want:?} vs {got:?}"); + } assert!(g.unrolled().stops.len() <= MAX_SPREAD_STOPS); assert!(p.gradient.padded_over(&p.geom, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none(), "it pads"); assert!(spread(GradientKind::Conic, GradientSpread::Repeat, 3).padded_over(&wide, Rect::new(0.0, 0.0, 1.0, 1.0)).is_none()); @@ -325,6 +332,111 @@ fn unrolled_and_cycle_stops() { assert!(matches!(Gradient::default().unrolled(), std::borrow::Cow::Borrowed(_))); } +/// A gradient of more stops than [`MAX_SPREAD_STOPS`] allows for all its runs keeps every run +/// when unrolled, each run without the stops that change its colors least: its last run ends on +/// its last color instead of the rest padding with it. +#[test] +fn unrolled_many_stops_keep_every_run() { + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: MAX_REPEATS, ..Gradient::new(GradientKind::Linear, stops) }; + let u = g.unrolled(); + assert!(u.stops.len() <= MAX_SPREAD_STOPS, "{}", u.stops.len()); + for t in [0.005, 0.5051, 0.9149, 0.9951] { + let want = g.sample(g.spread_param(t) as f32).0.to_rgb(); + let got = u.sample(t as f32).0.to_rgb(); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 2e-3), "{t}: {want:?} vs {got:?}"); + } + // A padding gradient fitted to fewer stops keeps its ends. + let pad = Gradient::new(GradientKind::Linear, g.stops.clone()); + let fitted = pad.fitted(16); + assert_eq!(fitted.stops.len(), 16); + for (stop, want) in [(&fitted.stops[0], Color::gray(0.0)), (&fitted.stops[15], Color::gray(1.0))] { + assert!(stop.color.to_rgb().iter().zip(want.to_rgb()).all(|(a, b)| (a - b).abs() < 1e-5), "{:?}", stop.color); + } + assert!(matches!(pad.fitted(500), std::borrow::Cow::Borrowed(_))); +} + +/// One run of more stops than the limit (255 inside it, and a copy of each end color at 0 and 1) +/// is written within the limit, every run the area needs included. +#[test] +fn a_run_over_the_stop_limit_is_written_within_it() { + let stops: Vec = (1..=255).map(|i| GradientStop::new(i as f32 / 256.0, Color::gray(i as f32 / 256.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 2, ..Gradient::new(GradientKind::Linear, stops) }; + let geom = GradientGeom { start: Point::new(0.0, 0.0), end: Point::new(20.0, 0.0), aspect: 1.0, focal: None }; + let p = g.padded_within(&geom, Rect::new(0.0, 0.0, 20.0, 1.0), 256).unwrap(); + assert!(!p.capped && p.runs == 2 && p.gradient.stops.len() <= 256, "{} {} {}", p.capped, p.runs, p.gradient.stops.len()); + assert!(g.unrolled_within(256).stops.len() <= 256); +} + +/// Fitting a run to fewer stops keeps a narrow band between two sharp steps, a stop linked to a +/// swatch, and each kept stop's link and tint, where sampling at evenly spaced places would lose +/// them. +#[test] +fn fitting_keeps_sharp_steps_and_swatch_links() { + let mut stops: Vec = (0..300).map(|i| GradientStop::new(i as f32 / 299.0, Color::gray(i as f32 / 299.0))).collect(); + let red = Color::rgb(1.0, 0.0, 0.0); + // A band of red from 0.5003 to 0.5013, with a sharp step at each side. + for (offset, color) in [(0.5003, None), (0.5003, Some(red)), (0.5013, Some(red)), (0.5013, None)] { + let gray = Color::gray(offset); + stops.push(GradientStop::new(offset, color.unwrap_or(gray))); + } + let spot = GradientStop { swatch: Some("Ink".into()), tint: 0.4, ..GradientStop::new(0.25, Color::gray(0.25)) }; + stops.push(spot.clone()); + // Three stops of one ink that look alike but print at different tints. + for (offset, tint) in [(0.8, 0.2), (0.81, 0.9), (0.82, 0.2)] { + stops.push(GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(offset)) }); + } + // A span shaped by an off-center midpoint. + if let Some(s) = stops.iter_mut().find(|s| (s.offset - 0.6).abs() < 2e-3) { + s.midpoint = 0.13; + } + stops.sort_by(|a, b| a.offset.total_cmp(&b.offset)); + let g = Gradient::new(GradientKind::Linear, stops); + let fitted = g.fitted(32); + assert!(fitted.stops.len() <= 32, "{}", fitted.stops.len()); + let red_at = |t: f32| fitted.sample(t).0.to_rgb(); + assert_eq!(red_at(0.5008), [1.0, 0.0, 0.0], "the band stays"); + assert!(red_at(0.499)[1] > 0.4 && red_at(0.503)[1] > 0.4, "the band stays narrow"); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink") && s.tint == 0.4 && s.offset == 0.25), "{:?}", fitted.stops); + assert!(fitted.stops.iter().any(|s| s.swatch.as_deref() == Some("Ink2") && s.tint == 0.9), "the tint change stays"); + assert!(fitted.stops.iter().any(|s| s.midpoint == 0.13), "the shaped span stays"); + // Of a stop that changes only its ink's tint and one that the colors' line nearly gives, the + // first stays. (The stop before an ink starts stays as well: leaving it out would blend the + // ink in sooner.) + let ink = |offset: f32, tint: f32| GradientStop { swatch: Some("Ink2".into()), tint, ..GradientStop::new(offset, Color::gray(0.5)) }; + let few = vec![ + GradientStop::new(0.0, Color::gray(0.0)), + GradientStop::new(0.2, Color::gray(0.17)), + GradientStop::new(0.3, Color::gray(0.3)), + ink(0.4, 0.2), + ink(0.5, 0.9), + ink(0.6, 0.2), + GradientStop::new(1.0, Color::gray(1.0)), + ]; + let fitted = Gradient::new(GradientKind::Linear, few).fitted(6).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.3, 0.4, 0.5, 0.6, 1.0]); + // A Perceptual gradient leaves out the stop its own mixing gives, not the one the RGB line + // gives. + let (blue, yellow, gray) = (Color::rgb(0.0, 0.0, 1.0), Color::rgb(1.0, 1.0, 0.0), Color::rgb(0.5, 0.5, 0.5)); + let on_curve = GradientMethod::Perceptual.mix(&gray, &yellow, 0.5); + let stops = vec![GradientStop::new(0.0, blue), GradientStop::new(0.5, gray), GradientStop::new(0.75, on_curve), GradientStop::new(1.0, yellow)]; + let perceptual = Gradient { method: GradientMethod::Perceptual, ..Gradient::new(GradientKind::Linear, stops) }; + let fitted = perceptual.fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + // A black of other inks between blacks that look the same prints differently: it stays. + let k = |offset: f32| GradientStop::new(offset, Color::Cmyk { c: 1.0, m: 1.0, y: 1.0, k: 1.0 }); + let rich = GradientStop::new(0.5, Color::Cmyk { c: 0.6, m: 0.4, y: 0.4, k: 1.0 }); + let blacks = vec![k(0.0), k(0.4), rich, k(0.6), k(1.0)]; + let fitted = Gradient::new(GradientKind::Linear, blacks).fitted(3).into_owned(); + let offsets: Vec = fitted.stops.iter().map(|s| s.offset).collect(); + assert_eq!(offsets, [0.0, 0.5, 1.0]); + let spread = Gradient { spread: GradientSpread::Repeat, repeats: 4, ..g.clone() }; + let u = spread.unrolled_within(128); + assert_eq!(u.stops.iter().filter(|s| s.swatch.as_deref() == Some("Ink")).count(), 4, "one linked stop a run"); +} + /// Pixels (what the canvas draws a diamond, dithered or Perceptual gradient as, and what SVG and /// PDF write as images) repeat with the stops, for each kind. #[test] diff --git a/crates/doc/src/appearance.rs b/crates/doc/src/appearance.rs index b1aa9727ba..ba0f5fc013 100644 --- a/crates/doc/src/appearance.rs +++ b/crates/doc/src/appearance.rs @@ -10,7 +10,7 @@ mod stroke_style; pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds}; pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile}; -pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign}; +pub use stroke_style::{ArrowAlign, Arrowhead, CustomArrowhead, Dash, LineCap, LineJoin, StrokeAlign}; /// One entry of the appearance stack. // A stroke takes 328 bytes and a fill 104; an appearance holds a few of each. diff --git a/crates/doc/src/appearance/layers.rs b/crates/doc/src/appearance/layers.rs index 923275999b..f8a58758d8 100644 --- a/crates/doc/src/appearance/layers.rs +++ b/crates/doc/src/appearance/layers.rs @@ -193,8 +193,10 @@ impl StrokeLayer { /// How far the start (`end == false`) or end arrowhead can reach from its end point (0 /// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap. pub fn head_reach(&self, end: bool) -> f64 { - let head = if end { self.end_arrow } else { self.start_arrow }; - head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0) + let head = if end { &self.end_arrow } else { &self.start_arrow }; + // A custom head can paint in front of its tip, which Extend puts past the end point. + let reach = |k: &Arrowhead| if self.arrow_align == ArrowAlign::Extend { k.reach().max(k.length()) } else { k.reach() }; + head.as_ref().map_or(0.0, |k| reach(k) * self.arrow_weight(end) + self.width / 2.0) } /// How far the arrowheads can reach from the path's end points (0 without heads). pub fn arrow_reach(&self) -> f64 { diff --git a/crates/doc/src/appearance/stroke_style.rs b/crates/doc/src/appearance/stroke_style.rs index 46a0c30c33..18b226c676 100644 --- a/crates/doc/src/appearance/stroke_style.rs +++ b/crates/doc/src/appearance/stroke_style.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use vectorcraft_geom::{PathData, Rect}; + use super::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -53,8 +57,74 @@ impl Dash { /// (the open arrow's arms are cut square a little behind the box). const HEAD_REACH: f64 = 4.6; +/// An arrowhead the user drew (Stroke panel menu › Edit Arrowheads…), carried with its art so a +/// document that uses it draws it wherever it opens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(from = "CustomArrowheadData")] +pub struct CustomArrowhead { + /// The name the arrowhead menus show (the symbol's name in the arrowheads file). + pub name: String, + /// The filled outline (non-zero rule) in head units: x from the tip (0) back along the line, y + /// across it, positive to the right of the direction the head points. A head is 4 units wide, + /// as the built-in heads are, and a unit is the head's weight. + pub path: PathData, +} + +#[derive(Deserialize)] +struct CustomArrowheadData { + name: String, + #[serde(default)] + path: PathData, +} + +impl From for CustomArrowhead { + fn from(d: CustomArrowheadData) -> Self { + CustomArrowhead::new(d.name, d.path) + } +} + +impl CustomArrowhead { + /// The most anchors a custom head's outline may have, and the most subpaths. + pub const MAX_ANCHORS: usize = 4096; + /// How far from the tip a custom head's outline may reach, in head units. + pub const MAX_UNITS: f64 = 64.0; + + /// Head `name` with outline `path` (in head units). An outline with more than + /// [`Self::MAX_ANCHORS`] anchors or subpaths, a point that is not finite, or a point farther + /// than [`Self::MAX_UNITS`] from the tip is dropped: the head then draws nothing. + pub fn new(name: String, path: PathData) -> Self { + let path = if Self::fits(&path) { path } else { PathData::default() }; + CustomArrowhead { name, path } + } + /// Whether outline `path` has at most [`Self::MAX_ANCHORS`] anchors and as many subpaths, with + /// finite points within [`Self::MAX_UNITS`] only. + pub fn fits(path: &PathData) -> bool { + let anchors = path.subpaths.iter().map(|s| s.anchors.len()).sum::(); + let near = |p: vectorcraft_geom::Point| p.x.is_finite() && p.y.is_finite() && p.x.abs() <= Self::MAX_UNITS && p.y.abs() <= Self::MAX_UNITS; + anchors <= Self::MAX_ANCHORS + && path.subpaths.len() <= Self::MAX_ANCHORS + && path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| near(a.p) && near(a.h_in) && near(a.h_out)) + } + fn bounds(&self) -> Option { + self.path.control_bounds() + } + /// How far any part of the head can be from its tip, in head units. + pub fn reach(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x0.hypot(b.y0).max(b.x1.hypot(b.y0)).max(b.x0.hypot(b.y1)).max(b.x1.hypot(b.y1))) + } + /// How far the head spreads either side of its line, in head units. + pub fn half_width(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.y0.abs().max(b.y1.abs())) + } + /// Where the stroke ends under the head, in head units back from the tip: halfway along the + /// head's length. + pub fn stop(&self) -> f64 { + self.bounds().map_or(0.0, |b| b.x1.max(0.0) / 2.0) + } +} + /// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum Arrowhead { Triangle, TriangleOpen, @@ -102,6 +172,8 @@ pub enum Arrowhead { /// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as /// the other heads. LineArrow, + /// A head the user drew, with its art. + Custom(Arc), } impl Arrowhead { @@ -151,23 +223,43 @@ impl Arrowhead { ]; /// How far any part of the head can be from its tip, in units of its weight. - pub fn reach(self) -> f64 { + pub fn reach(&self) -> f64 { match self { // Its farthest corners are 4 + ½√2 back and 4 − ½√2 out. Arrowhead::LineArrow => 5.75, + Arrowhead::Custom(c) => c.reach(), _ => HEAD_REACH, } } /// How far the head spreads either side of its line, in units of its weight. - pub fn half_width(self) -> f64 { + pub fn half_width(&self) -> f64 { match self { Arrowhead::LineArrow => 4.0, + Arrowhead::Custom(c) => c.half_width(), _ => 2.0, } } + /// How far past its end point a head placed with [`ArrowAlign::Extend`] can paint, in units of + /// its weight: the built-in heads' length, or a custom head's reach past its tip, which sits + /// [`CustomArrowhead::stop`] past the end point. + pub fn length(&self) -> f64 { + match self { + Arrowhead::Custom(c) => c.stop() + c.reach(), + _ => 4.0, + } + } - /// The name the arrowhead menus show. - pub fn label(self) -> &'static str { + /// The name the arrowhead menus show: [`Self::label`], or a custom head's own name. + pub fn name(&self) -> &str { + match self { + Arrowhead::Custom(c) => &c.name, + _ => self.label(), + } + } + + /// The name the arrowhead menus show for a built-in head ("Custom" for a custom one, whose + /// own name is [`Self::name`]). + pub fn label(&self) -> &'static str { match self { Arrowhead::Triangle => "Triangle", Arrowhead::TriangleOpen => "Triangle (open)", @@ -210,6 +302,7 @@ impl Arrowhead { Arrowhead::TriangleBar => "Triangle to Bar", Arrowhead::Oval => "Oval", Arrowhead::LineArrow => "Line Arrow", + Arrowhead::Custom(_) => "Custom", } } } diff --git a/crates/doc/src/appearance/tests.rs b/crates/doc/src/appearance/tests.rs index aa2ba0100c..22e5659051 100644 --- a/crates/doc/src/appearance/tests.rs +++ b/crates/doc/src/appearance/tests.rs @@ -337,13 +337,65 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() { ]; for name in SAVED { let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap(); - assert_eq!(serde_json::to_value(a).unwrap(), name); + assert_eq!(serde_json::to_value(&a).unwrap(), name); assert_eq!(Arrowhead::ALL.iter().filter(|x| **x == a).count(), 1, "{name} in the menu once"); } let labels: std::collections::HashSet<&str> = Arrowhead::ALL.iter().map(|a| a.label()).collect(); assert_eq!(labels.len(), Arrowhead::ALL.len(), "labels are distinct"); } +/// A custom head: a triangle from its tip back 3 units, 2 units right and 1 left of its line. +fn custom_head() -> Arrowhead { + use vectorcraft_geom::{PathData, Point, SubPath}; + let path = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), path))) +} + +#[test] +fn custom_arrowheads_carry_their_art_and_measure_it() { + let head = custom_head(); + assert_eq!((head.name(), head.label()), ("Wedge", "Custom")); + assert_eq!(head.reach(), 3.0f64.hypot(2.0)); + assert_eq!(head.half_width(), 2.0); + let Arrowhead::Custom(c) = &head else { panic!("custom") }; + assert_eq!(c.stop(), 1.5, "the line stops halfway along the head"); + // Saved with the stroke, art and all, beside the built-in heads' names. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.end_arrow) = (Some(Arrowhead::Triangle), Some(head.clone())); + let json = serde_json::to_value(&st).unwrap(); + assert_eq!(json["start_arrow"], "Triangle"); + assert_eq!(json["end_arrow"]["Custom"]["name"], "Wedge"); + let back: StrokeLayer = serde_json::from_value(json).unwrap(); + assert_eq!(back, st); + // Extended, the tip sits the stop past the end point; on it, only the reach counts. + assert_eq!(head.length(), 1.5 + head.reach()); + assert_eq!(st.head_reach(true), head.length() * 2.0 + 1.0); + st.arrow_align = ArrowAlign::Tip; + assert_eq!(st.head_reach(true), head.reach() * 2.0 + 1.0); +} + +#[test] +fn custom_arrowheads_out_of_bounds_draw_nothing() { + let Arrowhead::Custom(c) = custom_head() else { panic!("custom") }; + let mut path = c.path.clone(); + path.subpaths[0].anchors[1] = vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1e9, 0.0)); + // A file can hold what `new` drops: reading it drops it too. + let json = serde_json::to_value(CustomArrowhead { name: "Wedge".into(), path }).unwrap(); + let far: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert_eq!((far.name.as_str(), far.path.subpaths.len()), ("Wedge", 0)); + assert_eq!((far.reach(), far.half_width(), far.stop()), (0.0, 0.0, 0.0)); + let anchors = vec![vectorcraft_geom::Anchor::corner(vectorcraft_geom::Point::new(1.0, 0.0)); CustomArrowhead::MAX_ANCHORS + 1]; + let many = CustomArrowhead::new("Many".into(), vectorcraft_geom::PathData::single(vectorcraft_geom::SubPath::new(anchors, true))); + assert!(many.path.subpaths.is_empty(), "too many anchors"); + // Empty subpaths add no anchors, and still count. + let mut hollow = c.path.clone(); + hollow.subpaths.extend(std::iter::repeat_n(vectorcraft_geom::SubPath::new(vec![], false), CustomArrowhead::MAX_ANCHORS)); + assert!(!CustomArrowhead::fits(&hollow), "too many subpaths"); + let json = serde_json::to_value(CustomArrowhead { name: "Hollow".into(), path: hollow }).unwrap(); + let hollow: CustomArrowhead = serde_json::from_value(json).unwrap(); + assert!(hollow.path.subpaths.is_empty(), "too many subpaths, read back"); +} + #[test] fn arrow_align_defaults_to_extend_and_round_trips() { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); diff --git a/crates/doc/src/blend.rs b/crates/doc/src/blend.rs index ebd4811959..d9e179286f 100644 --- a/crates/doc/src/blend.rs +++ b/crates/doc/src/blend.rs @@ -236,7 +236,8 @@ fn lcm(a: usize, b: usize) -> usize { a.checked_div(x).map_or(0, |q| q * b) } -/// Interpolate width profiles (none is uniform) at every position either has a width point. +/// Interpolate width profiles (none is uniform) at every position either has a width point, +/// keeping their discontinuous points (a position where either steps gives two points). fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> Option { if x.is_none() && y.is_none() { return None; @@ -245,22 +246,45 @@ fn lerp_profile(x: Option<&WidthProfile>, y: Option<&WidthProfile>, t: f64) -> O let (x, y) = (x.unwrap_or(&uniform), y.unwrap_or(&uniform)); let mut at: Vec = x.points.iter().chain(&y.points).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); - let points = at - .into_iter() - .map(|p| { - let ((l1, r1), (l2, r2)) = (x.at(p), y.at(p)); - (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)) - }) - .collect(); + // Within the distance `around` reads a discontinuous point at, so a point near one keeps it. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-7); + // Each profile is a smooth curve through its own points: samples between the positions keep + // both curves' shapes where the other profile's points and steps change the tangents. Two + // profiles with points at the same positions need none (so a preset on both keys stays it). + let same_positions = x.points.len() == y.points.len() && x.points.iter().zip(&y.points).all(|(p, q)| (p.0 - q.0).abs() <= 1e-7); + let per_span = if same_positions { 1 } else { (4096 / at.len().max(1)).clamp(1, 8) }; + let mut points = Vec::with_capacity(at.len() * per_span + 2); + let mix = |p: f64, (l1, r1): (f64, f64), (l2, r2): (f64, f64)| (p, lerp_f64(l1, l2, t), lerp_f64(r1, r2, t)); + for (k, &p) in at.iter().enumerate() { + let ((xb, xa), (yb, ya)) = (x.around(p), y.around(p)); + points.push(mix(p, xb, yb)); + // A discontinuous point ends the curve on each side even where both widths match. + if steps_at(x, p) || steps_at(y, p) { + points.push(mix(p, xa, ya)); + } + if let Some(&q) = at.get(k + 1) { + for j in 1..per_span { + let f = p + (q - p) * j as f64 / per_span as f64; + points.push(mix(f, x.at(f), y.at(f))); + } + } + } Some(WidthProfile { points }) } +/// Whether width profile `w` has a discontinuous point at `p` (two points there, within `1e-7`, +/// as [`WidthProfile::around`] reads them). +fn steps_at(w: &WidthProfile, p: f64) -> bool { + let i = w.points.partition_point(|q| q.0 < p - 1e-7); + w.points.get(i..).unwrap_or_default().iter().take_while(|q| q.0 <= p + 1e-7).count() > 1 +} + /// The positions along the stroke where either of two pen recordings has a sample, in order. fn sample_positions(x: Option<&[(f64, f64)]>, y: Option<&[(f64, f64)]>) -> Vec { let mut at: Vec = x.unwrap_or_default().iter().chain(y.unwrap_or_default()).map(|p| p.0).filter(|p| p.is_finite()).collect(); at.sort_by(f64::total_cmp); - at.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + // Within the distance `step_after` reads a step at, so a sample near a step keeps the step. + at.dedup_by(|a, b| (*a - *b).abs() <= 1e-9); if at.is_empty() { at = vec![0.0, 1.0]; } @@ -487,6 +511,161 @@ fn reverse_closed(sp: &mut SubPath) { sp.anchors.rotate_right(1); } +/// How a key's subpaths were turned for its blend, so that its stroke's profiles, pen +/// recordings and arrowheads turn with it: position `u` along the turned subpath is position +/// `start + u` (`start - u` when `reversed`) along the key's own, wrapping round a closed one. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct Orient { + start: f64, + reversed: bool, + closed: bool, +} + +impl Orient { + fn of(sp: &SubPath) -> Self { + Self { closed: sp.closed, ..Self::default() } + } + + /// The subpath now starts at the fraction `f` of its length. + fn rotate(&mut self, f: f64) { + self.start += if self.reversed { -f } else { f }; + } + + /// The subpath now runs the other way. + fn reverse(&mut self) { + self.start += if self.reversed { -1.0 } else { 1.0 }; + self.reversed = !self.reversed; + } + + /// As [`start_at`] turns `sp` (call it before). + fn start_at(&mut self, sp: &SubPath, i: usize) { + let n = sp.anchors.len(); + if i >= n { + return; + } + if sp.closed { + self.rotate(arc_fraction(sp, i)); + } else if i + 1 == n && n > 1 { + self.reverse(); + } + } + + /// Where a closed subpath now starts, as a fraction of its length; 0 for an open one. + fn seam(&self) -> f64 { + let s = if self.closed { self.start.rem_euclid(1.0) } else { 0.0 }; + if s > 1e-9 && s < 1.0 - 1e-9 { s } else { 0.0 } + } + + fn is_identity(&self) -> bool { + !self.reversed && self.seam() == 0.0 + } + + /// Whether two subpaths were turned alike, so one stroke along both turns the same way. + fn same_turn(&self, o: &Self) -> bool { + self.reversed == o.reversed && (self.seam() - o.seam()).abs() < 1e-9 + } +} + +/// How far along `sp` its anchor `i` sits, as a fraction of its length. +fn arc_fraction(sp: &SubPath, i: usize) -> f64 { + let pos = sp.anchor_arc_positions(); + match (pos.get(i), pos.last()) { + (Some(&at), Some(&total)) if total.is_finite() && total > 1e-12 => (at / total).clamp(0.0, 1.0), + _ => 0.0, + } +} + +/// Samples (position, value) along a key's subpath, read along the subpath turned as `o` says. +/// A closed subpath starting elsewhere starts the samples there too: the ones before it move +/// past the seam (`wrap` applied), which keeps any step the seam makes, and a value between +/// samples is read with `at`. A reversed subpath then reads them backwards (`flip` applied). +fn reoriented(points: &[(f64, V)], o: Orient, at: impl Fn(f64) -> V, wrap: impl Fn(V) -> V, flip: impl Fn(V) -> V) -> Vec<(f64, V)> { + let s = o.seam(); + let mut out = points.to_vec(); + if s > 0.0 + && let (Some(&(_, first)), Some(&(_, last))) = (points.first(), points.last()) + { + let i = points.partition_point(|q| q.0 < s - 1e-9); + let j = points.partition_point(|q| q.0 <= s + 1e-9).max(i); + let (before, after) = match points.get(i..j) { + Some([p, .., q]) => (p.1, q.1), + Some([p]) => (p.1, p.1), + _ => (at(s), at(s)), + }; + out = Vec::with_capacity(points.len() + 4); + out.push((0.0, after)); + out.extend(points.get(j..).unwrap_or_default().iter().map(|&(t, v)| (t - s, v))); + out.push((1.0 - s, last)); + out.push((1.0 - s, wrap(first))); + out.extend(points.get(..i).unwrap_or_default().iter().map(|&(t, v)| (t + 1.0 - s, wrap(v)))); + out.push((1.0, wrap(before))); + } + if o.reversed { + out = out.into_iter().rev().map(|(t, v)| (1.0 - t, flip(v))).collect(); + } + out +} + +/// A key's stroke read along its subpath turned as `o` says: its width profile (whose sides swap +/// when reversed), pen pressure, tilt, bearing and rotation, and on a reversed open path its +/// arrowheads. +fn reorient_stroke(s: &mut StrokeLayer, o: Orient) { + if o.is_identity() { + return; + } + let same = |v: f64| v; + if let Some(p) = &mut s.profile { + let mut pts: Vec<(f64, (f64, f64))> = p.points.iter().map(|&(t, l, r)| (t, (l, r))).collect(); + // A width profile is a smooth curve through its points: where the new start splits a span, + // samples of that span keep its curve once its ends become ends of the profile. + let seam = o.seam(); + let i = p.points.partition_point(|q| q.0 < seam); + if seam > 0.0 + && let (Some(a), Some(b)) = (i.checked_sub(1).and_then(|j| p.points.get(j)), p.points.get(i)) + && seam - a.0 > 1e-9 + && b.0 - seam > 1e-9 + { + const SAMPLES: usize = 32; + let (a, b) = (a.0, b.0); + let extra: Vec<(f64, (f64, f64))> = + (1..SAMPLES).map(|k| a + (b - a) * k as f64 / SAMPLES as f64).filter(|f| (f - seam).abs() > 1e-9).map(|f| (f, p.at(f))).collect(); + pts.splice(i..i, extra); + } + let out = reoriented(&pts, o, |f| p.at(f), |v| v, |(l, r)| (r, l)); + p.points = out.into_iter().map(|(t, (l, r))| (t, l, r)).collect(); + } + for p in [&mut s.pressure, &mut s.tilt].into_iter().flatten() { + let out = reoriented(&p.points, o, |f| p.at(f), same, same); + p.points = out; + } + for p in [&mut s.bearing, &mut s.rotation].into_iter().flatten() { + // The samples moved past the seam carry the whole turns the recording makes along the + // stroke, so the angle does not jump by them there. + let turns = match (p.points.first(), p.points.last()) { + (Some(a), Some(b)) if (b.1 - a.1).is_finite() => ((b.1 - a.1) / 360.0).round() * 360.0, + _ => 0.0, + }; + 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. +fn reorient_strokes(n: &mut Node, o: Orient) { + if o.is_identity() { + return; + } + for it in n.appearance.items.iter_mut() { + if let AppearanceItem::Stroke(s) = it { + reorient_stroke(s, o); + } + } +} + /// Anchor positions of a subpath relative to its box, scaled to unit size. fn normalised(sp: &SubPath) -> Vec { let pts = sp.anchors.iter().map(|a| a.p); @@ -524,6 +703,8 @@ fn best_rotation(a: &SubPath, b: &SubPath) -> usize { pub struct PathPair { a: Vec, b: Vec, + /// How each path's subpaths were turned, where all of them turned alike (else unturned). + orient: (Orient, Orient), } impl PathPair { @@ -535,6 +716,7 @@ impl PathPair { let cb = b.bounds().map(|r| r.center()).unwrap_or_default(); let degenerate = |like: &SubPath, c: Point| SubPath::new(vec![Anchor::corner(c); like.anchors.len().max(1)], like.closed); let (mut va, mut vb) = (Vec::with_capacity(n), Vec::with_capacity(n)); + let mut orient = (Orient::default(), Orient::default()); for i in 0..n { let (mut sa, mut sb) = match (a.subpaths.get(i), b.subpaths.get(i)) { (Some(x), Some(y)) => (x.clone(), y.clone()), @@ -542,17 +724,21 @@ impl PathPair { (None, Some(y)) => (degenerate(y, ca), y.clone()), (None, None) => continue, }; + let (mut oa, mut ob) = (Orient::of(&sa), Orient::of(&sb)); let explicit = i == 0 && (starts.0.is_some() || starts.1.is_some()); if explicit { if let Some(s) = starts.0 { + oa.start_at(&sa, s); start_at(&mut sa, s); } if let Some(s) = starts.1 { + ob.start_at(&sb, s); start_at(&mut sb, s); } } let closed = sa.closed && sb.closed && sa.anchors.len() > 2 && sb.anchors.len() > 2; if closed && (sa.area() * sb.area()) < 0.0 { + ob.reverse(); reverse_closed(&mut sb); } let m = sa.anchors.len().max(sb.anchors.len()); @@ -560,12 +746,26 @@ impl PathPair { grow(&mut sb, m); if closed && !explicit { let r = best_rotation(&sa, &sb); + if r != 0 { + ob.rotate(arc_fraction(&sb, r)); + } sb.anchors.rotate_left(r); } + // One stroke runs along every subpath of a key, each reading it by its own length, so + // it turns only when all of the key's subpaths turned alike. + if i == 0 { + orient = (oa, ob); + } + if i < a.subpaths.len() && !orient.0.same_turn(&oa) { + orient.0 = Orient::default(); + } + if i < b.subpaths.len() && !orient.1.same_turn(&ob) { + orient.1 = Orient::default(); + } va.push(sa); vb.push(sb); } - Self { a: va, b: vb } + Self { a: va, b: vb, orient } } /// The path at `t` (0 = `a`, 1 = `b`). @@ -680,7 +880,12 @@ impl Lerp { _ => Shape::Other, }, }; - Self { a: a.clone(), b: b.clone(), shape } + let (mut a, mut b) = (a.clone(), b.clone()); + if let Shape::Path { pair, .. } = &shape { + reorient_strokes(&mut a, pair.orient.0); + reorient_strokes(&mut b, pair.orient.1); + } + Self { a, b, shape } } /// The object at `t` (0 = `a`, 1 = `b`), with id 0. @@ -1582,7 +1787,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { // Even position of the step, then where the easing puts its shape and its colours. let u = j as f64 / (n + 1) as f64; // Easing needs a spine: a blend made without one keeps its steps evenly spaced. - let eased = rail.is_some() && !spec.no_spine; + let eased = rail.is_some() && spec.eases_along_spine(); let (t, tc) = (if eased { spec.ease.apply(u) } else { u }, spec.color_position(u)); let mut s = lerp.at_eased(t, tc); if let Some(r) = &rail { @@ -1597,6 +1802,7 @@ pub fn blend_expand(keys: &[Arc], spec: &BlendSpec) -> Vec { #[cfg(test)] mod tests { use super::*; + use crate::appearance::Arrowhead; use vectorcraft_geom::{Rect, shapes}; #[test] @@ -1909,6 +2115,89 @@ mod tests { assert!(s.pressure.is_none() && s.tilt.is_none() && s.bearing.is_none() && s.rotation.is_none()); } + #[test] + fn a_point_near_the_other_keys_step_keeps_the_step() { + let flat = PressureProfile { points: vec![(0.0, 0.0), (0.4999995, 0.0), (1.0, 0.0)] }; + let stepped = PressureProfile { points: vec![(0.0, 0.0), (0.5, 0.0), (0.5, 1.0), (1.0, 1.0)] }; + let p = lerp_pressure(Some(&flat), Some(&stepped), 1.0).unwrap_or_default(); + assert!((p.at(0.75) - 1.0).abs() < 1e-9, "{p:?}"); + let narrow = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.4999995, 1.0, 1.0), (1.0, 1.0, 1.0)] }; + let stepped = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.5, 1.0, 1.0), (0.5, 0.0, 0.0), (1.0, 0.0, 0.0)] }; + let w = lerp_profile(Some(&narrow), Some(&stepped), 1.0).unwrap_or_default(); + assert!(w.at(0.75).0.abs() < 1e-9, "{w:?}"); + // The other key's step doesn't reshape a smooth profile's curve. + let early = WidthProfile { points: vec![(0.0, 1.0, 1.0), (0.25, 1.0, 1.0), (0.25, 0.5, 0.5), (1.0, 0.5, 0.5)] }; + let w = lerp_profile(Some(&WidthProfile::lens()), Some(&early), 0.0).unwrap_or_default(); + for u in [0.1, 0.125, 0.2, 0.3, 0.4] { + let (got, want) = (w.at(u).0, WidthProfile::lens().at(u).0); + assert!((got - want).abs() < 5e-3, "at {u}: {got} for {want}: {w:?}"); + } + let lens = WidthProfile::lens(); + assert_eq!(lerp_profile(Some(&lens), Some(&lens), 0.5).and_then(|p| p.preset_id()), Some("lens")); + } + + #[test] + fn stroke_profiles_turn_with_a_key_path_started_elsewhere() { + let keyed = |path: PathData, edit: &dyn Fn(&mut StrokeLayer)| { + let mut ap = Appearance::default(); + ap.set_stroke(Paint::solid(Color::BLACK)); + if let Some(s) = ap.stroke_mut() { + edit(s); + } + Node::path(NodeId(1), path, ap) + }; + let stroke = |n: &Node| n.appearance.stroke().cloned().unwrap_or_else(|| panic!("a stroke")); + let pressure = |s: &StrokeLayer, f: f64| s.pressure.as_ref().map_or(PressureProfile::MID, |p| p.at(f)); + // An open key clicked at its last anchor runs the other way, and so do its pressure, its + // width profile (whose sides swap) and its arrowheads. + let line = |x: f64| PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0)], false)); + let light = keyed(line(0.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 0.0)] })); + let ramp = keyed(line(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 0.0), (1.0, 0.0, 0.0)] }); + s.start_arrow = Some(Arrowhead::Triangle); + s.arrow_scale = (200.0, 100.0); + }); + let blend = Lerp::new(&light, &ramp, (None, Some(1))); + let s = stroke(&blend.at(0.25)); + assert!(pressure(&s, 0.0).abs() < 1e-9 && (pressure(&s, 1.0) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let s = stroke(&blend.at(1.0)); + assert_eq!(s.profile.as_ref().map(|p| p.at(1.0)), Some((0.0, 1.0)), "{:?}", s.profile); + assert_eq!((s.start_arrow, s.end_arrow, s.arrow_scale), (None, Some(Arrowhead::Triangle), (100.0, 200.0))); + // A key whose other subpaths keep their direction keeps its one stroke as it is. + let two = |x: f64| { + let sub = |y: f64| SubPath::polyline(&[Point::new(x, y), Point::new(x + 10.0, y)], false); + PathData::new(vec![sub(0.0), sub(20.0)]) + }; + let ramps = keyed(two(100.0), &|s| s.pressure = Some(PressureProfile { points: vec![(0.0, 1.0), (1.0, 0.0)] })); + let s = stroke(&Lerp::new(&keyed(two(0.0), &|_| {}), &ramps, (None, Some(1))).at(1.0)); + assert!((pressure(&s, 0.0) - 1.0).abs() < 1e-9, "{:?}", s.pressure); + // A closed key started half way round starts its recordings there too, keeping the step + // where its own start was and the whole turn its rotation makes. + let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 10.0, 10.0)); + let plain = keyed(square(0.0), &|_| {}); + let turning = keyed(square(100.0), &|s| { + s.pressure = Some(PressureProfile { points: vec![(0.0, 0.0), (1.0, 1.0)] }); + s.rotation = Some(AngleProfile { points: vec![(0.0, 0.0), (1.0, 360.0)] }); + s.profile = Some(WidthProfile { points: vec![(0.0, 1.0, 1.0), (1.0, 0.0, 0.0)] }); + }); + let blend = Lerp::new(&plain, &turning, (Some(0), Some(2))); + let s = stroke(&blend.at(1.0)); + assert!((pressure(&s, 0.25) - 0.75).abs() < 1e-9 && (pressure(&s, 0.75) - 0.25).abs() < 1e-9, "{:?}", s.pressure); + let width = s.profile.as_ref().map_or((1.0, 1.0), |p| p.at(0.75)); + assert!((width.0 - 0.75).abs() < 1e-6 && (width.1 - 0.75).abs() < 1e-6, "the width steps where the key started: {:?}", s.profile); + // A width curve split by the new start keeps its shape. + let lensed = keyed(square(100.0), &|s| s.profile = Some(WidthProfile::lens())); + let s = stroke(&Lerp::new(&plain, &lensed, (Some(0), Some(1))).at(1.0)); + for u in [0.05, 0.125, 0.2, 0.6, 0.7, 0.74, 0.76, 0.8, 0.875, 0.95] { + let (got, want) = (s.profile.as_ref().map_or(1.0, |p| p.at(u).0), WidthProfile::lens().at((u + 0.25) % 1.0).0); + assert!((got - want).abs() < 2e-3, "at {u}: {got} for {want}: {:?}", s.profile); + } + let rot = |t: f64, f: f64| stroke(&blend.at(t)).rotation.as_ref().map_or(0.0, |p| p.unwrapped_at(f)); + assert!((vectorcraft_geom::normalize_deg(rot(1.0, 0.25)) + 90.0).abs() < 1e-6, "{:?}", s.rotation); + assert!((rot(0.5, 0.499) - rot(0.5, 0.501)).abs() < 1.0, "no jump where the key started: {:?}", stroke(&blend.at(0.5)).rotation); + } + #[test] fn odd_dash_blends_to_solid_with_closed_gaps() { let d = lerp_dash(Some(&Dash { pattern: vec![2.0, 3.0, 4.0], ..Default::default() }), None, 1.0).unwrap(); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c5759f202..2ed800ceec 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -82,8 +82,8 @@ pub(crate) mod skip; pub use appearance::StrokeGradientMode; pub use appearance::{ - AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, ProfilePreset, - SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, + AngleProfile, Appearance, AppearanceItem, ArrowAlign, Arrowhead, CustomArrowhead, Dash, Effect, FillLayer, LineCap, LineJoin, PressureProfile, + ProfilePreset, SavedProfile, StrokeAlign, StrokeLayer, WidthProfile, }; pub use assets::ExportAsset; pub use compound_shape::ShapeMode; diff --git a/crates/doc/src/live/blend_spec.rs b/crates/doc/src/live/blend_spec.rs index 56405e0d18..047e1ad7b5 100644 --- a/crates/doc/src/live/blend_spec.rs +++ b/crates/doc/src/live/blend_spec.rs @@ -119,6 +119,12 @@ impl BlendSpec { pub fn start(&self, i: usize) -> Option { self.starts.get(i).copied().flatten().map(|a| a as usize) } + + /// Whether [`Self::ease`] moves the steps: the blend has a spine ([`Self::no_spine`] is off) + /// and a replaced spine or Align to Path, the cases where the steps are placed along it. + pub fn eases_along_spine(&self) -> bool { + !self.no_spine && (self.spine.is_some() || self.orientation == BlendOrientation::AlignToPath) + } } /// The Blend panel's Create Spine setting: whether a new blend gets a spine. diff --git a/crates/doc/src/reach.rs b/crates/doc/src/reach.rs index 8279507e01..8115d00954 100644 --- a/crates/doc/src/reach.rs +++ b/crates/doc/src/reach.rs @@ -1,6 +1,7 @@ //! Where a path's strokes paint, from the path's geometry without building their outlines: the //! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes -//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points. +//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points, +//! and a bevel ends at the corners of the two segments it joins. use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit}; use vectorcraft_geom::hit::fill_contains; @@ -39,10 +40,20 @@ fn joints(segs: &[PathSeg], closed: bool) -> impl Iterator Option<[Point; 3]> { - let cos = t_in.dot(t_out); // cos(half the turn) is sin(half the angle between the segments): the miter is side / it. - let half = ((1.0 + cos) / 2.0).max(0.0).sqrt(); - if cos >= CORNER_COS || half * limit < 1.0 { + let half = ((1.0 + t_in.dot(t_out)) / 2.0).max(0.0).sqrt(); + if half * limit < 1.0 { + return None; + } + let ([a, b], out) = bevel(at, t_in, t_out, side)?; + Some([a, at + out * (side / half), b]) +} + +/// The bevel at a corner with tangents `t_in`, `t_out` of a stroke reaching `side` from its path: +/// the outer corners of the two segments' strokes and the unit direction out of the turn, or +/// `None` when the path doesn't turn there. +fn bevel(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<([Point; 2], Vec2)> { + if t_in.dot(t_out) >= CORNER_COS { return None; } let out = unit(t_in - t_out); @@ -51,7 +62,7 @@ fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> Option<[P let n = Vec2::new(-t.y, t.x); if n.dot(out) >= 0.0 { n } else { -n } }; - Some([at + outer(t_in) * side, at + out * (side / half), at + outer(t_out) * side]) + Some(([at + outer(t_in) * side, at + outer(t_out) * side], out)) } /// The heads' ends of `bp`: the start of its first subpath and the end of its last, when open (as @@ -187,26 +198,44 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool { } impl StrokeLayer { - /// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads, - /// an alignment that depends on whether the path is closed, or butt caps, which stop at the - /// ends of an open path? Else it reaches evenly round it. + /// Does bounding this stroke need its path's shape: miter spikes, bevels, projecting caps, + /// arrowheads, an alignment that depends on whether the path is closed, or butt caps, which + /// stop at the ends of an open path? Else it reaches evenly round it. fn needs_shape(&self) -> bool { - self.join == LineJoin::Miter + self.join != LineJoin::Round || self.cap != LineCap::Round || self.start_arrow.is_some() || self.end_arrow.is_some() || self.align != StrokeAlign::Center } - /// The box this stroke paints along `segs`, the segments with length of an open subpath, when - /// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each - /// joint between segments paints ([`round_join_box`]), a shallow one included. - /// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips - /// are measured apart. A segment in the middle is measured only when its own box grown by - /// `side`, which holds everything it paints, is not inside the box so far. - fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option { + /// Does this stroke stop at the corners of its segments along a subpath that is `closed` or + /// open, where its caps are butt caps on an open subpath or its joins are bevels or miters? + /// Dashes with round or projecting caps can end at a corner, so with them only butt caps stop. + fn stops_at_corners(&self, closed: bool) -> bool { + let capped_dashes = self.cap != LineCap::Butt && self.dash.as_ref().is_some_and(Dash::is_dashed); + (self.cap == LineCap::Butt && !closed) || (self.join != LineJoin::Round && !capped_dashes) + } + + /// Does this stroke reach past a subpath that is `closed` or open by a margin that follows the + /// direction of its segments at their corners: where it stops at them + /// ([`Self::stops_at_corners`]) or paints miter spikes, which dashes with round or projecting + /// caps also paint? + fn reaches_by_corners(&self, closed: bool) -> bool { + self.stops_at_corners(closed) || self.join == LineJoin::Miter + } + + /// The box this stroke's body paints along `segs`, the segments with length of a subpath, short + /// of its caps, joined with `within`: each segment's [`swept_box`] and, with round joins on an + /// open subpath, the arc each joint between segments paints ([`round_join_box`]), a shallow one + /// included. A bevel stays inside the boxes of the segments it joins. `None` when `segs` is + /// empty. Butt dashes stop inside the same box. The caps other than butt ones, the heads and + /// miter tips are measured apart. A segment in the middle is measured only when its own box + /// grown by `side`, which holds everything it paints, is not inside the box so far, which + /// starts as `within` and the first and last segments' boxes. + fn body_along(&self, segs: &[PathSeg], side: f64, within: Rect) -> Option { let (first, last) = (segs.first()?, segs.last()?); - let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)); + let ends = swept_box(first, side, 3).union(swept_box(last, side, 3)).union(within); let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| { if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) } }); @@ -217,24 +246,28 @@ impl StrokeLayer { } /// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt - /// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its - /// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of - /// projecting caps and the reach of the arrowheads round their end points. + /// caps, and every subpath when the joins are bevels or miters, by [`Self::body_along`] with its + /// caps, every other subpath by the box of its segments grown by its reach, with `tips` and, + /// when `measure_tips`, the miter spikes the limit keeps, the corners of projecting caps and the + /// reach of the arrowheads round their end points. fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option, measure_tips: bool) -> Rect { let els = bp.elements(); let subs = subpaths(bp); let mut out = tips.map_or(bounds, |t| bounds.union(t)); let mut side_max: f64 = 0.0; - // Every subpath reaches evenly unless one is open and butt-capped; only then is each - // subpath's own box needed. - let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1); - let mut even = even_only.then_some(bounds); // Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere. let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed); + // A butt cap stops at an open subpath's end, and a bevel, or a miter the limit bevels, at + // the two segments' corners, so such a subpath is measured along its segments. Every + // subpath reaches evenly unless one is measured that way; only then is each subpath's own + // box needed. + let even_only = !subs.iter().any(|s| self.stops_at_corners(s.1)); + let mut even = even_only.then_some(bounds); for (r, closed) in subs { let side = self.side_reach(closed); let butt = self.cap == LineCap::Butt && !closed; - if !butt { + let along = self.stops_at_corners(closed); + if !along { side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side }); } if side <= 0.0 { @@ -242,10 +275,10 @@ impl StrokeLayer { } let square = self.cap == LineCap::Square && !closed; let miters = measure_tips && self.join == LineJoin::Miter; - if !(butt || square || miters || !even_only) { + if !(along || square || miters || !even_only) { continue; } - let segs = drawn(&els[r]); + let segs = drawn(&els[r.clone()]); if miters { for (at, t_in, t_out) in joints(&segs, closed) { if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) { @@ -253,13 +286,24 @@ impl StrokeLayer { } } } - if butt { - if let Some(body) = self.butt_body(&segs, side) { - out = out.union(body); + if along { + // The path's bounds are in the box already, so only the segments that can reach + // past them are measured. + match self.body_along(&segs, side, out) { + Some(body) => out = out.union(body), + // A subpath with no length paints at most its caps' dot. + None if !butt => { + let dot = segments(&els[r]).iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)); + out = dot.map_or(out, |d| out.union(d.inflate(side, side))); + } + None => {} } - continue; - } - if !even_only { + if self.cap == LineCap::Round && !closed { + for (at, _) in ends(&segs).into_iter().flatten() { + out = out.union(Rect::from_center_size(at, (2.0 * side, 2.0 * side))); + } + } + } else if !even_only { even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b))); } if square { @@ -279,7 +323,8 @@ impl StrokeLayer { /// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown /// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of /// projecting caps and the reach of the arrowheads round their end points. An open subpath - /// with butt caps does not reach past its ends ([`Self::butt_body`]). + /// with butt caps does not reach past its ends, and a bevel does not reach round its corner + /// ([`Self::body_along`]). pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect { self.reach_along(bp, bounds, None, true) } @@ -329,6 +374,9 @@ impl StrokeLayer { let segs = drawn(&els[r]); let (half, region) = self.body(closed); let full = half * self.profile_max() * dash_caps.unwrap_or(1.0); + // Past a butt cap or a bevel the stroke stops at the segments' corners. A width profile + // keeps the round reach at the ends. + let squared = self.profile.is_none() && self.stops_at_corners(closed); // Every segment near enough to reach `p`: with a width profile the wider side of a // farther segment can paint past the nearest one, so the nearest alone is not enough. let near = segs.iter().enumerate().filter_map(|(i, s)| { @@ -365,7 +413,8 @@ impl StrokeLayer { } }) }; - if d <= half * factor.max(0.0) + tol && on_side(region, d) { + if d <= half * factor.max(0.0) + tol && on_side(region, d) && (!squared || self.paints_at_end(&segs, closed, (i, nr.t), p, half, tol)) + { return true; } } @@ -393,6 +442,35 @@ impl StrokeLayer { false } + /// Does this stroke, where it stops at the corners of its segments ([`Self::stops_at_corners`]), + /// paint `p`, which is within `side` (and `tol`) of segment `i` of the subpath `segs` + /// (`closed`: a closed one) and nearest to it at `t`? It does when `p` is not past the end of + /// the segment that `t` is at, by more than `tol`, and past it when a round cap or a round join + /// paints there, when the path does not turn at that end, or when `p` is within `tol` of the + /// bevel's triangle. Projecting caps and + /// miter spikes are checked apart. A curve that bends more tightly than `side` ([`gentle`]) + /// counts as painting round its ends. + fn paints_at_end(&self, segs: &[PathSeg], closed: bool, (i, t): (usize, f64), p: Point, side: f64, tol: f64) -> bool { + let (n, at_start) = (segs.len(), t <= 1e-9); + let Some(seg) = segs.get(i) else { return true }; + if (!at_start && t < 1.0 - 1e-9) || !(matches!(seg, PathSeg::Line(_)) || gentle(seg, side)) { + return true; + } + let (at, out) = if at_start { (seg.start(), -tangent(seg, 0.0)) } else { (seg.end(), tangent(seg, 1.0)) }; + if (p - at).dot(out) <= tol { + return true; + } + // The segment on the other side of this end, `None` at the end of an open subpath. + let other = if at_start { (i > 0 || closed).then(|| (i + n - 1) % n) } else { (i + 1 < n || closed).then(|| (i + 1) % n) }; + let Some(other) = other.and_then(|o| segs.get(o)) else { return self.cap == LineCap::Round }; + if self.join == LineJoin::Round { + return true; + } + let (t_in, t_out) = if at_start { (tangent(other, 1.0), tangent(seg, 0.0)) } else { (tangent(seg, 1.0), tangent(other, 0.0)) }; + // A joint where the path does not turn has no bevel: the stroke runs on through it. + bevel(at, t_in, t_out, side).is_none_or(|([a, b], _)| in_convex(&[at, a, b], p, tol)) + } + /// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path /// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and /// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With @@ -400,13 +478,15 @@ impl StrokeLayer { /// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the /// chord the path bends through under it, so anywhere within its length of the end. fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool { - let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false }; + let Some(kind) = (if end { &self.end_arrow } else { &self.start_arrow }) else { return false }; let (hw, v) = (self.arrow_weight(end), p - at); match self.arrow_align { ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol, ArrowAlign::Extend => { let along = v.dot(out); - v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol + v.cross(out).abs() <= kind.half_width() * hw + tol + && along <= kind.length() * hw + self.width / 2.0 + tol + && along >= -kind.reach() * hw - tol } } } @@ -426,7 +506,7 @@ impl Appearance { } /// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape - /// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`. + /// (miter spikes, bevels, projecting or butt caps, arrowheads or an alignment) given by `shaped`. pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect { self.painted_strokes().fold(bounds, |acc, s| { if !s.needs_shape() { @@ -437,17 +517,21 @@ impl Appearance { }) } - /// Does this appearance have a visible butt-capped stroke? - pub fn has_butt_stroke(&self) -> bool { - self.painted_strokes().any(|s| s.cap == LineCap::Butt) + /// Does this appearance have a visible stroke that stops at the corners of its segments on an + /// open subpath or a closed one, or paints miter spikes: butt caps, or bevel and miter joins + /// ([`StrokeLayer::reaches_by_corners`])? + pub fn has_cornered_stroke(&self) -> bool { + self.painted_strokes().any(|s| s.reaches_by_corners(false)) } - /// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with - /// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The - /// visual box's margin at the stroke's corners there depends on their direction, which a scale - /// out of proportion changes. - pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { - if !self.has_butt_stroke() { + /// Does a visible stroke of this appearance that stops at the corners of its segments (butt + /// caps on an open subpath, bevel and miter joins) or paints miter spikes, dashed ones with + /// round or projecting caps included, run along a subpath of `bp` where it does so, with a + /// segment that starts or ends at a slant, not square to the axes, once placed by `a`? The + /// visual box's margin at those corners, and at miter tips, depends on their direction, which a + /// scale out of proportion changes. + pub fn has_slanted_stroke_corners(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool { + if !self.has_cornered_stroke() { return false; } let slanted = |v: Vec2| { @@ -455,9 +539,10 @@ impl Appearance { (d.x * d.y).abs() > 1e-9 }; let els = bp.elements(); - subpaths(bp) - .into_iter() - .filter(|(_, closed)| !closed) + let subs = subpaths(bp); + let cornered = |closed: bool| self.painted_strokes().any(|s| s.reaches_by_corners(closed)); + subs.into_iter() + .filter(|(_, closed)| cornered(*closed)) .any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0)))) } @@ -468,28 +553,28 @@ impl Appearance { } } -/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object +/// How many symbol instances [`crate::Node::has_slanted_stroke_corners`] looks into for one object /// before it answers yes without looking further. pub const SLANT_INSTANCES: usize = 4096; impl crate::Node { - /// Does a visible butt-capped stroke of this object, of an object in it or of an object in a - /// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment - /// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it - /// answers yes. - pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { - self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) + /// Does a visible stroke of this object, of an object in it or of an object in a symbol + /// instance's art from `symbols`, placed by `a`, stop at the corners of a segment at a slant + /// ([`Appearance::has_slanted_stroke_corners`])? Past [`SLANT_INSTANCES`] instances it answers + /// yes. + pub fn has_slanted_stroke_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool { + self.slanted_corners(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES)) } - /// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into. - fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { + /// [`Self::has_slanted_stroke_corners`] with `left` instances still to look into. + fn slanted_corners(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell) -> bool { if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind { let Some(l) = left.get().checked_sub(1) else { return true }; left.set(l); - return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false); + return symbols.measure_art(symbol, |art| art.slanted_corners(symbols, a * *xf, left)).unwrap_or(false); } - (self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a))) - || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left))) + (self.appearance.has_cornered_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_stroke_corners(&bp, a))) + || self.children().is_some_and(|c| c.iter().any(|c| c.slanted_corners(symbols, a, left))) } } @@ -566,6 +651,79 @@ mod tests { assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0)); } + #[test] + fn a_bevel_stops_at_the_corners_of_the_segments_it_joins() { + // A 40 × 100 pt triangle stroked 20 pt: a miter limit of 2 or a bevel join bevels its + // 22.6° tip at (20, 0), so the stroke reaches 10 · 20 / √10400 pt above it, not 10 pt. + let mut tri = BezPath::new(); + tri.move_to((0.0, 100.0)); + tri.line_to((20.0, 0.0)); + tri.line_to((40.0, 100.0)); + tri.close_path(); + let tb = tri.bounding_box(); + let (top, side) = (-200.0 / 10400.0_f64.sqrt(), 1000.0 / 10400.0_f64.sqrt()); + let limited = stroke(20.0, |s| s.miter_limit = 2.0); + let bevel = stroke(20.0, |s| { + s.join = LineJoin::Bevel; + s.cap = LineCap::Round; + }); + let b = limited.bounds_on(&tri, tb); + assert!((b.y0 - top).abs() < 1e-9, "{b:?}"); + // The limit keeps the base corners' miters, whose tips reach about 12.2 pt left and right. + assert!(b.x0 < -12.0 && b.x1 > 52.0, "{b:?}"); + let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 20.0); + *ap.stroke_mut().unwrap() = bevel.clone(); + let b = ap.stroked_bounds(tb, || tri.clone()); + for (got, want) in [(b.x0, -side), (b.y0, top), (b.x1, 40.0 + side), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // Open, the same corner is beveled and the round caps reach the half weight round the ends. + let chevron = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + let b = bevel.bounds_on(&chevron, chevron.bounding_box()); + for (got, want) in [(b.x0, -10.0), (b.y0, top), (b.x1, 50.0), (b.y1, 110.0)] { + assert!((got - want).abs() < 1e-9, "{b:?}"); + } + // A round join, and the round caps of dashes, which can end at the tip, reach round it. + let round = stroke(20.0, |s| s.join = LineJoin::Round); + assert!((round.bounds_on(&tri, tb).y0 + 10.0).abs() < 1e-9); + let mut dashed = bevel.clone(); + dashed.dash = Some(Dash { pattern: vec![7.0, 4.0], offset: 0.0, align_corners: false }); + assert!((dashed.bounds_on(&chevron, chevron.bounding_box()).y0 + 10.0).abs() < 1e-9); + } + + #[test] + fn a_click_past_a_bevel_or_a_butt_end_misses() { + // 5 pt above the triangle's tip is within the half weight of it, but past the bevel, which + // reaches about 1.96 pt above it. A round join paints there. + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + let (above, on) = (Point::new(20.0, -5.0), Point::new(20.0, -1.5)); + for join in [LineJoin::Bevel, LineJoin::Miter] { + let st = stroke(20.0, |s| (s.join, s.miter_limit) = (join, 2.0)); + assert!(!st.hits(&tri, FillRule::NonZero, above, 0.5), "{join:?}"); + assert!(st.hits(&tri, FillRule::NonZero, on, 0.0), "{join:?}"); + } + assert!(stroke(20.0, |s| s.join = LineJoin::Round).hits(&tri, FillRule::NonZero, above, 0.5)); + // 1.5 pt past a butt end of a 4 pt line misses, and a quarter point within it hits; a round + // cap paints past it. + let line = polyline(&[(0.0, 0.0), (0.0, 50.0)]); + let butt = stroke(4.0, |s| s.cap = LineCap::Butt); + assert!(!butt.hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + assert!(butt.hits(&line, FillRule::NonZero, Point::new(1.0, -0.2), 0.25)); + assert!(stroke(4.0, |s| s.cap = LineCap::Round).hits(&line, FillRule::NonZero, Point::new(1.0, -1.5), 0.25)); + } + + #[test] + fn a_joint_too_shallow_to_bevel_paints_past_both_segments_ends() { + // The path turns by about 0.57° at (100, -0.5), too little to bevel, so the stroke runs on + // through the joint: 3.5 pt above it is past the end of both segments and still painted. + let kink = polyline(&[(0.0, 0.0), (100.0, -0.5), (200.0, 0.0)]); + let above = Point::new(100.0, -4.0); + for join in [LineJoin::Bevel, LineJoin::Miter, LineJoin::Round] { + assert!(stroke(10.0, |s| s.join = join).hits(&kink, FillRule::NonZero, above, 0.0), "{join:?}"); + } + } + /// An open path through `points`. fn polyline(points: &[(f64, f64)]) -> BezPath { let mut bp = BezPath::new(); @@ -691,15 +849,25 @@ mod tests { let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0); ap.stroke_mut().unwrap().cap = LineCap::Butt; let id = vectorcraft_geom::Affine::IDENTITY; - assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); + assert!(!ap.has_slanted_stroke_corners(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id)); // Square ends, slanted segments in the middle. let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&apex, id)); - // A rotation slants a vertical line; a round cap is never slanted. + assert!(ap.has_slanted_stroke_corners(&apex, id)); + // A rotation slants a vertical line. let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]); - assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3))); + assert!(ap.has_slanted_stroke_corners(&v, vectorcraft_geom::Affine::rotate(0.3))); + // With round caps, the miter join still stops at the corners of the slanted segments, on a + // closed path too; round caps and round joins never do. ap.stroke_mut().unwrap().cap = LineCap::Round; - assert!(!ap.has_slanted_butt_stroke(&apex, id)); + assert!(ap.has_slanted_stroke_corners(&apex, id)); + let mut tri = polyline(&[(0.0, 100.0), (20.0, 0.0), (40.0, 100.0)]); + tri.close_path(); + ap.stroke_mut().unwrap().join = LineJoin::Bevel; + assert!(ap.has_slanted_stroke_corners(&tri, id)); + assert!(!ap.has_slanted_stroke_corners(&square(), id)); + ap.stroke_mut().unwrap().join = LineJoin::Round; + assert!(!ap.has_slanted_stroke_corners(&apex, id)); + assert!(!ap.has_slanted_stroke_corners(&tri, id)); } #[test] @@ -723,11 +891,11 @@ mod tests { } let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY }); let symbols = d.symbol_boxes(); - assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY)); + assert!(top.has_slanted_stroke_corners(&symbols, vectorcraft_geom::Affine::IDENTITY)); assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured()); // One instance of S1 holds three instances, all looked into. let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY }); - assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); + assert!(!one.has_slanted_stroke_corners(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY)); } #[test] diff --git a/crates/doc/src/text/para_style.rs b/crates/doc/src/text/para_style.rs index bb3725072e..a2c2a66498 100644 --- a/crates/doc/src/text/para_style.rs +++ b/crates/doc/src/text/para_style.rs @@ -237,6 +237,10 @@ pub struct ParaStyle { /// Kinsoku Shori Type (Paragraph panel menu): how a line keeps those characters off its start /// and end, by pushing them in onto the line or out to the next one. No effect without kinsoku. pub kinsoku_type: KinsokuShoriType, + /// Bunri-Kinshi (Paragraph panel menu): a line never breaks inside an inseparable pair (two em + /// dashes, two ellipses, two two-dot leaders, a vertical kana repeat mark's two halves). Kept + /// with kinsoku None, where it has no effect. Off by default. + pub bunri_kinshi: bool, /// Roman Hanging Punctuation (Paragraph panel menu): quotation marks, hyphens, full stops and /// commas starting or ending a line hang outside the margin, and asterisks, tildes, ellipses, /// dashes, colons and semicolons hang by half. East Asian punctuation hangs by burasagari. @@ -339,6 +343,8 @@ struct ParaStyleFile { #[serde(default, skip_serializing_if = "crate::skip::is_default")] kinsoku_type: KinsokuShoriType, #[serde(default, skip_serializing_if = "crate::skip::is_default")] + bunri_kinshi: bool, + #[serde(default, skip_serializing_if = "crate::skip::is_default")] roman_hanging: bool, #[serde(default, skip_serializing_if = "crate::skip::is_default")] kashida: Kashida, @@ -375,6 +381,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -401,6 +408,7 @@ impl From for ParaStyleFile { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -431,6 +439,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, @@ -456,6 +465,7 @@ impl From for ParaStyle { burasagari, kinsoku, kinsoku_type, + bunri_kinshi, roman_hanging, kashida, korean_line_break_anywhere, diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 795fc69bdf..24f3d68af6 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -29,7 +29,7 @@ use crate::group::{has_own_paint, paints}; use crate::reshape::{as_art, 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_item_geometry, has_pathfinder, is_geometry, is_pathfinder, merged_params, needs_outline, outline_text, reshape, + has_item_geometry, has_pathfinder, is_pathfinder, merged_params, needs_outline, outline_text, reshape, }; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { @@ -41,8 +41,8 @@ fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { fn clear_item_effects(item: &mut AppearanceItem) { match item { - AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)), - AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)), + AppearanceItem::Fill(f) => f.effects = crate::raster::without_geometry(&f.effects), + AppearanceItem::Stroke(s) => s.effects = crate::raster::without_geometry(&s.effects), } } @@ -337,7 +337,7 @@ fn moved_type_group(n: &Node) -> Option { g.knockout = Knockout::Off; g.knockout_shape = n.knockout_shape; g.extras_mut().mask = n.extras().mask.clone(); - g.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + g.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(g) } @@ -397,7 +397,8 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option { return None; } reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d)); - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); m.appearance.items.clear(); m.appearance.contents_index = None; Some(m) @@ -439,7 +440,7 @@ fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> { if carry.moves(&m.appearance) { carry.apply(&mut m.appearance); } - m.appearance.effects.retain(|e| !is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); Some((g, rule, ctx, m)) } diff --git a/crates/effects/src/catalog.rs b/crates/effects/src/catalog.rs index 84991392b2..031d3e1d98 100644 --- a/crates/effects/src/catalog.rs +++ b/crates/effects/src/catalog.rs @@ -94,6 +94,8 @@ fn lengths_of(id: &str) -> Lengths { "stylize.glowingEdges" => always(&["edgeWidth", "smoothness"]), "brushStrokes.accentedEdges" => always(&["edgeWidth"]), "artistic.paletteKnife" => always(&["strokeSize"]), + "artistic.coloredPencil" => always(&["pencilWidth"]), + "artistic.paintDaubs" => always(&["brushSize"]), "brushStrokes.angledStrokes" | "brushStrokes.crosshatch" | "brushStrokes.inkOutlines" => always(&["strokeLength"]), "brushStrokes.spatter" => always(&["sprayRadius"]), "brushStrokes.sprayedStrokes" => always(&["strokeLength", "sprayRadius"]), @@ -258,14 +260,14 @@ pub fn effect_catalog() -> Vec { "distort.roughen", "Roughen…", DT, - "{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", + "{size: 0..100 (5; 0 keeps the outline and adds its points along it), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}", json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}), ), g( "distort.transform", "Transform…", DT, - "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}", + "{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once; on, without copies or random, the raster effects listed before this effect (Rasterize and SVG filters excepted) transform with the art whatever strokes says: their sizes scale by the mean scale and a Drop Shadow's offset turns, reflects and scales, while the angles and light directions of pixel filters stay as they are), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power; on a fill or stroke, the effects listed after this effect in that fill's or stroke's own list keep their distances and apply to the transformed art, and with objects off so do the effects before it)}", json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}), ), g( @@ -387,7 +389,7 @@ pub fn effect_catalog() -> Vec { "artistic.paintDaubs", "Paint Daubs…", ARTISTIC, - "{brushSize: 1..50 (10; dab centres farther apart), sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", + "{brushSize: 1..50 (10; spacing of the dab centers in document units),sharpness: 0..40 (25; harder dab edges), brushType: \"simple\"|\"lightRough\"|\"darkRough\"|\"wideSharp\"|\"wideBlurry\"|\"sparkle\" (\"simple\")} repaints the object with thick round brush dabs in the local colours, keeping its coverage. Original visual approximation; chosen VectorCraft defaults.", json!({"brushSize": 10.0, "sharpness": 25.0, "brushType": "simple"}), ), r( diff --git a/crates/effects/src/distort.rs b/crates/effects/src/distort.rs index c070457027..a5bc941b16 100644 --- a/crates/effects/src/distort.rs +++ b/crates/effects/src/distort.rs @@ -3,6 +3,7 @@ use serde_json::Value; use vectorcraft_geom::kurbo::ParamCurveArclen; use vectorcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2}; +use vectorcraft_pathops::addons::pathtools::insert_at; use crate::util::{catmull_rom, fit_piece, flag, map_nonlinear_within, mean_size, noise, normal_at, num, pieces, seed, seg_cubic, smooth_points}; @@ -79,7 +80,8 @@ const MAX_ROUGHEN_POINTS: usize = 2000; /// Roughen: resample at `detail` points per inch and jitter each point by up to `size`. Where /// that would make more than `max` anchors, the points are spaced further apart until it doesn't -/// (stacked Roughens multiply their points, #3578). +/// (stacked Roughens multiply their points, #3578). At size 0 no point moves: each segment is +/// split into its pieces, evenly along its length, and the outline stays as it is. pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let size = num(p, "size", 5.0).clamp(0.0, 1e4); let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size }; @@ -89,6 +91,23 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { let lens: Vec = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i).arclen(0.1))).collect(); let spacing = fit_piece(&lens, spacing, MAX_ROUGHEN_POINTS, max.saturating_sub(path.anchor_count())); let mut lens = lens.iter(); + if amp == 0.0 { + // Each segment's pieces, cut at equal lengths as `pathtools::add_points` cuts them. + let mut subs = Vec::with_capacity(path.subpaths.len()); + for sp in &path.subpaths { + let mut places = vec![]; + for (i, &coarse) in (0..sp.segment_count()).zip(&mut lens) { + let k = pieces(coarse, spacing, MAX_ROUGHEN_POINTS); + let c = sp.segment(i); + let len = c.arclen(ROUGHEN_ARCLEN_ACCURACY); + if len > 1e-9 && len.is_finite() { + places.extend((1..k).map(|j| (i, c.inv_arclen(len * j as f64 / k as f64, ROUGHEN_ARCLEN_ACCURACY)))); + } + } + subs.push(insert_at(sp, &places).0); + } + return PathData::new(subs); + } let smooth = smooth_points(p); let seed = seed(p); let mut subs = vec![]; @@ -118,6 +137,8 @@ pub fn roughen(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData { PathData::new(subs) } +const ROUGHEN_ARCLEN_ACCURACY: f64 = 1e-4; + /// The move and the number of copies of a Transform effect with params `p` that only moves what /// it applies to, as [`transform_map`] reads them; `None` when it also scales, rotates or reflects, /// or moves by a random share, or moves pattern paints along (Transform Patterns), or scales diff --git a/crates/effects/src/group.rs b/crates/effects/src/group.rs index 57f2fc8613..015490a74d 100644 --- a/crates/effects/src/group.rs +++ b/crates/effects/src/group.rs @@ -265,7 +265,7 @@ fn reshaped(src: &Node, n: &Node, doc: Option<&Document>) -> Node { fn without_geometry_effects(n: &mut Node) { if n.appearance.effects.iter().any(|e| crate::is_geometry(&e.id)) { let mut ap = (*n.appearance).clone(); - ap.effects.retain(|e| !crate::is_geometry(&e.id)); + ap.effects = crate::raster::without_geometry(&ap.effects); n.appearance = ap.into(); } } @@ -794,7 +794,8 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option { carry.apply(&mut m.appearance); } } - m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id)); + m.appearance.effects = crate::raster::without_geometry(&m.appearance.effects); + m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); if paint { let shapes = member_shapes(doc, &m); let bounds = m.geometric_bounds(); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 8591bbab2c..080098ad24 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -78,7 +78,7 @@ pub use lighting_presets::{LIGHTING_PRESETS, LightingPreset, lighting_preset_par pub use live::{expand_live, expand_live_deep, expanded_live_group, outline_object, text_outliner}; pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks}; pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace}; -pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects}; +pub use raster::{RASTERIZE, RasterFx, Rasterize, color_param, outset, raster_effects, without_geometry}; pub use reshape::{ SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art, has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape, @@ -89,8 +89,8 @@ pub use revolve::{ pub use solids::{ CLASSIC_EXTRUDE, CLASSIC_ROTATE, EXTRUDE, INFLATE, ROTATE, ROTATE_SURFACES, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options, classic_shading, extrude_options, has_solid, inflate_options, is_classic_three_d, is_three_d, solid_art, solid_art_in, - three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, - validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, + three_d_meshes, three_d_meshes_in, three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, three_d_wireframe_in, validate_solid, + validate_solid_in, validate_three_d, validate_three_d_at, validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use svg_filter::SVG_FILTER; diff --git a/crates/effects/src/raster.rs b/crates/effects/src/raster.rs index cfa51ca2b0..07140e2bc9 100644 --- a/crates/effects/src/raster.rs +++ b/crates/effects/src/raster.rs @@ -1,11 +1,13 @@ //! Raster (painted) effects: descriptions for the renderer. -use serde_json::Value; +use std::borrow::Cow; + +use serde_json::{Map, Value, json}; use vectorcraft_doc::color::{BlendMode, Color}; use vectorcraft_doc::rastersettings::MAX_ADD_AROUND; use vectorcraft_doc::{Effect, RasterColorModel, RasterEffectsSettings}; -use vectorcraft_geom::{Point, Rect}; +use vectorcraft_geom::{Affine, Point, Rect, Vec2}; use crate::merged_params; use crate::pixel::{self, PixelFx, PixelSpace}; @@ -384,9 +386,10 @@ fn opacity(p: &Value) -> f32 { (num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32 } -/// The visible raster effects of `effects`, in stack order. +/// The visible raster effects of `effects`, in stack order, each following the Transform effects +/// listed after it ([`followed`]). pub fn raster_effects(effects: &[Effect]) -> Vec { - effects + followed(effects) .iter() .filter(|e| e.visible) .filter_map(|e| { @@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec { .collect() } +/// `effects` with each raster effect following the visible Transform effects listed after it +/// ([`follow_transform`]): effects apply in the order they are listed, so a Transform effect +/// transforms what the raster effects before it paint, whatever its Scale Strokes & Effects says. +/// A hidden raster effect follows too, so it draws the same when it is shown after the Transform +/// effect has been expanded. A raster effect that a Transform effect making copies or moving at +/// random follows stays as it is, as does one after the last Transform effect. Borrowed when +/// nothing changes. +pub(crate) fn followed(effects: &[Effect]) -> Cow<'_, [Effect]> { + if !effects.iter().any(|e| e.visible && e.id == "distort.transform") { + return Cow::Borrowed(effects); + } + let mut out: Option> = None; + // The linear part of the Transform effects after the effect at hand, the last one outermost; + // `None` once one of them makes copies or moves at random. + let mut after = Some(Affine::IDENTITY); + for (i, e) in effects.iter().enumerate().rev() { + if e.id == "distort.transform" { + after = after.and_then(|a| transform_step(e).map(|s| a * s)); + continue; + } + let Some(a) = after.filter(|a| *a != Affine::IDENTITY && follows(&e.id)) else { continue }; + let list = out.get_or_insert_with(|| effects.to_vec()); + if let Some(e) = list.get_mut(i) { + follow_transform(e, a); + } + } + out.map_or(Cow::Borrowed(effects), Cow::Owned) +} + +/// `effects` without their geometry effects, each raster effect following the Transform effects +/// it loses ([`followed`]): what an object, or a fill or stroke, keeps once its geometry effects +/// are applied to its art. +pub fn without_geometry(effects: &[Effect]) -> Vec { + followed(effects).iter().filter(|e| !crate::is_geometry(&e.id)).cloned().collect() +} + +/// Make the raster effects of `effects`, all listed before Transform effect `t`, follow `t` as +/// [`followed`] makes them follow it, for art that applies its effects one at a time. `false` +/// when `t` leaves them as they are. +pub(crate) fn follow_one(effects: &mut [Effect], t: &Effect) -> bool { + let Some(a) = transform_step(t).filter(|a| *a != Affine::IDENTITY) else { return false }; + let mut changed = false; + for e in effects.iter_mut().filter(|e| follows(&e.id)) { + follow_transform(e, a); + changed = true; + } + changed +} + +/// How many of the effects of `effects` that [`without_geometry`] keeps no longer follow the +/// Transform effects listed after `effects` ([`followed`]): those before the last visible Transform +/// effect among them that makes copies or moves at random. +pub(crate) fn held_back(effects: &[Effect]) -> usize { + let Some(last) = effects.iter().rposition(|e| transform_step(e).is_none()) else { return 0 }; + effects.get(..last).unwrap_or_default().iter().filter(|e| !crate::is_geometry(&e.id)).count() +} + +/// Does `e` stop the raster effects listed before it from following the Transform effects after +/// it ([`followed`]): is it a visible Transform effect that makes copies or moves at random? +pub(crate) fn holds_back(e: &Effect) -> bool { + transform_step(e).is_none() +} + +/// What `e` does to the raster effects listed before it ([`followed`]): the linear part of its map +/// when it is a visible Transform effect that moves the art once, the identity when it is no +/// Transform effect, is hidden or has Transform Objects off (the art stays where it is), and `None` +/// when it makes copies or moves at random. +fn transform_step(e: &Effect) -> Option { + if e.id != "distort.transform" || !e.visible { + return Some(Affine::IDENTITY); + } + let p = merged_params(&e.id, &e.params); + if !flag(&p, "objects", true) { + return Some(Affine::IDENTITY); + } + let (map, copies) = crate::distort::transform_map(Rect::ZERO, &p, 0); + (copies == 0 && !flag(&p, "random", false)).then(|| map.with_translation(Vec2::ZERO)) +} + +/// Does raster effect `id` follow the Transform effects listed after it ([`followed`])? An SVG +/// filter does not, and neither does Rasterize, whose image keeps its own pixel grid and room +/// around the art. +fn follows(id: &str) -> bool { + crate::is_raster(id) && id != crate::SVG_FILTER && id != RASTERIZE +} + +/// Make raster effect `e` follow `a`, the linear part of a Transform effect listed after it, as +/// that effect transforms what `e` paints: its distances and raster sizes scale by `a`'s mean +/// scale ([`crate::scale_effect`]), and a Drop Shadow's offset turns, reflects and scales with +/// `a`. Effects that [`follows`] leaves out stay as they are. +fn follow_transform(e: &mut Effect, a: Affine) { + if !follows(&e.id) { + return; + } + let offset = (e.id == "stylize.dropShadow").then(|| { + let p = merged_params(&e.id, &e.params); + a * Point::new(num(&p, "x", 7.0), num(&p, "y", 7.0)) + }); + if let Some(k) = vectorcraft_doc::Scaling::factor(a) { + crate::scale_effect(e, k); + } + if let Some(o) = offset.filter(|o| o.x.is_finite() && o.y.is_finite()) { + if !e.params.is_object() { + e.params = Value::Object(Map::new()); + } + e.params["x"] = json!(o.x); + e.params["y"] = json!(o.y); + } +} + /// How far the visible raster effects of `effects` paint beyond `bounds`, the (effected) /// geometry's, in document units. Geometry effects are accounted for by evaluating them. pub fn outset(effects: &[Effect], bounds: Rect) -> f64 { diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs index 483ac44d6e..8822548c29 100644 --- a/crates/effects/src/reshape.rs +++ b/crates/effects/src/reshape.rs @@ -604,7 +604,7 @@ fn as_art_opts( out.knockout = vectorcraft_doc::Knockout::Off; out.knockout_shape = n.knockout_shape; out.extras_mut().mask = n.extras().mask.clone(); - out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + out.appearance.effects = crate::raster::without_geometry(&n.appearance.effects); Some(out) } @@ -944,7 +944,7 @@ impl Step<'_> { // The copies of a path replace it by a group of them, the path included. let copied = |leaf: &Node| leaf.children().map_or(0, Vec::len); if options.reach_appearance() { - let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, None); *used = used.minus(before).plus(made); if moved { // With Transform Objects off the path stays, and its mask takes the @@ -960,7 +960,7 @@ impl Step<'_> { } if separate { let scaling = vectorcraft_doc::Scaling { keep_fit_when_flat: true, ..Default::default() }; - let made = copy_leaf(leaf, &path, m, copies, scaling, room); + let made = copy_leaf(leaf, &path, m, copies, scaling, room, None); *used = used.minus(before).plus(made); moved_ids.insert(leaf.id); short |= counts(leaf.id) && copied(leaf) <= copies; @@ -1245,8 +1245,9 @@ fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::S /// and the distances of the effects on its fills and strokes scale by `m`'s mean scale, and its /// pattern paints transform by `m`. With copies, the original keeps them and copy `k` takes them /// `k` times, each copy an object of its own ([`copy_leaf`], within `budget`). With Transform -/// Objects off the geometry stays and the options apply once. Returns what the leaf costs after, -/// and `false` when the path kept its shape because the moved path would pass `budget`. +/// Objects off the geometry stays and the options apply once. The effects `kept` names keep their +/// values on the leaf and on each copy ([`keep_listed`]). Returns what the leaf costs after, and +/// `false` when the path kept its shape because the moved path would pass `budget`. fn transform_leaf( leaf: &mut Node, path: &PathData, @@ -1254,6 +1255,7 @@ fn transform_leaf( copies: usize, options: crate::distort::TransformOptions, budget: Budget, + kept: Kept, ) -> (Budget, bool) { let scaling = copy_scaling(options); if !options.objects { @@ -1266,6 +1268,7 @@ fn transform_leaf( if options.patterns { vectorcraft_doc::pattern::transform_pattern_paints(leaf, m); } + keep_listed(leaf, kept); return (Budget::of(path), true); } if copies == 0 { @@ -1275,9 +1278,10 @@ fn transform_leaf( return (Budget::of(path), false); } leaf.transform(m, scaling); + keep_listed(leaf, kept); return (Budget::of(&moved), true); } - (copy_leaf(leaf, path, m, copies, scaling, budget), true) + (copy_leaf(leaf, path, m, copies, scaling, budget, kept), true) } /// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a @@ -1423,9 +1427,10 @@ fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc:: /// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the /// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass /// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's -/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original -/// included. -fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget { +/// strokes would scale past [`MAX_STROKE_SCALE`]. The effects `kept` names keep their values on +/// each copy, before its cost is counted ([`keep_listed`]). Returns what the copies cost, the +/// original included. +fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget, kept: Kept) -> Budget { let mut made = Budget::of(path).plus(effects_cost(leaf, path)); let mut placements: Vec> = vec![Arc::new(leaf.clone())]; let mut xf = Affine::IDENTITY; @@ -1438,6 +1443,7 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling } let mut copy = leaf.clone(); copy.transform(xf, scaling); + keep_listed(&mut copy, kept); // Each copy's own geometry effects make a different amount, as the copy and its effects scale. let mut cost = Budget::of(&moved).plus(effects_cost(©, &moved)); // Each copy carries the art of its linked masks, moved with it. @@ -1584,9 +1590,12 @@ fn item_makes_piece(item: &AppearanceItem) -> bool { /// effects. In paint order, each such fill or stroke is a path of its own: the leaf's geometry after /// the geometry effects of that fill or stroke before its first such Transform effect, transformed /// as [`transform_leaf`] transforms an object with that effect's map, copies and options, so the -/// stroke's weight and dashes, the distances of its remaining effects and its pattern follow the -/// map. The reference box is that geometry's bounds and the seed is the leaf's id, as the renderer -/// draws a fill's or stroke's own effects. The geometry effects after the Transform effect then +/// stroke's weight and dashes, the distances of the effects before the Transform effect and its +/// pattern follow the map, and the raster effects before it that follow it take their followed +/// values ([`follow_piece`]). The effects after it keep their values and apply to what it made +/// ([`keep_listed`]), and with Transform Objects off so do the effects before it. The reference box +/// is that geometry's bounds and the seed is the leaf's id, as the renderer draws a fill's or +/// stroke's own effects. The geometry effects after the Transform effect then /// apply to the transformed path and its copies as one piece ([`transform_piece`]), and its other /// effects stay on each of them. The other fills and strokes stay together on paths of the leaf's /// geometry between them, with their own effects. `max` is the budget of the whole art and `used` @@ -1659,9 +1668,16 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let params = merged_params(&fx[at].id, &fx[at].params); // Where each effect the piece keeps sits among the item's effects. let kept: Vec = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect(); + // A Transform effect after this one that makes copies or moves at random holds the raster + // effects before this one back from every Transform effect ([`crate::raster::followed`]). + let held_later = fx.get(at + 1..).unwrap_or_default().iter().any(crate::raster::holds_back); + // The effects before the Transform effect that it keeps, each raster effect following the + // geometry effects before it ([`crate::raster::without_geometry`]) unless held back. + let kept_before: Vec = + if held_later { before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() } else { crate::without_geometry(before) }; if let Some(plugin) = painted_plugin(&fx[at]) { let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); *it.effects_mut() = rest; let mut one = Appearance { items: vec![it.clone()], ..Default::default() }; if carry.moves(&one) { @@ -1683,7 +1699,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt group.push(Arc::new(p)); } let mut p = Node::group(leaf.id, group); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(before), limit, used, doc); pieces.push(Arc::new(p)); continue; } @@ -1691,16 +1707,22 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(tb, ¶ms, ctx.seed); let mut it = item.clone(); - let rest: Vec = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect(); - *it.effects_mut() = rest; + let rest: Vec = kept_before.iter().chain(&fx[at + 1..]).cloned().collect(); + *it.effects_mut() = rest.clone(); let mut p = piece(vec![it]); if carry.moves(&p.appearance) { carry.apply(&mut p.appearance); } set_geometry(&mut p, g.clone(), rule); - let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room); + let after = kept_before.len(); + let keep = |k: usize| k >= after || !options.objects; + let (made, moved) = transform_leaf(&mut p, &g, map, copies, options, room, Some((&rest, &keep))); *used = used.plus(made); - transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc); + let from = crate::raster::held_back(before); + if moved && !held_later { + follow_piece(&mut p, kept_before.get(from..).unwrap_or_default(), from, &fx[at]); + } + transform_piece(&mut p, (ctx.seed, i, &kept), crate::raster::held_back(&fx[..=at]), limit, used, doc); pieces.push(Arc::new(p)); } if !plain.is_empty() { @@ -1717,27 +1739,45 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt /// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece: /// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's /// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of -/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with -/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does. +/// the effect, at the effect's own distances. A Transform effect with Transform Patterns or Scale +/// Strokes & Effects on transforms each path as [`transform_leaf`] does, and leaves the values of +/// the effects after it, and with Transform Objects off those before it, as they are +/// ([`keep_listed`]). /// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path -/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and -/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art -/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source, -/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the -/// piece was made from, and the index among that item's effects of each effect the piece kept, -/// which a deferred live-effect plug-in is listed by. -fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) { +/// for each piece it returns ([`paint_piece`]). The raster effects before a Transform effect +/// follow it ([`follow_piece`]), except the first `held` effects, which a Transform effect before +/// the piece's effects that makes copies or moves at random holds back, and the effects before +/// such a Transform effect among the piece's effects. The geometry effects then +/// leave the fills and strokes; their other effects stay. The paths keep to `max` together with +/// the rest of the art (`used`), and the swatches and patterns painted pieces name must be in +/// `doc`. `(seed, source, kept)`: the object's seed, the index in the object's appearance of the +/// fill or stroke the piece was made from, and the index among that item's effects of each effect +/// the piece kept, which a deferred live-effect plug-in is listed by. +fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), held: usize, max: Budget, used: &mut Budget, doc: Option<&Document>) { let mut count = 0; for_each_leaf(p, &mut |l| { if let Some(item) = l.appearance.items.first() { count = count.max(item.effects().len()); } }); + // The effects before a Transform effect among the piece's effects that makes copies or moves at + // random are held back from the Transform effects before it too. + let mut held = held; + for_each_leaf(p, &mut |l| { + if let Some(last) = l.appearance.items.first().and_then(|item| item.effects().iter().rposition(crate::raster::holds_back)) { + held = held.max(last); + } + }); for j in 0..count { let Some(bounds) = p.geometric_bounds() else { break }; + let from = held.min(j); + let mut holds = false; for_each_leaf(p, &mut |leaf| { let Some(item) = leaf.appearance.items.first().cloned() else { return }; let Some(e) = item.effects().get(j).filter(|e| e.visible && is_geometry(&e.id)).cloned() else { return }; + holds |= crate::raster::holds_back(&e); + // The effects before this one as they were before it moved the path. + let was = item.effects().get(from..j).unwrap_or_default(); let Some((path, rule)) = leaf_geometry(leaf) else { return }; // What the other paths leave of the budget. let before = Budget::of(&path); @@ -1754,8 +1794,12 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m let (_, copies) = crate::distort::transform_map(bounds, ¶ms, seed); let options = crate::distort::TransformOptions::of(¶ms); let map = crate::distort::transform_affine(bounds, ¶ms, seed); - let (made, _) = transform_leaf(leaf, &path, map, copies, options, room); + let keep = |k: usize| k > j || (k < j && !options.objects); + let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room, Some((item.effects(), &keep))); *used = used.minus(before).plus(made); + if moved { + follow_piece(leaf, was, from, &e); + } return; } let appearance = leaf.appearance.clone(); @@ -1772,7 +1816,11 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m carry.apply(&mut leaf.appearance); } set_geometry(leaf, out, rule); + follow_piece(leaf, was, from, &e); }); + if holds { + held = held.max(j); + } } for_each_leaf(p, &mut |leaf| { for item in &mut leaf.appearance.items { @@ -1781,6 +1829,53 @@ fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), m }); } +/// The effects a Transform effect in a fill's or stroke's own list leaves as they were +/// ([`keep_listed`]): the list as it was before the Transform effect was applied, and which of its +/// indices keep their values. `None` for an object's own Transform effect. +type Kept<'a> = Option<(&'a [Effect], &'a dyn Fn(usize) -> bool)>; + +/// Put back on the fills and strokes of each path under `p` the effect `kept`'s list holds at each +/// index it takes, in place of what a Transform effect in their own list scaled or moved with its +/// options ([`transform_leaf`]). The effects after the Transform effect apply to the art it made, +/// and with Transform Objects off the effects before it keep their values too. A piece's fills and +/// strokes share one list: a live-effect plug-in that paints what it returns gives each paint a copy +/// of the fill or stroke ([`vectorcraft_plugins::painted::items`]). +fn keep_listed(p: &mut Node, kept: Kept) { + let Some((was, keep)) = kept else { return }; + for_each_leaf(p, &mut |leaf| { + for item in &mut leaf.appearance.items { + for (k, (e, old)) in item.effects_mut().iter_mut().zip(was).enumerate() { + if keep(k) { + e.clone_from(old); + } + } + } + }); +} + +/// Make the raster effects of each of `leaf`'s fills and strokes from index `from` on follow +/// Transform effect `t`, listed after them, once `t` has moved the leaf +/// ([`crate::raster::follow_one`]). The fills and strokes are copies of one fill or stroke (those a +/// painted plug-in returns, [`vectorcraft_plugins::painted::items`]), with the same effects. `was` +/// holds those effects of the first one as they were before `t` was applied: each one that +/// follows takes the value [`crate::raster::followed`] gives it, in place of what Scale Strokes & +/// Effects made of it, and the others keep theirs. +fn follow_piece(leaf: &mut Node, was: &[Effect], from: usize, t: &Effect) { + let mut follow = was.to_vec(); + if !crate::raster::follow_one(&mut follow, t) { + return; + } + for item in &mut leaf.appearance.items { + for (k, (new, old)) in follow.iter().zip(was).enumerate() { + if new != old + && let Some(e) = item.effects_mut().get_mut(from + k) + { + *e = new.clone(); + } + } + } +} + /// The bounds of each group of type's outlines under `n` ([`outline_text`]), `n` included, in /// order. A group inside one of them is part of it. fn type_outline_bounds(n: &Node, out: &mut Vec>) { diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index ec9563f05c..d82e4e8818 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -8,7 +8,9 @@ use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, FillLayer, L use vectorcraft_geom::{PathData, PathEl, Point, Rect, Vec2}; use vectorcraft_three_d::{Edge, Revolve}; -use crate::solids::{Profile, Scope, is_container, members, object_profile, profile, reshape_members, resolve_for, solid_paints, union_bounds}; +use crate::solids::{ + Profile, Scope, is_container, members, object_profile, past_effect, profile, reshape_members, resolve_for, solid_paints, union_bounds, +}; use crate::util::{flag, num, text}; use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params}; @@ -507,6 +509,60 @@ pub(crate) fn revolve_meshes(n: &Node, total: &mut usize, limit: usize) -> Resul Ok(out) } +/// The outline of every face [`revolve_art`] draws for `n`'s Revolve or Revolve (Classic) (its symbol +/// 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`. +pub(crate) fn revolve_face_paths(doc: Option<&Document>, n: &Node) -> Result, String> { + let faces = |path: &PathData, b: Rect, options: Revolve| -> Result, String> { + Ok(vectorcraft_three_d::revolve(path, b, options)?.iter().map(vectorcraft_three_d::Face::path).collect()) + }; + if is_container(n) { + let index = n + .appearance + .effects + .iter() + .position(|e| e.visible && is_revolve(&e.id)) + .ok_or("a 3D effect on a group's fill or stroke draws nothing")?; + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let mut shapes = members(n, "Revolve")?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + let b = union_bounds(&shapes).ok_or("Revolve requires a group holding a shape with at least two anchors")?; + let options = shared_options(&shapes, fx_options(fx))?; + let mut out = vec![]; + for s in shapes.iter().filter(|s| surface_item(&s.items).is_some()) { + out.extend(faces(&s.path, b, options)?); + } + return Ok(out); + } + let (path, _) = object_profile(n, "Revolve")?; + let ctx = GeomContext::of(n); + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && is_revolve(&e.id)) { + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + if surface_item(&solid_paints(n)).is_none() { + return Err("the revolved object has no fill or stroke".into()); + } + let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + return faces(&path, b, fx_options(fx)); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && is_revolve(&e.id)) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let b = p.bounds().ok_or("Revolve requires a shape with at least two anchors")?; + let surface = faces(&p, b, fx_options(fx))?; + out.extend(crate::solids::wireframe::reshaped(surface, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + /// [`revolve_meshes`] of group or layer `n`: one mesh for each member, about one shared axis. fn group_meshes(n: &Node, total: &mut usize, limit: usize) -> Result, String> { let index = n @@ -558,17 +614,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option { } children.push(Arc::new(surface)); // Later geometry acts on the projected art as a whole through container evaluation. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| !is_revolve(&e.id) && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); + remaining.retain(|e| !is_revolve(&e.id)); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining.retain(|e| !is_geometry(&e.id)); + remaining = crate::without_geometry(&remaining); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { @@ -835,8 +885,7 @@ fn group_art(n: &Node, expand: bool) -> Option { // One shadow for every member's surface, behind them all. let mut children = children; children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 538d2a5028..9e7578d6d7 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -22,6 +22,9 @@ pub(crate) use source::{resolve, resolve_for}; pub(crate) mod shadow; +pub(crate) mod wireframe; +pub use wireframe::three_d_wireframe_in; + pub const EXTRUDE: &str = "threeD.extrude"; pub const INFLATE: &str = "threeD.inflate"; pub const ROTATE: &str = "threeD.rotate"; @@ -978,17 +981,10 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option { let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?; children.extend(shadow.into_iter().chain(surfaces)); // Later geometry effects act on the projected art as a whole. - remaining = n - .appearance - .effects - .iter() - .enumerate() - .filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)) - .map(|(_, e)| e.clone()) - .collect(); + remaining = past_effect(&n.appearance.effects, index); } else { let path = geometry(&path, &n.appearance.effects, &ctx); - remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + remaining = crate::without_geometry(&n.appearance.effects); // Every item's shadow goes behind every item's art. let mut shadows = vec![]; for item in &paints { @@ -1024,14 +1020,28 @@ fn group_solid_art(n: &Node) -> Option { reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let (shadow, surfaces) = art_for(n, fx, &shapes)?; let children = shadow.into_iter().chain(surfaces).collect(); - let remaining = - n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); + let remaining = past_effect(&n.appearance.effects, index); let mut art = n.clone(); art.kind = NodeKind::Group { children, clip: false }; art.appearance = Appearance { effects: remaining, ..Default::default() }.into(); Some(art) } +/// `effects` without the 3D effect at `index` and the geometry effects before it, which shape the +/// art it projects. Each raster effect before it follows the Transform effects among those +/// ([`crate::without_geometry`]), unless a Transform effect after it makes copies or moves at +/// random ([`crate::raster::holds_back`]); the effects after it stay as they are. +pub(crate) fn past_effect(effects: &[Effect], index: usize) -> Vec { + let (before, after) = effects.split_at(index.min(effects.len())); + let after = after.get(1..).unwrap_or_default(); + let kept: Vec = if after.iter().any(crate::raster::holds_back) { + before.iter().filter(|e| !is_geometry(&e.id)).cloned().collect() + } else { + crate::without_geometry(before) + }; + kept.into_iter().chain(after.iter().cloned()).collect() +} + /// One solid of a 3D object as a mesh, with the color it is painted. #[derive(Clone, Debug, PartialEq)] pub struct SolidMesh { diff --git a/crates/effects/src/solids/wireframe.rs b/crates/effects/src/solids/wireframe.rs new file mode 100644 index 0000000000..f1dc324679 --- /dev/null +++ b/crates/effects/src/solids/wireframe.rs @@ -0,0 +1,276 @@ +//! Expand as Wireframes: the edges of the faces a 3D effect draws, as open and closed paths that +//! can be edited on their own. +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; + +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Anchor, CubicBez, PathData, Point, SubPath}; +use vectorcraft_three_d::Facet; + +use super::{ + INFLATE, Profile, ROTATE, facets, geometry, has_solid, inflate_options, is_container, is_plane, is_three_d, label_of, members, object_profile, + region, reshape_members, resolve, resolve_for, solid, solid_paints, solid_parts, union_bounds, view_options, +}; +use crate::{GeomContext, is_geometry}; + +/// The most distinct edges one object's wireframe may have. +pub(crate) const MAX_WIRE_EDGES: usize = 1_000_000; + +/// The grid points are rounded to before edges are compared, in points: two faces that share an +/// edge give it once. +const GRID: f64 = 1.0 / 1024.0; + +const NO_EFFECT: &str = "the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)"; + +/// The wireframe of `n`'s 3D effect (Extrude & Bevel, Revolve, Inflate, Rotate or one of their 3D +/// (Classic) forms), in document coordinates: the edges of every face the effect draws for the object as +/// it is turned and projected, each edge once. Edges that meet end to end are joined into one path; +/// a run that comes back to where it started is closed. A curved edge stays a curve. The geometry +/// effects after the object's 3D effect reshape the wireframe as they reshape the drawn art. On a +/// group or layer, the edges of the solids of all its members. An error when `n` has no visible 3D +/// effect, when its solids can't be built, when the canvas draws the source art in their place, when +/// they draw no face, or past [`MAX_WIRE_EDGES`] edges. +pub fn three_d_wireframe_in(doc: Option<&Document>, n: &Node) -> Result, String> { + if !crate::has_revolve(n) && !has_solid(n) { + return Err(NO_EFFECT.into()); + } + let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id)); + // The faces come from the effect whose art the canvas draws (Revolve first, as the canvas tries + // it first). When neither draws, past their limits, the canvas shows the source art and there + // is no surface to expand. + let revolve = crate::has_revolve(n) && crate::revolve_art_in(doc, n).is_some(); + if !revolve && !(has_solid(n) && super::solid_art_in(doc, n).is_some()) { + // The builders' own error says why, when there is one. Revolve's art is cut for Revolve, + // as the canvas cuts it. + if crate::has_revolve(n) { + crate::revolve::revolve_face_paths(doc, &*resolve_for(doc, n, label, false)?)?; + } else { + solid_face_paths(doc, &*resolve(doc, n, label)?)?; + } + return Err(format!("{label} draws the source art for this object instead of its 3D surface")); + } + // Revolve's art is cut for Revolve whatever other 3D effect the object carries, as the canvas + // cuts it ([`crate::revolve_art_in`]). + let n = &*if revolve { resolve_for(doc, n, label, false)? } else { resolve(doc, n, label)? }; + let faces = if revolve { crate::revolve::revolve_face_paths(doc, n)? } else { solid_face_paths(doc, n)? }; + let faces = after_geometry(doc, n, faces, revolve); + let wire = edges(&faces)?; + if wire.is_empty() { + return Err(format!("{label} draws no faces for this object")); + } + Ok(wire) +} + +/// `faces` reshaped by the visible geometry effects after the 3D effect of `n`'s own that drew them +/// ([`reshaped`]): its first visible Revolve when `revolve` is set, else its first visible Extrude & +/// Bevel, Inflate or Rotate. +fn after_geometry(doc: Option<&Document>, n: &Node, faces: Vec, 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) }) +} + +/// `faces` reshaped by the visible geometry effects in `later` (the effects after a 3D effect), as +/// they reshape the faces of the drawn surface ([`crate::reshape::reshape_leaves`]): each effect +/// reshapes each face on its own, about the bounds all the faces have before it, and the faces keep +/// to one budget of anchors together. `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 { + let after: Vec<&Effect> = later.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if after.is_empty() { + return faces; + } + let id = NodeId(ctx.seed); + let fill = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0); + let mut art = Node::group(id, faces.into_iter().map(|f| Arc::new(Node::path(id, f, fill.clone()))).collect()); + crate::reshape::reshape_leaves(&mut art, &after, later, ctx.doc); + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +/// 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> { + 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); + } + 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)?; + let ctx = GeomContext::of(n); + let paints = solid_paints(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 path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + let faces = facet_paths(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?; + out.extend(reshaped(faces, item.effects().get(index + 1..).unwrap_or_default(), &GeomContext { doc, ..ctx })); + } + } + Ok(out) +} + +/// The outlines of the facets effect `fx` builds from `shapes`, as `art_for` builds them: Rotate and +/// Rotate (Classic) turn every fill and stroke; Extrude & Bevel and Inflate build a solid from each shape's +/// top fill and from the outline of each stroke. A stroke whose solid is past the limits is left out, +/// as it is left out of the drawn art. +fn facet_paths(fx: &Effect, shapes: &[Profile]) -> Result, String> { + let label = label_of(&fx.id); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let mut out = vec![]; + if is_plane(&fx.id) { + for s in shapes { + for item in &s.items { + let (area, rule) = region(&s.path, s.rule, item); + out.extend(facets(ROTATE, &area, rule, b, &p)?.iter().map(Facet::path)); + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + let built: Vec, String>> = if fx.id == INFLATE { + let shapes: Vec<(&PathData, vectorcraft_geom::FillRule)> = parts.iter().map(|(_, _, area, rule, ..)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p)) + } else { + parts.iter().map(|(_, _, area, rule, ..)| facets(&fx.id, area, *rule, bounds, &p)).collect() + }; + for ((item, ..), f) in parts.iter().zip(built) { + match f { + Ok(f) => out.extend(f.iter().map(Facet::path)), + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// A point on the [`GRID`]. +type Snap = (i64, i64); + +fn snap(p: Point) -> Snap { + ((p.x / GRID).round() as i64, (p.y / GRID).round() as i64) +} + +fn reversed(c: CubicBez) -> CubicBez { + CubicBez::new(c.p3, c.p2, c.p1, c.p0) +} + +/// The edges of `faces`, each once, joined end to end into paths ([`three_d_wireframe_in`]). +pub(crate) fn edges(faces: &[PathData]) -> Result, String> { + let mut seen: HashSet<[Snap; 4]> = HashSet::new(); + let mut segments: Vec = vec![]; + for sp in faces.iter().flat_map(|f| &f.subpaths) { + for i in 0..sp.segment_count() { + let mut c = sp.segment(i); + if sp.segment_is_line(i) { + c = CubicBez::new(c.p0, c.p0, c.p3, c.p3); + } + if [c.p0, c.p1, c.p2, c.p3].iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + continue; + } + let key = [c.p0, c.p1, c.p2, c.p3].map(snap); + // A segment that starts and ends on one grid point with its handles there too draws nothing. + if key.iter().all(|k| *k == key[0]) { + continue; + } + let back = [key[3], key[2], key[1], key[0]]; + if seen.contains(&back) || !seen.insert(key) { + continue; + } + if segments.len() >= MAX_WIRE_EDGES { + return Err(format!("the wireframe has more than {MAX_WIRE_EDGES} edges")); + } + segments.push(c); + } + } + Ok(chain(&segments)) +} + +/// `segments` joined end to end: each path starts at the first segment not yet used and takes in +/// the unused segments that meet its ends until none is left at either end, or it closes. +fn chain(segments: &[CubicBez]) -> Vec { + let mut at: HashMap> = HashMap::new(); + for (i, c) in segments.iter().enumerate() { + at.entry(snap(c.p0)).or_default().push(i); + if snap(c.p3) != snap(c.p0) { + at.entry(snap(c.p3)).or_default().push(i); + } + } + let mut used = vec![false; segments.len()]; + // The next unused segment at `point`, turned to leave it (`leave`) or to arrive at it. + let mut take = |used: &mut Vec, point: Snap, leave: bool| -> Option { + let list = at.get_mut(&point)?; + while let Some(&i) = list.last() { + if used.get(i).copied().unwrap_or(true) { + list.pop(); + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let c = *segments.get(i)?; + let starts = snap(c.p0) == point; + return Some(if starts == leave { c } else { reversed(c) }); + } + None + }; + let mut out = vec![]; + for (i, first) in segments.iter().enumerate() { + if used.get(i).copied().unwrap_or(true) { + continue; + } + if let Some(u) = used.get_mut(i) { + *u = true; + } + let mut run: VecDeque = VecDeque::from([*first]); + // A path of one anchor has no segment (`SubPath::segment_count`), so a run of one segment + // that comes back to its start stays open, with its two ends on one point. + let closes = |run: &VecDeque| run.len() > 1 && run.front().zip(run.back()).is_some_and(|(f, b)| snap(f.p0) == snap(b.p3)); + while !closes(&run) { + let Some(end) = run.back().map(|c| snap(c.p3)) else { break }; + let Some(c) = take(&mut used, end, true) else { break }; + run.push_back(c); + } + while !closes(&run) { + let Some(start) = run.front().map(|c| snap(c.p0)) else { break }; + let Some(c) = take(&mut used, start, false) else { break }; + run.push_front(c); + } + out.push(PathData::single(subpath(&run, closes(&run)))); + } + out +} + +/// `run`, segments that meet end to end, as one subpath; a closed one ends at its first anchor. +fn subpath(run: &VecDeque, closed: bool) -> SubPath { + let mut anchors: Vec = vec![]; + if let Some(first) = run.front() { + let h_in = if closed { run.back().map_or(first.p0, |b| b.p2) } else { first.p0 }; + anchors.push(Anchor::with_handles(first.p0, h_in, first.p1)); + } + for (k, c) in run.iter().enumerate() { + let h_out = run.get(k + 1).map_or(c.p3, |next| next.p1); + anchors.push(Anchor::with_handles(c.p3, c.p2, h_out)); + } + // A closed run's last anchor is its first, whose in handle already comes from the last segment. + if closed { + anchors.pop(); + } + SubPath::new(anchors, closed) +} + +#[cfg(test)] +mod tests; diff --git a/crates/effects/src/solids/wireframe/tests.rs b/crates/effects/src/solids/wireframe/tests.rs new file mode 100644 index 0000000000..5ea4423ab3 --- /dev/null +++ b/crates/effects/src/solids/wireframe/tests.rs @@ -0,0 +1,235 @@ +use serde_json::{Value, json}; +use vectorcraft_doc::color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Node, NodeId}; +use vectorcraft_geom::{BezPath, Rect}; + +use super::*; + +fn square(x: f64, y: f64, size: f64) -> PathData { + let mut b = BezPath::new(); + b.move_to((x, y)); + b.line_to((x + size, y)); + b.line_to((x + size, y + size)); + b.line_to((x, y + size)); + b.close_path(); + PathData::from_bezpath(&b) +} + +/// A filled 100 pt square without a stroke, with effect `id` and `params`. +fn solid_square(id: &str, params: Value) -> Node { + let mut n = Node::path(NodeId(1), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap()); + n +} + +/// Every segment of `wire`, each as its two ends on the grid. +fn segments(wire: &[PathData]) -> Vec<(Snap, Snap)> { + wire.iter() + .flat_map(|p| &p.subpaths) + .flat_map(|sp| (0..sp.segment_count()).map(move |i| sp.segment(i))) + .map(|c| (snap(c.p0), snap(c.p3))) + .collect() +} + +fn bounds(paths: &[PathData]) -> Rect { + paths.iter().filter_map(PathData::bounds).reduce(|a, b| a.union(b)).unwrap() +} + +/// The paths of the faces `art` draws (every leaf of the art). +fn face_paths(art: &Node) -> Vec { + let mut out = vec![]; + art.walk(&mut |c| out.extend(c.path_data().cloned())); + out +} + +#[test] +fn two_faces_that_share_an_edge_give_it_once() { + let wire = edges(&[square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)]).unwrap(); + let segs = segments(&wire); + assert_eq!(segs.len(), 7, "{segs:?}"); + for (i, a) in segs.iter().enumerate() { + for b in segs.iter().skip(i + 1) { + assert!(a != b && (a.1, a.0) != *b, "{a:?} twice"); + } + } +} + +#[test] +fn a_loop_of_edges_is_one_closed_path_and_a_curve_stays_a_curve() { + let wire = edges(&[square(0.0, 0.0, 10.0)]).unwrap(); + assert_eq!(wire.len(), 1); + assert!(wire[0].subpaths[0].closed); + assert_eq!(wire[0].subpaths[0].anchors.len(), 4); + + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.curve_to((0.0, 10.0), (10.0, 10.0), (10.0, 0.0)); + b.close_path(); + let wire = edges(&[PathData::from_bezpath(&b)]).unwrap(); + let curved = wire.iter().flat_map(|p| &p.subpaths).any(|sp| (0..sp.segment_count()).any(|i| !sp.segment_is_line(i))); + assert!(curved, "{wire:?}"); +} + +/// A single curve that comes back to its start keeps its segment: it stays open, with two anchors on +/// one point, since a path of one anchor has no segment. +#[test] +fn a_single_curve_that_comes_back_to_its_start_keeps_its_segment() { + let p = Point::new(0.0, 0.0); + let anchors = vec![Anchor::with_handles(p, p, Point::new(0.0, 10.0)), Anchor::with_handles(p, Point::new(10.0, 10.0), p)]; + let face = PathData::single(SubPath::new(anchors, false)); + assert_eq!(face.subpaths[0].segment_count(), 1); + let wire = edges(&[face]).unwrap(); + assert_eq!(wire.len(), 1); + let sp = &wire[0].subpaths[0]; + assert_eq!(sp.segment_count(), 1, "{sp:?}"); + assert!(!sp.segment_is_line(0)); +} + +#[test] +fn edges_without_length_or_with_non_finite_points_are_left_out() { + let point = PathData::single(SubPath::polyline(&[Point::new(5.0, 5.0), Point::new(5.0, 5.0)], false)); + let bad = PathData::single(SubPath::polyline(&[Point::new(f64::NAN, 0.0), Point::new(1.0, 1.0)], false)); + assert!(edges(&[point, bad]).unwrap().is_empty()); + assert!(edges(&[]).unwrap().is_empty()); +} + +#[test] +fn a_box_turned_off_axis_shows_the_nine_edges_of_its_three_faces() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert_eq!(segments(&wire).len(), 9, "{wire:?}"); + // The wireframe covers what the effect draws, in the same place. + let art = super::super::solid_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } +} + +#[test] +fn the_classic_wireframe_surface_expands_to_all_twelve_edges() { + let n = solid_square(super::super::CLASSIC_EXTRUDE, json!({"surface": "wireframe"})); + assert_eq!(segments(&three_d_wireframe_in(None, &n).unwrap()).len(), 12); +} + +#[test] +fn revolve_inflate_and_rotate_expand_too() { + for id in [crate::REVOLVE, super::super::INFLATE, ROTATE] { + let wire = three_d_wireframe_in(None, &solid_square(id, json!({}))).unwrap_or_else(|e| panic!("{id}: {e}")); + assert!(!wire.is_empty(), "{id}"); + assert!(wire.iter().flat_map(|p| &p.subpaths).flat_map(|sp| &sp.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite()), "{id}"); + } + // Rotate is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// Revolve (Classic) and Rotate (Classic) expand to the edges of the faces they draw, in the same +/// place. +#[test] +fn revolve_classic_and_rotate_classic_expand_too() { + let n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let wire = three_d_wireframe_in(None, &n).unwrap(); + assert!(!wire.is_empty()); + let art = crate::revolve_art(&n).unwrap(); + let (w, a) = (bounds(&wire), bounds(&face_paths(&art))); + for (x, y) in [(w.x0, a.x0), (w.y0, a.y0), (w.x1, a.x1), (w.y1, a.y1)] { + assert!((x - y).abs() < 1e-6, "{w:?} {a:?}"); + } + // On a fill, with a geometry effect after it. + let mut n = solid_square(crate::CLASSIC_REVOLVE, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap()); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{plain:?} {moved:?}"); + // Rotate (Classic) is one flat face: its outline. + let plane = three_d_wireframe_in(None, &solid_square(super::super::CLASSIC_ROTATE, json!({}))).unwrap(); + assert_eq!(segments(&plane).len(), 4); +} + +/// The geometry effects after a 3D effect reshape each face on its own, as they reshape each face +/// of the drawn surface: two faces that touch stay two outlines. +#[test] +fn later_geometry_effects_reshape_each_face_on_its_own() { + let n = solid_square(super::super::EXTRUDE, json!({})); + let fx = [crate::new_effect("path.offsetPath", &json!({"offset": 1.0})).unwrap()]; + let out = reshaped(vec![square(0.0, 0.0, 10.0), square(10.0, 0.0, 10.0)], &fx, &GeomContext::of(&n)); + assert_eq!(out.len(), 2, "{out:?}"); + let (a, b) = (out[0].bounds().unwrap(), out[1].bounds().unwrap()); + assert!((a.x0 + 1.0).abs() < 1e-3 && (a.x1 - 11.0).abs() < 1e-3, "{a:?}"); + assert!((b.x0 - 9.0).abs() < 1e-3 && (b.x1 - 21.0).abs() < 1e-3, "{b:?}"); +} + +/// A geometry effect after a fill's 3D effect moves that fill's edges, as it moves its drawn surface. +#[test] +fn a_geometry_effect_after_a_fill_s_3d_effect_reshapes_its_edges() { + for id in [super::super::EXTRUDE, crate::REVOLVE] { + let mut n = solid_square(id, json!({})); + let fx = n.appearance.effects.remove(0); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(fx.clone()); + } + } + let plain = bounds(&three_d_wireframe_in(None, &n).unwrap_or_else(|e| panic!("{id}: {e}"))); + for item in &mut n.appearance.items { + if matches!(item, AppearanceItem::Fill(_)) { + item.effects_mut().push(crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + } + } + let moved = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((moved.x0 - plain.x0 - 50.0).abs() < 1e-6 && (moved.y0 - plain.y0).abs() < 1e-6, "{id}: {plain:?} {moved:?}"); + } +} + +/// 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] +fn a_geometry_effect_before_the_drawn_3d_effect_is_not_applied_again() { + let mut n = solid_square(crate::REVOLVE, json!({})); + n.appearance.effects.insert(0, crate::new_effect("distort.transform", &json!({"moveH": 50.0})).unwrap()); + let alone = bounds(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.insert(0, crate::new_effect(super::super::EXTRUDE, &json!({})).unwrap()); + let both = bounds(&three_d_wireframe_in(None, &n).unwrap()); + assert!((both.x0 - alone.x0).abs() < 1e-6 && (both.x1 - alone.x1).abs() < 1e-6, "{alone:?} {both:?}"); +} + +/// A clipping group with Revolve and Rotate draws Revolve, which cuts the art's fills as one piece +/// in the top fill's place. Its wireframe is cut the same way, so art with more fills than a 3D +/// effect takes members still expands, to the edges of Revolve alone. +#[test] +fn a_clipping_group_with_revolve_and_rotate_expands_the_art_revolve_cuts() { + let mut art = Node::path(NodeId(3), square(100.0, 100.0, 100.0), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0)); + let fill = art.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).cloned().unwrap(); + art.appearance.items = vec![fill; super::super::MAX_MEMBERS + 4]; + let mut clip = Node::path(NodeId(2), square(100.0, 100.0, 50.0), Appearance::default()); + if let vectorcraft_doc::NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let mut n = Node::new(NodeId(1), vectorcraft_doc::NodeKind::Group { children: vec![Arc::new(clip), Arc::new(art)], clip: true }); + n.appearance.effects.push(crate::new_effect(crate::REVOLVE, &json!({})).unwrap()); + let alone = segments(&three_d_wireframe_in(None, &n).unwrap()); + n.appearance.effects.push(crate::new_effect(ROTATE, &json!({})).unwrap()); + assert!(crate::revolve_art_in(None, &n).is_some(), "the canvas draws Revolve"); + let both = segments(&three_d_wireframe_in(None, &n).unwrap()); + assert_eq!(both, alone); +} + +#[test] +fn an_object_without_a_visible_3d_effect_is_an_error() { + let mut n = solid_square(super::super::EXTRUDE, json!({})); + n.appearance.effects[0].visible = false; + assert!(three_d_wireframe_in(None, &n).unwrap_err().contains("no 3D effect")); + n.appearance.effects.clear(); + assert!(three_d_wireframe_in(None, &n).is_err()); +} diff --git a/crates/effects/src/stroke/arrow.rs b/crates/effects/src/stroke/arrow.rs index acb980204c..d188bcafd6 100644 --- a/crates/effects/src/stroke/arrow.rs +++ b/crates/effects/src/stroke/arrow.rs @@ -4,7 +4,8 @@ //! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner //! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by //! the inset; with [`ArrowAlign::Extend`] the path keeps its length and the tip sits the inset -//! past the end point. Heads added after the first ten are drawn in local units ([`local`]). +//! past the end point. Heads added after the first ten are drawn in local units ([`local`]), as are +//! custom heads, whose stroke ends halfway along their length. use std::borrow::Cow; @@ -37,8 +38,14 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> let last = subs.last().filter(|s| !s.1).map(|s| s.0.clone()); let cap = cap_extent(st.cap, st.width); let (hw0, hw1) = (st.arrow_weight(false), st.arrow_weight(true)); - let start = st.start_arrow.zip(first).map(|(k, r)| (k, r, inset(k, hw0, cap))); - let end = st.end_arrow.zip(last).map(|(k, r)| (k, r, inset(k, hw1, cap))); + let start = st.start_arrow.clone().zip(first).map(|(k, r)| { + let i = inset(&k, hw0, cap); + (k, r, i) + }); + let end = st.end_arrow.clone().zip(last).map(|(k, r)| { + let i = inset(&k, hw1, cap); + (k, r, i) + }); if start.is_none() && end.is_none() { return StrokePieces { line: Cow::Borrowed(bp), heads: vec![] }; } @@ -56,7 +63,8 @@ pub(super) fn pieces<'a>(bp: &'a BezPath, st: &StrokeLayer) -> StrokePieces<'a> } else { (end_pt + outward * inset, outward) }; - heads.push(Arrow { kind, outline: shape(kind, tip, dir, hw), tip, dir, inset }); + let outline = shape(&kind, tip, dir, hw); + heads.push(Arrow { kind, outline, tip, dir, inset }); }; if let Some((k, r, i)) = start { place(k, r, true, i, hw0); @@ -181,12 +189,12 @@ pub(super) fn trims(st: &StrokeLayer) -> (f64, f64) { return (0.0, 0.0); } let cap = cap_extent(st.cap, st.width); - let inset_of = |kind: Option, end: bool| kind.map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); - (inset_of(st.start_arrow, false), inset_of(st.end_arrow, true)) + let inset_of = |kind: &Option, end: bool| kind.as_ref().map_or(0.0, |k| inset(k, st.arrow_weight(end), cap)); + (inset_of(&st.start_arrow, false), inset_of(&st.end_arrow, true)) } /// Distance from the tip where the stroke ends, for a stroke whose cap reaches `cap` past its end. -fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { +fn inset(kind: &Arrowhead, hw: f64, cap: f64) -> f64 { let l = len(hw); // Solid heads: halfway in, far enough that the cap stays behind the tip. let solid = |back: f64| (back / 2.0).max(cap); @@ -199,6 +207,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { Arrowhead::TriangleOpen | Arrowhead::CircleOpen | Arrowhead::SquareOpen => hollow(l), // The inner corner of the chevron: arm / sin(half angle). Arrowhead::ArrowOpen => (arm(hw) * (l * l + l * l / 4.0).sqrt() / (l / 2.0)).max(cap), + Arrowhead::Custom(c) => (c.stop() * hw).max(cap), _ => match local_stop(kind) { Stop::At(u) => (u * hw).max(cap), Stop::Wall(back) => hollow(back * hw), @@ -207,7 +216,7 @@ fn inset(kind: Arrowhead, hw: f64, cap: f64) -> f64 { } /// Outline of a `kind` head of weight `hw` with its tip at `tip`, pointing along `dir`. -fn shape(kind: Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { +fn shape(kind: &Arrowhead, tip: Point, dir: Vec2, hw: f64) -> BezPath { let n = Vec2::new(-dir.y, dir.x); let l = len(hw); let half = l / 2.0; @@ -265,7 +274,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2; const LOCAL_TOL: f64 = 1e-3; /// Where the stroke ends under a head drawn in local units ([`local`]), in those units. -fn local_stop(kind: Arrowhead) -> Stop { +fn local_stop(kind: &Arrowhead) -> Stop { match kind { Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0), Arrowhead::HexagonOpen => Stop::Wall(HEX), @@ -288,8 +297,8 @@ fn local_stop(kind: Arrowhead) -> Stop { /// The outline of a head added after the first ten, in local units: x from the tip (0) back /// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2, /// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape -/// is generated here. -fn local(kind: Arrowhead) -> BezPath { +/// is generated here, and a custom head's outline is its own. +fn local(kind: &Arrowhead) -> BezPath { let mut p = BezPath::new(); let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>()); let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::>(), ring(1.0)); @@ -385,6 +394,7 @@ fn local(kind: Arrowhead) -> BezPath { let t = FRAC_1_SQRT_2; poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]); } + Arrowhead::Custom(c) => p = c.path.to_bezpath(), // The first ten are drawn by `shape`. Arrowhead::Triangle | Arrowhead::TriangleOpen diff --git a/crates/effects/src/stroke/dash.rs b/crates/effects/src/stroke/dash.rs index 400f3b58b9..f174069c4e 100644 --- a/crates/effects/src/stroke/dash.rs +++ b/crates/effects/src/stroke/dash.rs @@ -134,6 +134,10 @@ impl Plan { fn fitted(mut segs: Vec, mut lens: Vec, closed: bool, period: f64) -> Self { let n = segs.len(); + // A subpath with no segments (a lone moveto) has no runs to fit. + if n == 0 { + return Self::exact(segs, lens, closed, period); + } // Corners: where segments with length meet at a kink (zero-length ones have no direction). let live: Vec = (0..n).filter(|&i| lens[i] > EPS).collect(); let mut corners: Vec = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect(); diff --git a/crates/effects/src/stroke/gradient.rs b/crates/effects/src/stroke/gradient.rs index ec54be20cd..ffd128b6dd 100644 --- a/crates/effects/src/stroke/gradient.rs +++ b/crates/effects/src/stroke/gradient.rs @@ -32,6 +32,12 @@ const SHARP: f64 = 1.25; const HAIRPIN: f64 = 4.0; /// Rows of a mesh across the stroke (more where the gradient has stops). const MESH_ROWS: usize = 16; +/// The most stops of the gradient [`gradient_meshes`] puts rows or columns at +/// ([`Gradient::fitted`]: past it, the stops that change the colors least are left out). +pub(crate) const MAX_MESH_STOPS: usize = 1024; +/// The most points of one mesh [`gradient_meshes`] makes, unless a single pair of ribs needs more: +/// a subpath with more ribs than leave room for a row at each stop becomes several meshes. +pub(crate) const MAX_MESH_POINTS: usize = 1 << 20; /// The most of a subpath one column of a mesh along the stroke spans. const MESH_STEP: f64 = 1.0 / 32.0; /// The most of a subpath one slice spans while a width profile shapes the across gradient. @@ -513,26 +519,33 @@ fn mesh_marks(g: &Gradient) -> Vec { /// Outline Stroke of stroke `st` of `bp` (filled with `rule`) whose gradient `g` runs along or /// across it: a gradient mesh per subpath over all the stroke can paint there (clip it to the -/// stroke's outline), coloured as [`gradient_slices`] paint it. +/// stroke's outline), coloured as [`gradient_slices`] paint it. The gradient's stops are fitted to +/// [`MAX_MESH_STOPS`], and a subpath whose mesh would hold more than [`MAX_MESH_POINTS`] points is +/// split into meshes of fewer ribs ([`rib_pieces`]). pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradient) -> Vec { - let g = &*g.unrolled(); + let fitted = g.fitted(MAX_MESH_STOPS); let across = st.gradient_mode == StrokeGradientMode::Across; - let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); - let at_stops = mesh_marks(g); // Along: a column at each stop (colours are linear between columns), close enough to look smooth. - let marks: Vec = if across { vec![] } else { at_stops.clone() }; + let marks: Vec = if across { vec![] } else { mesh_marks(&fitted) }; let max_dt = match (across, st.profile.is_some()) { (false, _) => MESH_STEP, (true, true) => PROFILE_STEP, (true, false) => 1.0, }; // Rows across: the strip's edges, then (across) the band's left edge to its right edge with a row at each stop. - let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(at_stops).collect() } else { vec![0.5] }; + let mut us: Vec = if across { (0..=MESH_ROWS).map(|i| i as f64 / MESH_ROWS as f64).chain(mesh_marks(&fitted)).collect() } else { vec![0.5] }; us.sort_by(f64::total_cmp); us.dedup_by(|a, b| (*a - *b).abs() < 1e-6); + let us = &us; + let g = &*fitted; + let color = |t: f64| g.sample_keep_model(t.clamp(0.0, 1.0) as f32); + // A mesh is a point for each row (and its two edges) on each rib: a strip with more ribs than + // that leaves room for is split into meshes of fewer ribs. + let per_mesh = MAX_MESH_POINTS / (us.len() + 2); strips(bp, rule, st, SLICE_TOL, SLICE_MARGIN, &marks, max_dt) .into_iter() .filter(|ribs| ribs.len() > 1) + .flat_map(|ribs| rib_pieces(ribs.len(), per_mesh).into_iter().map(move |r| ribs.get(r).unwrap_or_default().to_vec())) .map(|ribs| { let (rows, cols) = (us.len() + 1, ribs.len() - 1); // Each level across: a point on each rib and the gradient position there. @@ -569,3 +582,11 @@ pub fn gradient_meshes(bp: &BezPath, rule: FillRule, st: &StrokeLayer, g: &Gradi }) .collect() } + +/// The ribs (index ranges of `n`, at least two) of each mesh [`gradient_meshes`] makes of a strip: +/// at most `per_mesh` ribs each (at least two), each starting on the last rib of the one before, +/// so the meshes meet without a gap. +pub(crate) fn rib_pieces(n: usize, per_mesh: usize) -> Vec> { + let per_mesh = per_mesh.max(2); + (0..n.saturating_sub(1)).step_by(per_mesh - 1).map(|start| start..(start + per_mesh).min(n)).collect() +} diff --git a/crates/effects/src/stroke/tests.rs b/crates/effects/src/stroke/tests.rs index fc3f2eee48..1c79fd4805 100644 --- a/crates/effects/src/stroke/tests.rs +++ b/crates/effects/src/stroke/tests.rs @@ -280,7 +280,7 @@ fn no_heads_borrows_the_path_and_closed_paths_get_none() { #[test] fn tip_mode_shortens_the_stroke_by_the_head_and_puts_the_tip_on_the_end_point() { for kind in Arrowhead::ALL { - let st = arrows(kind, ArrowAlign::Tip, LineCap::Butt); + let st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Butt); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let [h] = &p.heads[..] else { panic!("one head") }; @@ -335,7 +335,7 @@ fn extend_mode_keeps_the_path_and_overhangs_by_the_inset() { #[test] fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { for (kind, cap) in [(Arrowhead::CircleOpen, LineCap::Round), (Arrowhead::SquareOpen, LineCap::Square), (Arrowhead::TriangleOpen, LineCap::Butt)] { - let st = arrows(kind, ArrowAlign::Tip, cap); + let st = arrows(kind.clone(), ArrowAlign::Tip, cap); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); let h = &p.heads[0]; @@ -361,7 +361,7 @@ fn hollow_heads_are_rings_and_the_stroke_stays_out_of_the_hole() { fn round_caps_never_poke_past_the_tip() { for kind in Arrowhead::ALL { for scale in [10.0, 100.0, 300.0] { - let mut st = arrows(kind, ArrowAlign::Tip, LineCap::Round); + let mut st = arrows(kind.clone(), ArrowAlign::Tip, LineCap::Round); st.arrow_scale = (scale, scale); let l = line(0.0, 100.0); let p = stroke_pieces(&l, &st); @@ -403,8 +403,8 @@ fn degenerate_paths_and_patterns_neither_panic_nor_hang() { for pat in patterns { for off in [0.0, -7.0, 1e12, f64::INFINITY] { let st = stroke(2.0, |s| { - s.start_arrow = Some(kind); - s.end_arrow = Some(kind); + s.start_arrow = Some(kind.clone()); + s.end_arrow = Some(kind.clone()); s.arrow_align = align; s.cap = cap; s.dash = Some(dashed(pat, off)); diff --git a/crates/effects/src/stroke/tests_arrows.rs b/crates/effects/src/stroke/tests_arrows.rs index 4328048991..a32a70245b 100644 --- a/crates/effects/src/stroke/tests_arrows.rs +++ b/crates/effects/src/stroke/tests_arrows.rs @@ -50,7 +50,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { let (o, d) = (Point::ORIGIN, Point::new(2.0, 2.0)); assert!(cross((o, d), (Point::new(0.0, 2.0), Point::new(2.0, 0.0))) && !cross((o, d), (d, Point::new(4.0, 0.0))), "the check itself"); for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn"); let b = h.outline.bounding_box(); let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width()); @@ -73,7 +73,7 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() { #[test] fn the_stroke_ends_inside_every_head() { for kind in Arrowhead::ALL { - let h = head(kind); + let h = head(kind.clone()); // Just in front of where the line stops, on its centre line. let end = h.tip - h.dir * (h.inset - 0.1); assert_ne!(h.outline.winding(end), 0, "{kind:?}: the line ends outside the head (inset {})", h.inset); @@ -112,3 +112,30 @@ fn half_arrows_barb_on_their_own_side() { assert!(head(Arrowhead::HalfArrowLeft).outline.bounding_box().y1 < 1.5); assert!(head(Arrowhead::HalfArrowRight).outline.bounding_box().y1 > 3.0); } + +#[test] +fn custom_heads_draw_their_own_art_at_the_tip() { + use vectorcraft_doc::CustomArrowhead; + use vectorcraft_geom::{PathData, SubPath}; + // From the tip back 3 units, 2 to the right of the line and 1 to the left. + let wedge = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(3.0, 2.0), Point::new(3.0, -1.0)], true)); + let kind = Arrowhead::Custom(std::sync::Arc::new(CustomArrowhead::new("Wedge".into(), wedge))); + let h = head(kind.clone()); + // Weight 2: drawn back to x 94, right of +x is +y on a y-down page. + let b = h.outline.bounding_box(); + assert!((b.x0 - 94.0).abs() < 1e-9 && (b.x1 - 100.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.y1 - 4.0).abs() < 1e-9, "{b:?}"); + assert_eq!(h.outline.winding(Point::new(95.0, 1.0)), h.outline.winding(Point::new(95.0, 0.5))); + assert_ne!(h.outline.winding(Point::new(95.0, 1.0)), 0); + // The line stops halfway along the head. + assert!((h.inset - 3.0).abs() < 1e-9, "inset {}", h.inset); + // Extended, the tip sits the inset past the end point, and scaling scales the art. + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 2.0); + (st.start_arrow, st.arrow_scale) = (Some(kind), (200.0, 100.0)); + let mut l = BezPath::new(); + l.move_to((0.0, 0.0)); + l.line_to((100.0, 0.0)); + let p = stroke_pieces(&l, &st); + let [s] = &p.heads[..] else { panic!("one head") }; + assert!((s.tip.x + 6.0).abs() < 1e-9 && s.dir.x < 0.0, "tip {:?}", s.tip); + assert!((s.outline.bounding_box().width() - 12.0).abs() < 1e-9); +} diff --git a/crates/effects/src/stroke/tests_fit.rs b/crates/effects/src/stroke/tests_fit.rs index b389cab853..cc69606aee 100644 --- a/crates/effects/src/stroke/tests_fit.rs +++ b/crates/effects/src/stroke/tests_fit.rs @@ -37,6 +37,20 @@ fn near(a: f64, b: f64, tol: f64) -> bool { // ---------------------------------------------------------------- dashes fitted to corners +/// A subpath with no segments (a lone moveto, as an HP-GL wedge of almost no radius gives) is +/// dashed fitted to corners without a panic, and the other subpaths keep their dashes. +#[test] +fn fitted_dashes_take_a_subpath_with_no_segments() { + let mut b = BezPath::new(); + b.move_to((5.0, 5.0)); + b.extend(line(100.0)); + let d = dash(&b, &fitted(&[10.0, 5.0], 0.0)).expect("dashed"); + assert!(moves(&d.path) > 1 && length(&d.path) > 0.0, "{:?}", d.path); + let mut lone = BezPath::new(); + lone.move_to((0.0, 0.0)); + assert!(dash(&lone, &fitted(&[10.0, 5.0], 0.0)).is_some_and(|d| d.path.elements().is_empty())); +} + #[test] fn every_corner_of_a_rectangle_falls_mid_dash() { // 100 × 50 with 12/6: the long sides hold 6 periods (scaled by 100/108), the short ones 3 diff --git a/crates/effects/src/stroke/tests_gradient.rs b/crates/effects/src/stroke/tests_gradient.rs index 3745e4a6f6..784c84d1db 100644 --- a/crates/effects/src/stroke/tests_gradient.rs +++ b/crates/effects/src/stroke/tests_gradient.rs @@ -345,6 +345,35 @@ fn a_narrow_peak_where_a_reflecting_gradient_turns_back_stays_in_the_mesh() { } } +/// Outline Stroke's mesh across a stroke of many ribs, with a gradient of many stops repeated 100 +/// times: each mesh holds at most so many points, and every run still starts on its first color. +#[test] +fn a_mesh_across_many_ribs_and_stops_stays_bounded() { + use vectorcraft_color::GradientSpread; + let pts: Vec<(f64, f64)> = (0..1200).map(|i| (i as f64 * 10.0, if i % 2 == 0 { 0.0 } else { 10.0 })).collect(); + let bp = poly(&pts, false); + let stops: Vec = (0..200).map(|i| GradientStop::new(i as f32 / 199.0, Color::gray(1.0 - i as f32 / 199.0))).collect(); + let g = Gradient { spread: GradientSpread::Repeat, repeats: 100, ..Gradient::new(vectorcraft_color::GradientKind::Linear, stops) }; + let mut st = stroke(4.0, StrokeGradientMode::Across); + st.paint = Paint::Gradient(Box::new(GradientPaint::new(g.clone()))); + let meshes = gradient_meshes(&bp, FillRule::NonZero, &st, &g); + assert!(!meshes.is_empty()); + for m in &meshes { + assert!(m.points.len() <= super::gradient::MAX_MESH_POINTS, "{} points", m.points.len()); + // A row at the start of each of the 100 runs, on every rib, is white. + let light = m.points.iter().filter(|p| p.color.to_rgb()[0] > 0.97).count(); + assert!(light >= 100 * (m.cols as usize + 1), "{light} light points of {}", m.points.len()); + } + // A strip of more ribs than one mesh has room for is split into meshes that share their end + // ribs, every rib in one of them. + use super::gradient::rib_pieces; + assert_eq!(rib_pieces(5, 3), [0..3, 2..5]); + assert_eq!(rib_pieces(4, 3), [0..3, 2..4]); + let one = rib_pieces(2, 1000); + assert_eq!((one.len(), one.first()), (1, Some(&(0..2)))); + assert_eq!(rib_pieces(3, 0), [0..2, 1..3]); +} + /// #5960: with dashes, the ribs reach as far as the stroke gets wide within a cap's length along /// the path, however many points lie that far: not as far as it is wide anywhere when more than /// 256 do. diff --git a/crates/effects/src/stroke/tests_reach.rs b/crates/effects/src/stroke/tests_reach.rs index 29a48343ed..5c66c9fe9d 100644 --- a/crates/effects/src/stroke/tests_reach.rs +++ b/crates/effects/src/stroke/tests_reach.rs @@ -78,6 +78,35 @@ fn a_butt_capped_open_path_is_measured_where_it_paints() { } } +#[test] +fn beveled_corners_are_measured_where_they_paint() { + let mut tip = BezPath::new(); + tip.move_to((0.0, 100.0)); + tip.line_to((20.0, 0.0)); + tip.line_to((40.0, 100.0)); + tip.close_path(); + let mut chevron = BezPath::new(); + chevron.move_to((0.0, 100.0)); + chevron.line_to((20.0, 0.0)); + chevron.line_to((40.0, 100.0)); + // A rectangle with no width turns back at its top and bottom. + let flat = vectorcraft_geom::shapes::rectangle(Rect::new(3.0, 100.0, 3.0, 150.0)).to_bezpath(); + let ps = paths(); + for (name, bp) in [("tip", &tip), ("chevron", &chevron), ("zigzag", &ps[1]), ("triangle", &ps[2]), ("flat", &flat)] { + for cap in [LineCap::Butt, LineCap::Round, LineCap::Square] { + for (join, limit) in [(LineJoin::Bevel, 10.0), (LineJoin::Miter, 1.0), (LineJoin::Miter, 2.0), (LineJoin::Miter, 10.0)] { + let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 20.0); + (st.cap, st.join, st.miter_limit) = (cap, join, limit); + let painted = outline_region(&PathData::from_bezpath(bp), FillRule::NonZero, &st).to_bezpath().bounding_box(); + let bounds = st.bounds_on(bp, bp.bounding_box()); + for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] { + assert!((got - want).abs() < 0.05, "{name} {cap:?} {join:?} {limit}: measured {bounds:?}, paints {painted:?}"); + } + } + } + } +} + #[test] fn bounds_and_clicks_cover_what_every_stroke_paints() { let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })]; @@ -99,14 +128,27 @@ fn bounds_and_clicks_cover_what_every_stroke_paints() { } } +/// Custom heads: one wholly behind its tip, and a long one reaching as far in front of its tip as +/// behind it (its tip sits halfway along it past the end point with Extend). +fn custom_heads() -> Vec { + use vectorcraft_geom::{Point as P, SubPath}; + let head = |name: &str, pts: &[(f64, f64)]| { + let pts: Vec

= pts.iter().map(|&(x, y)| P::new(x, y)).collect(); + let path = PathData::single(SubPath::polyline(&pts, true)); + Arrowhead::Custom(std::sync::Arc::new(vectorcraft_doc::CustomArrowhead::new(name.into(), path))) + }; + vec![head("Wedge", &[(0.0, 0.0), (3.0, 2.0), (3.0, -1.0)]), head("Spindle", &[(-10.0, 0.0), (0.0, 2.0), (10.0, 0.0), (0.0, -2.0)])] +} + #[test] fn bounds_and_clicks_cover_every_arrowhead() { let bp = &paths()[0]; - for kind in Arrowhead::ALL { + for kind in Arrowhead::ALL.into_iter().chain(custom_heads()) { for align in [ArrowAlign::Extend, ArrowAlign::Tip] { for (scale, cap) in [(50.0, LineCap::Round), (100.0, LineCap::Butt), (300.0, LineCap::Square)] { let mut st = StrokeLayer::new(Paint::solid(Color::BLACK), 3.0); - (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = (Some(kind), Some(kind), align, (scale, scale), cap); + (st.start_arrow, st.end_arrow, st.arrow_align, st.arrow_scale, st.cap) = + (Some(kind.clone()), Some(kind.clone()), align, (scale, scale), cap); check(bp, &st, &format!("{kind:?} {align:?} {scale}%")); } } diff --git a/crates/effects/src/tests.rs b/crates/effects/src/tests.rs index e63b38a462..88300586b3 100644 --- a/crates/effects/src/tests.rs +++ b/crates/effects/src/tests.rs @@ -4,6 +4,7 @@ use vectorcraft_geom::{Affine, PathData, Point, Rect, Shape, SubPath, shapes}; use super::*; +mod rasterfollow; mod scale; mod transformfx; @@ -1251,6 +1252,96 @@ fn roughen_smooth_makes_no_cusps() { } } +#[test] +fn roughen_at_size_0_keeps_the_outline_and_spaces_its_points_evenly() { + use vectorcraft_geom::AnchorKind; + use vectorcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let (a, b) = (Point::new(0.0, 0.0), Point::new(300.0, 0.0)); + let curve = PathData::single(SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(a, a, Point::new(10.0, 40.0)), + vectorcraft_geom::Anchor::with_handles(b, Point::new(100.0, 200.0), b), + ], + false, + )); + let ellipse = shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 50.0)); + // Two subpaths with lines and curves: a closed one whose last segment is a line, and an open + // one that ends with a line. + let (c, d, e, f) = (Point::new(100.0, 100.0), Point::new(200.0, 0.0), Point::new(300.0, 0.0), Point::new(300.0, 100.0)); + let mixed = PathData::new(vec![ + SubPath::new( + vec![ + vectorcraft_geom::Anchor::corner(a), + vectorcraft_geom::Anchor::with_handles(Point::new(100.0, 0.0), Point::new(100.0, 0.0), Point::new(160.0, 20.0)), + vectorcraft_geom::Anchor::with_handles(c, Point::new(160.0, 80.0), c), + ], + true, + ), + SubPath::new( + vec![ + vectorcraft_geom::Anchor::with_handles(d, d, Point::new(220.0, 60.0)), + vectorcraft_geom::Anchor::with_handles(e, Point::new(280.0, 60.0), e), + vectorcraft_geom::Anchor::corner(f), + ], + false, + ), + ]); + for path in [square(), ellipse, curve, mixed] { + let all: Vec<_> = path.subpaths.iter().flat_map(|sp| (0..sp.segment_count()).map(|i| sp.segment(i))).collect(); + let off = |q: Point| all.iter().map(|c| c.nearest(q, 1e-9).distance_sq).fold(f64::INFINITY, f64::min).sqrt(); + for params in + [json!({"size": 0}), json!({"size": 0, "points": "corner"}), json!({"size": 0, "relative": false, "points": "corner", "seed": 7})] + { + let out = run("distort.roughen", params.clone(), &path); + assert!(out.anchor_count() > path.anchor_count(), "{params}: the points are added"); + assert_eq!(out.subpaths.len(), path.subpaths.len(), "{params}"); + for (sp, got) in path.subpaths.iter().zip(&out.subpaths) { + assert_eq!(got.closed, sp.closed); + // Walk the result one original segment at a time: its pieces end on the segment's end anchor. + let mut at = 0; + for i in 0..sp.segment_count() { + let (orig, end) = (sp.segment(i), sp.anchors[(i + 1) % sp.anchors.len()]); + let start = got.anchors[at]; + let was = sp.anchors[i]; + assert_eq!((start.p, start.kind, start.has_in(), start.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + let mut pieces = vec![]; + loop { + let piece = got.segment(at); + for j in 0..=8 { + let q = piece.eval(j as f64 / 8.0); + assert!(off(q) < 1e-6, "{params}: {q:?} is {} off the outline", off(q)); + } + pieces.push(piece.arclen(1e-6)); + at += 1; + if piece.p3.distance(end.p) < 1e-9 { + break; + } + // A point the effect added: a corner without handles on a line, a smooth + // point whose handles follow the curve. + let added = got.anchors[at]; + if sp.segment_is_line(i) { + assert_eq!((added.kind, added.has_in(), added.has_out()), (AnchorKind::Corner, false, false), "{params}"); + } else { + assert_eq!(added.kind, AnchorKind::Smooth, "{params}"); + let (hi, ho) = (added.h_in - added.p, added.h_out - added.p); + assert!(hi.cross(ho).abs() < 1e-6 * hi.hypot() * ho.hypot() && hi.dot(ho) < 0.0, "{params}: {added:?}"); + } + } + assert!(pieces.len() > 1, "{params}: segment {i} is split"); + assert!(close(pieces.iter().sum::(), orig.arclen(1e-6), 1e-3), "{params}: {pieces:?}"); + let (lo, hi) = pieces.iter().fold((f64::INFINITY, 0.0f64), |(lo, hi), &l| (lo.min(l), hi.max(l))); + assert!(hi - lo < 1e-3, "{params}: segment {i}'s pieces from {lo} to {hi}"); + } + assert_eq!(at, got.segment_count(), "{params}"); + if !sp.closed { + let (last, was) = (got.anchors[at], sp.anchors[sp.anchors.len() - 1]); + assert_eq!((last.p, last.kind, last.has_in(), last.has_out()), (was.p, was.kind, was.has_in(), was.has_out()), "{params}"); + } + } + } + } +} + #[test] fn zigzag_ridge_count_and_amplitude() { let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square()); diff --git a/crates/effects/src/tests/rasterfollow.rs b/crates/effects/src/tests/rasterfollow.rs new file mode 100644 index 0000000000..c674cf5041 --- /dev/null +++ b/crates/effects/src/tests/rasterfollow.rs @@ -0,0 +1,421 @@ +//! Raster effects listed before a Transform effect follow it: the Transform effect transforms +//! what they paint. + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::{RasterFx, bake_document, expand_outlined, raster_effects}; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +fn shadow(x: f64, y: f64, blur: f64) -> Effect { + fx("stylize.dropShadow", json!({"x": x, "y": y, "blur": blur})) +} + +fn transform(params: Value) -> Effect { + fx("distort.transform", params) +} + +/// The offset and blur of the first Drop Shadow `effects` draw. +fn drawn_shadow(effects: &[Effect]) -> (f64, f64, f64) { + match raster_effects(effects).first() { + Some(RasterFx::DropShadow { dx, dy, blur, .. }) => (*dx, *dy, *blur), + other => panic!("expected a drop shadow, got {other:?}"), + } +} + +fn close(a: (f64, f64, f64), b: (f64, f64, f64)) -> bool { + (a.0 - b.0).abs() < 1e-9 && (a.1 - b.1).abs() < 1e-9 && (a.2 - b.2).abs() < 1e-9 +} + +/// A 50 pt red square with `effects`. +fn square(id: u64, effects: Vec) -> Node { + let mut n = Node::path( + NodeId(id), + shapes::rectangle(Rect::new(0.0, 0.0, 50.0, 50.0)), + Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 0.0), + ); + n.appearance.effects = effects; + n +} + +#[test] +fn a_transform_after_a_drop_shadow_scales_its_offset_and_blur() { + for strokes in [false, true] { + let effects = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200, "strokes": strokes}))]; + assert!(close(drawn_shadow(&effects), (20.0, 20.0, 8.0)), "Scale Strokes & Effects {strokes}: {:?}", drawn_shadow(&effects)); + } +} + +#[test] +fn a_drop_shadow_after_a_transform_keeps_its_offset() { + let effects = [transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 10.0, 4.0)]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +#[test] +fn the_offset_follows_an_uneven_scale_a_rotation_and_a_reflection() { + let uneven = [shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 100}))]; + assert!(close(drawn_shadow(&uneven), (20.0, 10.0, 4.0 * 2f64.sqrt())), "{:?}", drawn_shadow(&uneven)); + // A turn of 90° counterclockwise on screen takes an offset to the right to one upward. + let turned = [shadow(10.0, 0.0, 4.0), transform(json!({"rotate": 90}))]; + let (dx, dy, blur) = drawn_shadow(&turned); + assert!(dx.abs() < 1e-9 && (dy + 10.0).abs() < 1e-9 && (blur - 4.0).abs() < 1e-9, "{dx} {dy} {blur}"); + let reflected = [shadow(10.0, 0.0, 4.0), transform(json!({"reflectX": true}))]; + assert!(close(drawn_shadow(&reflected), (-10.0, 0.0, 4.0)), "{:?}", drawn_shadow(&reflected)); +} + +#[test] +fn transforms_after_the_shadow_apply_in_order() { + let effects = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(json!({"rotate": 90}))]; + let (dx, dy, _) = drawn_shadow(&effects); + assert!(dx.abs() < 1e-9 && (dy + 20.0).abs() < 1e-9, "{dx} {dy}"); + // Only the Transform after a shadow moves it. + let between = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), shadow(0.0, 10.0, 0.0)]; + let drawn = raster_effects(&between); + let offsets: Vec<(f64, f64)> = drawn + .iter() + .filter_map(|r| match r { + RasterFx::DropShadow { dx, dy, .. } => Some((*dx, *dy)), + _ => None, + }) + .collect(); + assert_eq!(offsets, vec![(20.0, 0.0), (0.0, 10.0)]); +} + +#[test] +fn transforms_that_leave_the_art_or_copy_it_leave_the_shadow() { + for params in [ + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "copies": 1}), + json!({"scaleH": 200, "scaleV": 200, "random": true}), + ] { + let effects = [shadow(10.0, 10.0, 4.0), transform(params.clone())]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{params}: {:?}", drawn_shadow(&effects)); + } + let mut hidden = transform(json!({"scaleH": 200, "scaleV": 200})); + hidden.visible = false; + let effects = [shadow(10.0, 10.0, 4.0), hidden]; + assert!(close(drawn_shadow(&effects), (10.0, 10.0, 4.0)), "{:?}", drawn_shadow(&effects)); +} + +/// A hidden shadow kept when the Transform effect is applied (Expand Appearance) draws as it would +/// have before, once it is shown. +#[test] +fn a_hidden_shadow_follows_the_transform_it_loses() { + let mut hidden = shadow(10.0, 10.0, 4.0); + hidden.visible = false; + let kept = crate::raster::without_geometry(&[hidden, transform(json!({"scaleH": 200, "scaleV": 200}))]); + assert_eq!(kept.len(), 1); + let mut shown = kept[0].clone(); + shown.visible = true; + assert!(close(drawn_shadow(&[shown]), (20.0, 20.0, 8.0))); +} + +/// Rasterize keeps its room around the art: its image keeps its own pixel grid. +#[test] +fn rasterize_before_a_transform_keeps_its_options() { + let rasterize = fx(crate::RASTERIZE, json!({"addAround": 6})); + let effects = [rasterize.clone(), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::raster::followed(&effects)[0], rasterize); + assert_eq!(crate::raster::without_geometry(&effects), vec![rasterize]); +} + +/// An SVG filter keeps its options: it paints in its own user space. +#[test] +fn an_svg_filter_before_a_transform_keeps_its_options() { + let filter = fx(crate::SVG_FILTER, json!({"origin": [10, 10]})); + let effects = [filter.clone(), transform(json!({"scaleH": 200, "scaleV": 200, "rotate": 90}))]; + assert_eq!(crate::raster::followed(&effects)[0], filter); + assert_eq!(crate::raster::without_geometry(&effects), vec![filter]); +} + +/// The offset of every Drop Shadow drawn on `n` and the nodes under it, from the object's effects +/// and from each fill's and stroke's. +fn shadows_in(n: &Node) -> Vec<(f64, f64)> { + let mut out = vec![]; + n.walk(&mut |m| { + let lists = std::iter::once(&m.appearance.effects).chain(m.appearance.items.iter().map(|i| i.effects())); + for list in lists { + for r in raster_effects(list) { + if let RasterFx::DropShadow { dx, dy, .. } = r { + out.push((dx, dy)); + } + } + } + }); + out +} + +fn near(a: &[(f64, f64)], b: &[(f64, f64)]) -> bool { + a.len() == b.len() && a.iter().zip(b).all(|(p, q)| (p.0 - q.0).abs() < 1e-9 && (p.1 - q.1).abs() < 1e-9) +} + +/// A square whose fill carries `effects` of its own. +fn fill_square(id: u64, effects: Vec) -> Node { + let mut n = square(id, vec![]); + if let Some(item) = n.appearance.items.first_mut() { + *item.effects_mut() = effects; + } + n +} + +/// A fill's own Transform effect transforms the fill's raster effects before it: drawn as they are +/// (no options), drawn through the art the Transform Patterns or Scale Strokes & Effects option +/// makes, expanded and baked. Scale Strokes & Effects does not scale a shadow that follows the +/// Transform effect a second time. +#[test] +fn a_fills_shadow_follows_the_fills_own_transform() { + let cases = [ + (json!({"rotate": 90}), (0.0, -10.0)), + (json!({"rotate": 90, "strokes": true}), (0.0, -10.0)), + (json!({"scaleH": 200, "scaleV": 200, "strokes": true}), (20.0, 0.0)), + (json!({"scaleH": 200, "scaleV": 200, "patterns": true}), (20.0, 0.0)), + (json!({"reflectX": true, "patterns": true}), (-10.0, 0.0)), + ]; + for (params, want) in cases { + let n = fill_square(11, vec![shadow(10.0, 0.0, 0.0), transform(params.clone())]); + // Drawn and expanded through its art when an option makes some. + if crate::has_transform_art(&n) { + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[want]), "{params}: drawn {:?}", shadows_in(&art)); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(near(&shadows_in(&expanded), &[want]), "{params}: expanded {:?}", shadows_in(&expanded)); + } else { + assert!(near(&shadows_in(&n), &[want]), "{params}: drawn {:?}", shadows_in(&n)); + } + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = baked.node(NodeId(11)).unwrap(); + assert!(near(&shadows_in(b), &[want]), "{params}: baked {:?}", shadows_in(b)); + } +} + +/// A Transform effect after a fill's own Transform effect with Scale Strokes & Effects on turns the +/// shadow between them, which the first one leaves as it is; a Transform effect that moves at random +/// holds the shadow before it back from the Transform effects after it. +#[test] +fn a_fills_later_transform_turns_the_shadow_after_its_first() { + let n = fill_square( + 12, + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0), transform(json!({"rotate": 90}))], + ); + let art = crate::reshape(None, &n, None).unwrap(); + assert!(near(&shadows_in(&art), &[(0.0, -10.0)]), "{:?}", shadows_in(&art)); + let held = fill_square( + 13, + vec![ + shadow(10.0, 0.0, 0.0), + transform(json!({"scaleH": 200, "scaleV": 200, "random": true, "strokes": true})), + transform(json!({"rotate": 90})), + ], + ); + let art = crate::reshape(None, &held, None).unwrap(); + let drawn = shadows_in(&art); + assert!(drawn.len() == 1 && drawn[0].0 > 0.0 && drawn[0].1.abs() < 1e-9, "{drawn:?}"); +} + +/// A fill's own Transform effect with Transform Patterns or Scale Strokes & Effects on leaves the +/// fill's raster effects after it as they are: they paint around the transformed art, its copies +/// included. With Transform Objects off it leaves the raster effects before it as they are too. +/// Drawn, expanded and baked alike. +#[test] +fn a_fills_own_transform_leaves_the_shadow_after_it() { + let cases = [ + vec![transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 120, "copies": 1, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true})), shadow(10.0, 0.0, 0.0)], + vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}))], + // A later Transform effect with an option leaves the shadow after it as well. + vec![ + transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})), + transform(json!({"scaleH": 150, "scaleV": 150, "strokes": true})), + shadow(10.0, 0.0, 0.0), + ], + ]; + for effects in cases { + let n = fill_square(18, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{effects:?}: drawn {drawn:?}"); + let expanded = shadows_in(&expand_outlined(None, &n, None).unwrap()); + assert!(!expanded.is_empty() && near(&expanded, &vec![(10.0, 0.0); expanded.len()]), "{effects:?}: expanded {expanded:?}"); + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, n).unwrap(); + let baked = bake_document(&d).unwrap(); + let b = shadows_in(baked.node(NodeId(18)).unwrap()); + assert!(!b.is_empty() && near(&b, &vec![(10.0, 0.0); b.len()]), "{effects:?}: baked {b:?}"); + } +} + +/// A geometry effect after a fill's own Transform effect with Scale Strokes & Effects on reshapes +/// the transformed art by its own distance, as it does after an object's Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_offset_after_it() { + let offset = fx("path.offsetPath", json!({"offset": 5})); + let scaled = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = fill_square(19, vec![scaled.clone(), offset.clone()]); + let on_fill = crate::reshape(None, &n, None).unwrap(); + let on_object = crate::reshape(None, &square(20, vec![scaled, offset]), None).unwrap(); + let (fill, object) = (on_fill.geometric_bounds().unwrap(), on_object.geometric_bounds().unwrap()); + // The 50 pt square scaled to 100 pt about its center, then offset by 5 pt on each side. + assert!((fill.width() - 110.0).abs() < 1e-6 && (fill.height() - 110.0).abs() < 1e-6, "{fill:?}"); + assert!((fill.width() - object.width()).abs() < 1e-6 && (fill.x0 - object.x0).abs() < 1e-6, "{fill:?} {object:?}"); + let expanded = expand_outlined(None, &n, None).unwrap().geometric_bounds().unwrap(); + assert!((expanded.width() - 110.0).abs() < 1e-6 && (expanded.x0 - fill.x0).abs() < 1e-6, "expanded {expanded:?}"); +} + +/// The SVG filters on `n` and the nodes under it, from the object's effects and from each fill's +/// and stroke's. +fn filters_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())); + out.extend(lists.flatten().filter(|e| e.id == crate::SVG_FILTER).cloned()); + }); + out +} + +/// An SVG filter after a fill's own Transform effect with Scale Strokes & Effects on keeps its +/// scale and the origin of its user space, as it does after the same Transform effect without +/// options and after an object's own Transform effect. +#[test] +fn a_fills_own_transform_leaves_the_svg_filter_after_it() { + let filter = fx(crate::SVG_FILTER, json!({"scale": 1.0, "origin": [10.0, 10.0]})); + for params in [ + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "patterns": true}), + json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "copies": 1, "strokes": true}), + json!({"scaleH": 200, "scaleV": 200, "objects": false, "strokes": true}), + ] { + let art = crate::reshape(None, &fill_square(21, vec![transform(params.clone()), filter.clone()]), None).unwrap(); + let drawn = filters_in(&art); + assert!(!drawn.is_empty() && drawn.iter().all(|e| *e == filter), "{params}: {drawn:?}"); + } + let object = square(22, vec![transform(json!({"scaleH": 200, "scaleV": 200, "moveH": 100, "strokes": true})), filter.clone()]); + let art = crate::reshape(None, &object, None).unwrap(); + assert!(!filters_in(&art).is_empty() && filters_in(&art).iter().all(|e| *e == filter), "{:?}", filters_in(&art)); +} + +/// The 3D effects keep the shadow before a Transform effect that shapes the art they project, the +/// object's and its fill's 3D effect alike. +#[test] +fn a_shadow_before_a_transform_follows_it_into_3d_art() { + let before = vec![shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let mut on_object = before.clone(); + on_object.push(fx(three_d, json!({}))); + let projected = art(&square(14, on_object)).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the object: {:?}", shadows_in(&projected)); + let mut on_fill = square(15, before.clone()); + if let Some(item) = on_fill.appearance.items.first_mut() { + *item.effects_mut() = vec![fx(three_d, json!({}))]; + } + let projected = art(&on_fill).unwrap(); + assert!(near(&shadows_in(&projected), &[(20.0, 0.0)]), "{three_d} on the fill: {:?}", shadows_in(&projected)); + } +} + +#[test] +fn glows_blurs_and_feathers_scale_with_the_transform_after_them() { + let effects = [ + fx("stylize.outerGlow", json!({"blur": 5})), + fx("stylize.innerGlow", json!({"blur": 5})), + fx("stylize.feather", json!({"radius": 5})), + fx("blur.gaussian", json!({"radius": 5})), + transform(json!({"scaleH": 50, "scaleV": 50})), + ]; + let sizes: Vec = raster_effects(&effects) + .iter() + .map(|r| match r { + RasterFx::OuterGlow { blur, .. } | RasterFx::InnerGlow { blur, .. } => *blur, + RasterFx::Feather { radius } | RasterFx::GaussianBlur { radius, .. } => *radius, + other => panic!("unexpected {other:?}"), + }) + .collect(); + assert_eq!(sizes, vec![2.5; 4]); + // How far the shadow paints past the object follows too. + let shadowed = [shadow(10.0, 10.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + assert_eq!(crate::outset(&shadowed, Rect::new(0.0, 0.0, 100.0, 100.0)), 20.0); +} + +#[test] +fn pixel_filter_sizes_scale_with_the_transform_after_them() { + let effects = [ + fx("artistic.coloredPencil", json!({"pencilWidth": 5})), + fx("artistic.paintDaubs", json!({"brushSize": 10})), + transform(json!({"scaleH": 200, "scaleV": 200})), + ]; + let drawn = raster_effects(&effects); + assert!(matches!(drawn.first(), Some(RasterFx::Pixel(crate::PixelFx::ColoredPencil { width, .. })) if (width - 10.0).abs() < 1e-9), "{drawn:?}"); + assert!(matches!(drawn.get(1), Some(RasterFx::Pixel(crate::PixelFx::PaintDaubs { size, .. })) if (size - 20.0).abs() < 1e-9), "{drawn:?}"); +} + +/// A Transform effect after a 3D effect that makes copies or moves at random holds back the shadow +/// before the 3D effect, as it does without the 3D effect. +#[test] +fn a_copying_transform_after_3d_holds_the_shadow_back() { + for later in [json!({"copies": 1}), json!({"scaleH": 150, "scaleV": 150, "random": true})] { + let plain = [shadow(10.0, 0.0, 0.0), transform(json!({"scaleH": 200, "scaleV": 200})), transform(later.clone())]; + assert!(close(drawn_shadow(&plain), (10.0, 0.0, 0.0)), "{later}: {:?}", drawn_shadow(&plain)); + for three_d in [crate::EXTRUDE, crate::REVOLVE] { + let art = |n: &Node| if three_d == crate::EXTRUDE { crate::solid_art(n) } else { crate::revolve_art(n) }; + let effects = vec![plain[0].clone(), plain[1].clone(), fx(three_d, json!({})), plain[2].clone()]; + let projected = art(&square(16, effects)).unwrap(); + let drawn = shadows_in(&projected); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{three_d} {later}: {drawn:?}"); + } + } +} + +/// In a fill's own effects, a Transform effect that makes copies after a Transform effect with +/// Transform Patterns on holds back the shadow before both, and before a Transform effect between +/// them. +#[test] +fn a_fills_copying_transform_holds_back_the_shadow_before_an_earlier_transform() { + let patterns = transform(json!({"scaleH": 200, "scaleV": 200, "patterns": true})); + let copies = transform(json!({"copies": 1})); + let lists = [ + vec![shadow(10.0, 0.0, 0.0), patterns.clone(), copies.clone()], + vec![shadow(10.0, 0.0, 0.0), patterns, transform(json!({"rotate": 90})), copies], + ]; + for effects in lists { + let n = fill_square(17, effects.clone()); + let art = crate::reshape(None, &n, None).unwrap(); + let drawn = shadows_in(&art); + assert!(!drawn.is_empty() && near(&drawn, &vec![(10.0, 0.0); drawn.len()]), "{} effects: {drawn:?}", effects.len()); + } +} + +#[test] +fn expanded_and_exported_objects_keep_the_shadow_the_canvas_draws() { + let effects = vec![shadow(10.0, 10.0, 4.0), transform(json!({"scaleH": 200, "scaleV": 200}))]; + // A path drawn through its art (Scale Strokes & Effects on), expanded. + let mut with_options = effects.clone(); + with_options[1] = transform(json!({"scaleH": 200, "scaleV": 200, "strokes": true})); + let n = square(7, with_options); + let expanded = expand_outlined(None, &n, None).unwrap(); + assert!(close(drawn_shadow(&expanded.appearance.effects), (20.0, 20.0, 8.0)), "{:?}", expanded.appearance.effects); + // A path and a group, baked for export. + let mut d = Document::new(800.0, 800.0); + let layer = d.layers[0].id; + d.insert(Some(layer), 0, square(8, effects.clone())).unwrap(); + let mut g = Node::group(NodeId(9), vec![std::sync::Arc::new(square(10, vec![]))]); + g.appearance.effects = effects.clone(); + d.insert(Some(layer), 1, g).unwrap(); + let baked = bake_document(&d).unwrap(); + for id in [8, 9] { + let b = baked.node(NodeId(id)).unwrap(); + assert!(!b.appearance.effects.iter().any(|e| e.id == "distort.transform"), "{id}: {:?}", b.appearance.effects); + assert!(close(drawn_shadow(&b.appearance.effects), (20.0, 20.0, 8.0)), "{id}: {:?}", b.appearance.effects); + } +} diff --git a/crates/effects/src/tests/transformfx.rs b/crates/effects/src/tests/transformfx.rs index de69116b79..1046f3c437 100644 --- a/crates/effects/src/tests/transformfx.rs +++ b/crates/effects/src/tests/transformfx.rs @@ -222,7 +222,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() { } #[test] -fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects() { +fn a_fills_own_transform_with_scale_strokes_and_effects_leaves_its_later_effects() { let mut n = stroke_with_effect(json!({})); if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { f.effects.push(transform(json!({"scaleH": 300, "scaleV": 300, "strokes": true}))); @@ -231,10 +231,10 @@ fn a_fills_own_transform_with_scale_strokes_and_effects_scales_its_later_effects let art = reshape(None, &n, None).unwrap(); let fill = leaves(&art).into_iter().find(|p| p.appearance.stroke().is_none()).unwrap(); let AppearanceItem::Fill(f) = &fill.appearance.items[0] else { panic!("not a fill") }; - // The Offset Path, scaled from 5 to 15, applies to the transformed fill's path. + // The Offset Path applies to the transformed fill's path at its own 5 pt. assert!(f.effects.is_empty(), "{:?}", f.effects); let w = fill.geometric_bounds().unwrap().width(); - assert!(close(w, 330.0), "{w}"); + assert!(close(w, 310.0), "{w}"); } #[test] diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index a507686959..509279a3ae 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -4,6 +4,7 @@ pub mod actionset; mod addons; mod apiref; pub(crate) mod appearance; +pub mod arrowheads; mod assets; mod attributes; pub mod brushlib; @@ -105,6 +106,7 @@ mod variablecmds; pub(crate) mod views; pub mod wand; pub mod webexport; +mod wireframe; mod xform; use serde::Serialize; @@ -297,6 +299,7 @@ pub fn command_specs() -> &'static [CommandSpec] { // Split out of `paint` (M3.8); kept next to it so lists in registry order (the command // palette, `engine.commands`) show them where they always were. v.extend(stroke::specs()); + v.extend(arrowheads::specs()); v.extend(appearance::specs()); v.extend(style::specs()); v.extend(swatch::specs()); @@ -308,6 +311,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(draw2::specs()); v.extend(xform::specs()); v.extend(effectcmd::specs()); + v.extend(wireframe::specs()); v.extend(pathops::specs()); v.extend(compoundshape::specs()); v.extend(typecmd::specs()); diff --git a/crates/engine/src/cmd/addons/color/legend.rs b/crates/engine/src/cmd/addons/color/legend.rs index e2f38a7a17..e6942378be 100644 --- a/crates/engine/src/cmd/addons/color/legend.rs +++ b/crates/engine/src/cmd/addons/color/legend.rs @@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result { let parent = st.target_parent()?; let text_size = (size / 7.0).clamp(4.0, 12.0); let pad = (size / 12.0).clamp(1.0, 8.0); - let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size })); + let style = super::super::super::create::new_type_style(s, &json!({ "size": text_size }), C)?; let count = swatches.len(); // Each chip's fill links to its swatch, as paint.setFill {swatch} links art: editing a global or // spot swatch then recolours the legend too. diff --git a/crates/engine/src/cmd/addons/image.rs b/crates/engine/src/cmd/addons/image.rs index b2a9d74f83..bfe3d2f4ef 100644 --- a/crates/engine/src/cmd/addons/image.rs +++ b/crates/engine/src/cmd/addons/image.rs @@ -20,6 +20,7 @@ use vectorcraft_geom::Affine; use super::super::fileio::{MAX_RASTER_ALLOC, ppi}; use super::super::menucmds::has_image; +use super::super::paint::{PROCESS_ON_TRANSPARENCY, gray_colorized_with_process}; use super::super::{CommandSpec, Result, Session, bad, checked_ids_param, cmd, hex_with_alpha, number_or, switch_or, text_param}; /// Remove Background's command. @@ -39,7 +40,7 @@ pub fn specs() -> Vec { "Remove Background…", ["Object", "Plug-ins", "Image Tools"], None, - "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, and a result with no pixel left are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", + "{background?: \"edges\" (default: the most common color along the image's edges; nothing is cleared when the edges hold at least as many clear pixels as pixels of that color) | \"corners\" (the average of the image's four corner pixels that are not clear; nothing is cleared when all four are clear) | \"colors\" (the colors in `colors`), colors?: [color…] or one string of them split by commas or spaces (1 to 16 colors, each a hex color \"#rrggbb\" or \"#rgb\" with or without the # or one of black, white, red, green and blue; needed with background: colors, and checked but not used with edges or corners), contiguous?: bool (true: only background pixels that reach in from an edge are cleared; false: they are cleared wherever they are; true), match?: \"distance\" (default: a color's RGB distance from a background color) | \"channels\" (its largest difference on one of red, green and blue), tolerance?: 0..100 (with distance, % of the largest RGB distance; with channels, % of 255 steps on one channel; a pixel this near a background color is cleared; 10), softness?: 0..100 (% past the tolerance over which pixels fade from cleared to kept; 10), ids?: [id…] (default: the selected images)} make the background of each image transparent in its own pixels, as one undo step: the image stays an image, embedded as a PNG with an alpha channel at the depth of its pixels (16-bit images keep 16 bits and grayscale images stay gray) with the source's color profile and resolution, and a linked image is embedded; a CMYK image, a linked image that shows its preview (embed or relink it first), an image too large to change, a result with no pixel left, and a grayscale image colorized with a process color (which can't colorize gray pixels with transparency) are refused, and an image whose pixels do not change is left as it is (no undo step when none changes) → {changed: the images changed, ids, cleared: the pixels made fully clear in them}", has_image, remove_background )] @@ -182,8 +183,14 @@ fn changes(s: &Session, p: &Value, settings: &Settings, mut kept: Option<&mut De // Every image is checked before any is decoded. let mut images = vec![]; for id in ids { - let Some(NodeKind::Image(im)) = st.doc.node(id).map(|n| &n.kind) else { continue }; + let Some(n) = st.doc.node(id) else { continue }; + let NodeKind::Image(im) = &n.kind else { continue }; let blob = st.doc.images.get(&im.key).ok_or_else(|| bad(C, "the image's pixels are missing"))?; + // The result has an alpha channel, which a grayscale image colorized with a process color + // can't take. + if gray_colorized_with_process(&st.doc, n).is_some() { + return Err(bad(C, PROCESS_ON_TRANSPARENCY)); + } // The preview stands in for a file that hasn't been read, or that is kept at its preview. if im.link.is_some() && blob.is_proxy() { return Err(bad(C, "the linked image shows its preview: embed or relink it first")); diff --git a/crates/engine/src/cmd/addons/image/tests.rs b/crates/engine/src/cmd/addons/image/tests.rs index df27180e82..d57f58046f 100644 --- a/crates/engine/src/cmd/addons/image/tests.rs +++ b/crates/engine/src/cmd/addons/image/tests.rs @@ -1,8 +1,9 @@ use std::io::Cursor; use std::sync::Arc; -use image::{DynamicImage, ImageBuffer, Luma, Rgb}; +use image::{DynamicImage, ImageBuffer, Luma, LumaA, Rgb}; use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::{ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind}; use vectorcraft_geom::Affine; @@ -333,6 +334,54 @@ fn sixteen_bit_and_gray_images_keep_their_depth_and_channels() { assert_eq!((out.get_pixel(0, 0).0, out.get_pixel(3, 3).0), ([61_000, 0], [7_000, 65_535])); } +/// A process color can't colorize a grayscale image with transparency, so a gray image colorized +/// with one keeps its pixels. Black, white and spot colors can, so those images are cleared. +#[test] +fn a_gray_image_colorized_with_a_process_color_is_refused() { + let gray = || { + png(DynamicImage::ImageLuma8(ImageBuffer::from_fn(8, 8, |x, y| Luma([if (2..6).contains(&x) && (2..6).contains(&y) { 30u8 } else { 240 }])))) + }; + let (mut s, id) = open(&gray()); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the fill colorizes the image"); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "the document and its undo steps are unchanged"); + // Another image that already has an alpha channel and a process colorize color, as an opened + // file can hold, neither lets this one through nor stops a run on an image that can be cleared. + let held = ImageBlob::png(png(DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(2, 2, LumaA([128u8, 100]))))); + let other = place_linked(&mut s, "held", held, (2, 2)); + s.edit("Embed and colorize", |d, _| { + let n = d.node_mut(other).unwrap(); + n.extras_mut().colorize = Some(Box::new(Paint::solid(Color::rgb8(255, 0, 0)))); + if let NodeKind::Image(im) = &mut n.kind { + im.link = None; + } + Ok(()) + }) + .unwrap(); + let (before, steps) = (s.doc().unwrap().doc.clone(), undo_len(&s)); + let e = s.execute(C, &json!({"ids": [id.0]})).unwrap_err(); + assert!(e.to_string().contains("a process color can't colorize a grayscale image with transparency"), "{e}"); + assert_eq!((&s.doc().unwrap().doc, undo_len(&s)), (&before, steps), "with another image refused already, nothing changes"); + s.execute("paint.setFill", &json!({"color": "#000000", "ids": [id.0]})).unwrap(); + assert_eq!(s.execute(C, &json!({"ids": [id.0]})).unwrap()["changed"], 1, "an image colorized with black is cleared beside the other"); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "the image stays colorized"); + for fill in [json!({"color": "#000000"}), json!({"color": "#ffffff"}), json!({"swatch": "Ink"})] { + let (mut s, id) = open(&gray()); + s.execute("swatch.new", &json!({"name": "Ink", "color": {"c": 0, "m": 100, "y": 100, "k": 0}, "spot": true})).unwrap(); + s.execute("paint.setFill", &fill).unwrap(); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} colorizes the image"); + s.execute(C, &json!({})).unwrap(); + let DynamicImage::ImageLumaA8(out) = pixels(&s, id) else { panic!("gray with alpha") }; + assert_eq!(out.get_pixel(0, 0).0[1], 0, "{fill}: the background is cleared"); + assert!(s.doc().unwrap().doc.node(id).unwrap().extras().colorize.is_some(), "{fill} still colorizes the image"); + } +} + /// The ICC profile the colors are in stays with them. #[test] fn the_color_profile_is_kept() { diff --git a/crates/engine/src/cmd/addons/typetools.rs b/crates/engine/src/cmd/addons/typetools.rs index 537e6bfe75..10a00b1b45 100644 --- a/crates/engine/src/cmd/addons/typetools.rs +++ b/crates/engine/src/cmd/addons/typetools.rs @@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result { let mut run = 0; for (i, at) in chars.into_iter().enumerate() { let random = if shape == "random" { unit(&mut rng) } else { 0.0 }; - let value = from + (to - from) * amount(shape, i, n, random); + // Rounding can carry a value a hair past the range's ends. + let value = (from + (to - from) * amount(shape, i, n, random)).clamp(lo, hi); let mut change = CharChange::parse(&json!({ key: value }), C, &setup)?; change.localize(&t, C)?; while ends.get(run).is_some_and(|&e| e <= at) { diff --git a/crates/engine/src/cmd/arrowheads.rs b/crates/engine/src/cmd/arrowheads.rs new file mode 100644 index 0000000000..0aeda59731 --- /dev/null +++ b/crates/engine/src/cmd/arrowheads.rs @@ -0,0 +1,453 @@ +//! Custom arrowheads (Stroke panel menu › Edit Arrowheads…). The arrowheads file is a native +//! document kept next to the preferences ([`Session::attach_arrowheads_file`]). Each of its symbols +//! is one arrowhead, listed by the symbol's name after the built-in heads in the Stroke panel's +//! arrowhead menus. A head points right (+x): the symbol's registration point is its tip, and the +//! art's height (its extent across the line) is drawn as wide as the built-in heads, four times +//! the head's weight. A head covers what its art paints: the visible fills, and the visible +//! strokes outlined, of the paths, compound paths and groups in it, and its gradient meshes, with +//! their geometry effects applied and clip groups clipped. A head is one solid shape in the stroke's paint: art at 0 % +//! opacity is left out, and other opacities, blend modes and opacity masks do not change the +//! shape. Saving the file reloads the heads. A stroke that uses a custom head carries a copy of its art +//! ([`vectorcraft_doc::CustomArrowhead`]), so its document draws it anywhere. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, AppearanceItem, CustomArrowhead, Document, Node, NodeId, NodeKind, Symbol, SymbolRegistration}; +use vectorcraft_geom::{Affine, FillRule, PathData, Point, SubPath}; +use vectorcraft_pathops::BoolOp; + +use super::{CommandSpec, Result, Session, always, bad, cmd}; + +/// The arrowheads file's name, in the folder of the preferences. +pub const FILE_NAME: &str = "Arrowheads.vectorcraft"; +/// The largest arrowheads file read, and the most its document or its images may unpack to when +/// compressed. +pub const MAX_FILE_BYTES: u64 = 64 << 20; +/// The most heads read from the file; symbols past it are skipped. +pub const MAX_HEADS: usize = 256; +/// The most anchors a symbol's art, and the shapes it paints, may have; a symbol past it is skipped. +pub const MAX_ART_ANCHORS: usize = 16 * CustomArrowhead::MAX_ANCHORS; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "stroke.arrowheads.list", + "Arrowheads", + [], + None, + "{} → {arrowheads: [{name (stroke.set `startArrow`/`endArrow` as {custom: name}), reach, halfWidth (in head weights)}], file: the arrowheads file's path or null (no file here, as on the web), skipped: [{name, reason}] symbols of the file that are not heads, error: why the file could not be read, or null} the custom arrowheads, listed after the built-in ones in the Stroke panel's arrowhead menus", + always, + list + ), + cmd!( + "stroke.arrowheads.edit", + "Edit Arrowheads", + [], + None, + "{} open the arrowheads file (a native document next to the preferences; created first with one example head when it is missing). Each symbol in it is one custom arrowhead named after the symbol: it points right, its registration point is the tip, and its height is drawn as wide as the built-in heads; its visible fills and outlined strokes make the head. Saving the file reloads the heads → {path, created: whether the file was made now}", + has_file, + edit + ), + cmd!( + "stroke.arrowheads.reload", + "Reload Arrowheads", + [], + None, + "{} read the arrowheads file again (a save of it from VectorCraft does this already) → {count, skipped: [{name, reason}]}", + has_file, + reload + ), + ] +} + +/// The custom arrowheads read from the arrowheads file. +#[derive(Default)] +pub struct Arrowheads { + path: Option, + heads: Vec>, + skipped: Vec<(String, String)>, + error: Option, +} + +impl Arrowheads { + /// The arrowheads file's path; `None` without one (the web, runs without preferences). + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + /// The heads, in the order of the file's symbols. + pub fn heads(&self) -> &[Arc] { + &self.heads + } + /// Head `name`. + pub fn get(&self, name: &str) -> Option> { + self.heads.iter().find(|h| h.name == name).cloned() + } + fn set(&mut self, read: Read) { + (self.heads, self.skipped) = (read.heads.into_iter().map(Arc::new).collect(), read.skipped); + self.error = None; + } +} + +/// The heads a document holds, and the symbols that are not heads with the reason. +#[derive(Debug, Default)] +pub struct Read { + pub heads: Vec, + pub skipped: Vec<(String, String)>, +} + +/// The heads of document `doc`: one per symbol, up to [`MAX_HEADS`] (see the module docs). +pub fn heads_of(doc: &Document) -> Read { + let mut d = doc.clone(); + let brushes = vectorcraft_brush::library(&d); + let mut out = Read::default(); + for (i, sym) in doc.symbols.iter().enumerate() { + if i >= MAX_HEADS { + out.skipped.push((sym.name.clone(), format!("only the first {MAX_HEADS} symbols are read"))); + continue; + } + if out.heads.iter().any(|h| h.name == sym.name) { + out.skipped.push((sym.name.clone(), "another symbol has this name".into())); + continue; + } + match head(&mut d, &brushes, sym) { + Ok(h) => out.heads.push(h), + Err(why) => out.skipped.push((sym.name.clone(), why.into())), + } + } + out +} + +/// Symbol `sym` of `d` as a head. +fn head(d: &mut Document, brushes: &[vectorcraft_brush::Brush], sym: &Symbol) -> std::result::Result { + const TOO_DETAILED: &str = "its art is too long or too detailed for a head"; + const UNITED: &str = "its shapes could not be combined into one"; + if art_anchors(&sym.art) > MAX_ART_ANCHORS { + return Err(TOO_DETAILED); + } + let art = baked_art(d, sym); + let mut shapes = Shapes::default(); + painted(d, brushes, &art, &mut shapes); + if shapes.too_detailed { + return Err(TOO_DETAILED); + } + if shapes.failed { + return Err(UNITED); + } + let area = shapes.united().ok_or(UNITED)?; + let b = area.bounds().filter(|b| b.height() > 1e-9 && b.is_finite()).ok_or("its art paints nothing")?; + let tip = b.center() + d.symbol_registration(&sym.name).offset_from_center(b.width(), b.height()); + // Head units: x back from the tip, y across to the right of +x, 4 units to the art's height. + let k = 4.0 / b.height(); + let local = Affine::new([-k, 0.0, 0.0, k, k * tip.x, -k * tip.y]); + let h = CustomArrowhead::new(sym.name.clone(), area.transformed(local)); + if h.path.subpaths.is_empty() { + return Err(TOO_DETAILED); + } + Ok(h) +} + +/// The anchors of the paths in `n` and of the pieces of its gradient meshes ([`mesh_anchors`]), its +/// members' included. +fn art_anchors(n: &Node) -> usize { + let own = match &n.kind { + NodeKind::Mesh(m) => mesh_anchors(m), + _ => n.path_data().map_or(0, PathData::anchor_count), + }; + n.children().map_or(own, |c| c.iter().fold(own, |sum, c| sum.saturating_add(art_anchors(c)))) +} + +/// The art of symbol `sym` of `d` with every geometry effect in it applied (its objects', fills', +/// strokes' and groups'), as the canvas draws it: the art baked as for export, on a copy of `d`. +fn baked_art(d: &Document, sym: &Symbol) -> Node { + // A plain new layer, so the file's own layers' effects stay out of the art. + let mut tmp = d.clone(); + tmp.layers = Document::new(1.0, 1.0).layers; + let Some(children) = tmp.layers.first_mut().and_then(|l| Arc::make_mut(l).children_mut()) else { return (*sym.art).clone() }; + *children = vec![sym.art.clone()]; + let baked = vectorcraft_render::effects::bake_document(&tmp); + baked.as_ref().and_then(|b| b.layers.first()?.children()?.first().map(|c| (**c).clone())).unwrap_or_else(|| (*sym.art).clone()) +} + +/// The shapes a symbol's art paints, up to [`MAX_ART_ANCHORS`] anchors in all. +#[derive(Default)] +struct Shapes { + list: Vec<(PathData, FillRule)>, + anchors: usize, + /// More anchors were asked for than fit; the shapes past the limit were not made. + too_detailed: bool, + /// A clip group's art could not be combined or clipped, so a shape it paints is missing. + failed: bool, +} + +impl Shapes { + /// Whether `anchors` more fit; when they do not, the shapes are too detailed. + fn room(&mut self, anchors: usize) -> bool { + self.too_detailed |= self.anchors.saturating_add(anchors) > MAX_ART_ANCHORS; + !self.too_detailed + } + fn push(&mut self, shape: (PathData, FillRule)) { + if self.room(shape.0.anchor_count()) { + self.anchors += shape.0.anchor_count(); + self.list.push(shape); + } + } + /// The shapes united, or `None` when one of them could not be (the union would miss it). + fn united(&self) -> Option { + let refs: Vec<(&PathData, FillRule)> = self.list.iter().map(|(p, r)| (p, *r)).collect(); + vectorcraft_pathops::try_unite_all(&refs).ok() + } + /// The shapes of `inside` clipped to `outline` (filled under `rule`), added as one. + fn push_clipped(&mut self, inside: Shapes, outline: &PathData, rule: FillRule) { + if inside.too_detailed || !self.room(inside.anchors) { + self.too_detailed = true; + return; + } + self.failed |= inside.failed; + let clip = |art: PathData| { + vectorcraft_pathops::try_boolean(&art, FillRule::NonZero, outline, rule, BoolOp::Intersect, vectorcraft_pathops::DEFAULT_PRECISION).ok() + }; + let Some(clipped) = inside.united().and_then(clip) else { + self.failed = true; + return; + }; + if !clipped.subpaths.is_empty() { + self.push((clipped, FillRule::NonZero)); + } + } +} + +/// The shapes the shown paths and compound paths in `n` (baked art) paint: their filled areas and +/// their strokes' outlines, its groups' members included (baking has already made a group's own +/// fills and strokes art among its members), and the areas its gradient meshes cover. A clip +/// group's clipped art is clipped to +/// its clipping path, whose own fill and stroke paint unclipped, as on the canvas; a group used as +/// the clipping path paints nothing ([`clip_paint`]). Hidden or fully +/// transparent art is left out. +fn painted(d: &mut Document, brushes: &[vectorcraft_brush::Brush], n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Path { guide: true, .. } => {} + NodeKind::Path { .. } | NodeKind::Compound { .. } => { + fills(n, out); + // The outline holds a copy of the path for each fill too, which adds nothing new. + if let Some(o) = super::pathops::outline_strokes(d, brushes, n) { + fills_in(&o, out); + } + } + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| painted(d, brushes, part, out)); + // Nothing to clip by hides the clipped art, as on the canvas. + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| painted(d, brushes, c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => children.iter().for_each(|c| painted(d, brushes, c, out)), + NodeKind::Mesh(_) => mesh_area(n, out), + _ => {} + } +} + +/// What clipping path `clip` paints ([`Node::clip_paint`]): its fills and its strokes, each as a +/// copy of it keeping only those. A group or another object that does not paint as a clipping +/// path gives neither. +fn clip_paint(clip: &Node) -> Vec { + let paint = clip.clip_paint(); + [paint.fill, paint.stroke].into_iter().flatten().collect() +} + +/// The areas the shown paths and gradient meshes in `n` (an outlined stroke) fill, clipped by its +/// clip groups, whose clipping paths' own fills paint unclipped. A stroke with a gradient along or +/// across it is outlined as gradient meshes clipped to the stroke's outline. +fn fills_in(n: &Node, out: &mut Shapes) { + if !n.visible || n.opacity <= 0.0 || out.too_detailed { + return; + } + match &n.kind { + NodeKind::Mesh(_) => mesh_area(n, out), + NodeKind::Group { children, clip: true } => { + let Some((clip, rest)) = children.split_first() else { return }; + clip_paint(clip).iter().for_each(|part| fills_in(part, out)); + let Some((outline, rule)) = vectorcraft_render::effects::clip_outline(clip) else { return }; + let mut inside = Shapes { anchors: out.anchors, ..Default::default() }; + rest.iter().for_each(|c| fills_in(c, &mut inside)); + inside.anchors -= out.anchors; + out.push_clipped(inside, &PathData::from_bezpath(&outline), rule); + } + _ => match n.children() { + Some(children) => children.iter().for_each(|c| fills_in(c, out)), + None => fills(n, out), + }, + } +} + +/// The anchors [`mesh_area`] makes at most of mesh `m`: its outline's, one a patch along each side, +/// when no point of it is fully transparent, else four by four quads of four anchors a patch. +fn mesh_anchors(m: &vectorcraft_doc::GradientMesh) -> usize { + let (rows, cols) = (m.rows as usize, m.cols as usize); + if opaque(m) { rows.saturating_add(cols).saturating_mul(2) } else { rows.saturating_mul(cols).saturating_mul(64) } +} + +fn opaque(m: &vectorcraft_doc::GradientMesh) -> bool { + m.points.iter().all(|p| p.opacity > 0.0) +} + +/// The area gradient mesh `n` paints: its outline when no point of it is fully transparent, else +/// the pieces of it that are not fully transparent (four by four quads a patch). A mesh whose +/// pieces do not fit in `out` makes the shapes too detailed. +fn mesh_area(n: &Node, out: &mut Shapes) { + let NodeKind::Mesh(m) = &n.kind else { return }; + if !out.room(mesh_anchors(m)) { + return; + } + if opaque(m) { + let outline = m.outline(); + if !outline.subpaths.is_empty() { + out.push((outline, FillRule::NonZero)); + } + return; + } + for q in m.quads(4) { + if q.opacity > 0.0 || q.corners.iter().any(|(_, o)| *o > 0.0) { + out.push((PathData::single(SubPath::polyline(&q.pts, true)), FillRule::NonZero)); + } + } +} + +/// The area path `n` fills, when a shown fill paints it. +fn fills(n: &Node, out: &mut Shapes) { + let paints = |i: &AppearanceItem| matches!(i, AppearanceItem::Fill(f) if f.visible && f.opacity > 0.0 && !f.paint.is_none()); + if n.appearance.items.iter().any(paints) + && let Some(shape) = super::pathops::node_path(n) + { + out.push(shape); + } +} + +/// The document a missing arrowheads file starts as: one example head, a solid triangle. +pub fn starter() -> Document { + let mut doc = Document::new(612.0, 792.0); + doc.title = "Arrowheads".into(); + let name = "Example Arrowhead"; + let tri = SubPath::polyline(&[Point::new(-10.0, -8.0), Point::new(10.0, 0.0), Point::new(-10.0, 8.0)], true); + let art = Node::path(NodeId(0), PathData::single(tri), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 1.0)); + let art = doc.reid(&art); + doc.symbols.push(Symbol { name: name.into(), art: Arc::new(art), dynamic: false }); + // The tip is the right edge's middle. + doc.set_symbol_registration(name, SymbolRegistration(5)); + let id = doc.alloc_id(); + let instance = Node::new(id, NodeKind::SymbolInstance { symbol: name.into(), xf: Affine::translate((306.0, 396.0)) }); + let layer = doc.layers.first().map(|l| l.id); + let _ = doc.insert(layer, 0, instance); + doc +} + +impl Session { + /// Keep the custom arrowheads in the file at `path` and read the heads it holds (none while it + /// is missing; an unreadable file is reported by `stroke.arrowheads.list`). + pub fn attach_arrowheads_file(&mut self, path: String) { + self.arrowheads.path = Some(path); + self.reload_arrowheads(); + } + + /// Read the arrowheads file again. + fn reload_arrowheads(&mut self) { + let Some(path) = self.arrowheads.path.clone() else { return }; + match read(&path) { + Ok(Some(doc)) => self.arrowheads.set(heads_of(&doc)), + Ok(None) => self.arrowheads.set(Read::default()), + Err(e) => { + self.arrowheads.set(Read::default()); + self.arrowheads.error = Some(e); + } + } + } + + /// A document was saved to `path`: when that is the arrowheads file, its heads are read back + /// from what was written. + pub(crate) fn arrowheads_saved(&mut self, path: &str) { + if self.arrowheads.path.as_deref().is_some_and(|a| same_file(a, path)) { + self.reload_arrowheads(); + } + } +} + +fn same_file(a: &str, b: &str) -> bool { + let canon = |p: &str| std::fs::canonicalize(p).unwrap_or_else(|_| std::path::PathBuf::from(p)); + std::path::Path::new(a) == std::path::Path::new(b) || canon(a) == canon(b) +} + +/// The document in the arrowheads file at `path`; `None` when there is no file. +fn read(path: &str) -> std::result::Result, String> { + let size = match std::fs::metadata(path) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(format!("{path}: {e}")), + Ok(m) if !m.is_file() => return Err(format!("{path}: not a file")), + Ok(m) => m.len(), + }; + if size > MAX_FILE_BYTES { + return Err(format!("{path}: larger than {} MB", MAX_FILE_BYTES >> 20)); + } + let bytes = crate::file_access::unconfined(|| super::fileio::read_file(path)).map_err(|e| e.to_string())?; + // A compressed file's document, and its images, may unpack to no more than an uncompressed + // file may hold. + let parse = || vectorcraft_format::load_file_capped(&bytes, MAX_FILE_BYTES).map(|f| f.doc).map_err(|e| format!("{path}: {e}")); + crate::guard::catch_panic(parse).map_err(|_| format!("{path}: could not be read"))?.map(Some) +} + +fn has_file(s: &Session) -> std::result::Result<(), String> { + if s.arrowheads.path.is_some() { Ok(()) } else { Err("no arrowheads file here".into()) } +} + +fn skipped(s: &Session) -> Vec { + s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect() +} + +fn list(s: &mut Session, _: &Value) -> Result { + let heads: Vec = s.arrowheads.heads.iter().map(|h| json!({"name": h.name, "reach": h.reach(), "halfWidth": h.half_width()})).collect(); + Ok(json!({"arrowheads": heads, "file": s.arrowheads.path, "skipped": skipped(s), "error": s.arrowheads.error})) +} + +fn reload(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.reload"; + s.reload_arrowheads(); + if let Some(e) = &s.arrowheads.error { + return Err(bad(C, e.clone())); + } + Ok(json!({"count": s.arrowheads.heads.len(), "skipped": skipped(s)})) +} + +fn edit(s: &mut Session, _: &Value) -> Result { + const C: &str = "stroke.arrowheads.edit"; + let path = s.arrowheads.path.clone().ok_or_else(|| bad(C, "no arrowheads file here"))?; + if let Some(dir) = std::path::Path::new(&path).parent() { + std::fs::create_dir_all(dir).map_err(|e| bad(C, format!("{}: {e}", dir.display())))?; + } + // Made only when no file is there, also when another run makes one at the same moment. + let bytes = vectorcraft_format::save_file(&starter()); + let created = match crate::file_access::unconfined(|| super::fileio::write_new_file(&path, &bytes)) { + Ok(()) => true, + Err(_) if std::path::Path::new(&path).exists() => false, + Err(e) => return Err(bad(C, e.to_string())), + }; + if created { + s.reload_arrowheads(); + } + // Already open: that document comes to the front. + let open = s.documents().iter().position(|st| st.path.as_deref().is_some_and(|p| same_file(p, &path))); + match open { + Some(i) => { + s.set_active(i); + } + None => { + crate::file_access::unconfined(|| s.execute("document.open", &json!({ "path": path })))?; + } + } + Ok(json!({"path": path, "created": created})) +} diff --git a/crates/engine/src/cmd/brushlib.rs b/crates/engine/src/cmd/brushlib.rs index 988aa89572..16ab1c76d4 100644 --- a/crates/engine/src/cmd/brushlib.rs +++ b/crates/engine/src/cmd/brushlib.rs @@ -170,6 +170,7 @@ pub struct BuiltinBrushLibrary { /// The built-in brush libraries, in menu order: original brushes generated in code. pub const BUILTIN: &[BuiltinBrushLibrary] = &[ BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons }, + BuiltinBrushLibrary { id: "ink-and-wash", name: "Ink and Wash", make: ink_and_wash }, BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs }, BuiltinBrushLibrary { id: "bristle-brushes", name: "Bristle Brushes", make: bristle_brushes }, BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders }, @@ -268,6 +269,78 @@ fn art_ribbons() -> BrushLibrary { built("Art Ribbons", doc, brushes) } +/// A band between the edges y = `top(x)` and y = `bottom(x)` for x in `from`..`to`, in `n` steps. +fn between(from: f64, to: f64, n: usize, top: impl Fn(f64) -> f64, bottom: impl Fn(f64) -> f64) -> PathData { + let xs: Vec = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect(); + let mut pts: Vec = xs.iter().map(|&x| Point::new(x, top(x))).collect(); + pts.extend(xs.iter().rev().map(|&x| Point::new(x, bottom(x)))); + PathData::single(SubPath::polyline(&pts, true)) +} + +/// Path `p` filled with gray `hex` at `opacity`: one layer of a wash. +fn wash(p: PathData, hex: &str, opacity: f32) -> Node { + let mut n = filled(p, Paint::solid(Color::from_hex(hex).unwrap_or(Color::BLACK))); + n.opacity = opacity; + n +} + +/// How much of a wash's width is left at x: rounded ends, full width from 8 pt in. +fn wash_ends(x: f64) -> f64 { + let ends = ((x / 8.0).min(1.0) * ((100.0 - x) / 8.0).min(1.0)).max(0.0); + 0.4 + 0.6 * ends.sqrt() +} + +/// Ink and Wash: Tapered Ink, broad near its start and thinning to a fine tip; Brush Pen, a +/// rounded nib whose size follows the pen pressure; and two washes of translucent grays, Soft Wash +/// with faint edges and Pooling Wash with a darker rim and two light breaks. Black and grays take +/// the stroke color as tints of it. +fn ink_and_wash() -> BrushLibrary { + let tapered = { + let half = |x: f64| 3.0 * (x / 22.0).min(1.0).powf(0.6) * ((100.0 - x) / 78.0).min(1.0).powf(1.4); + black(between(0.0, 100.0, 48, |x| -0.85 * half(x), |x| 1.15 * half(x))) + }; + let pen = Brush { + name: "Brush Pen".into(), + kind: BrushKind::Calligraphic(Calligraphic { + angle: 40.0, + roundness: 75.0, + size: 5.0, + variation: [0.0, 0.0, 2.5], + modes: Some([Variation::Fixed, Variation::Fixed, Variation::Pressure]), + }), + }; + let soft = { + let top = |x: f64| -(4.5 + 0.5 * (x * 0.21).sin() + 0.3 * (x * 0.57 + 1.3).sin()) * wash_ends(x); + let bottom = |x: f64| (4.5 + 0.45 * (x * 0.26 + 2.1).sin() + 0.3 * (x * 0.69 + 0.4).sin()) * wash_ends(x); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#404040", 0.4), + wash(between(2.0, 98.0, 32, |x| top(x) - 0.7 - 0.3 * (x * 0.43).sin().abs(), |x| top(x) + 0.5), "#404040", 0.2), + wash(between(3.0, 97.0, 32, |x| bottom(x) - 0.5, |x| bottom(x) + 0.6 + 0.35 * (x * 0.37 + 1.0).sin().abs()), "#404040", 0.18), + wash(shapes::ellipse(Rect::new(21.0, -2.4, 39.0, 0.8)), "#e6e6e6", 0.22), + wash(shapes::ellipse(Rect::new(61.0, -0.3, 75.0, 2.3)), "#e6e6e6", 0.18), + ]) + }; + let pooling = { + let top = |x: f64| -(4.0 + 0.4 * (x * 0.18 + 0.7).sin() + 0.2 * (x * 0.53).sin()) * wash_ends(x); + let bottom = |x: f64| (4.0 + 0.35 * (x * 0.23 + 1.9).sin() + 0.25 * (x * 0.61 + 0.2).sin()) * wash_ends(x); + let rim = |x: f64, k: f64, phase: f64| 0.35 + k * (0.5 + 0.5 * (x * 0.29 + phase).sin()); + group(vec![ + wash(between(0.0, 100.0, 40, top, bottom), "#4d4d4d", 0.35), + wash(between(1.0, 99.0, 40, top, |x| top(x) + rim(x, 0.45, 0.0)), "#262626", 0.55), + wash(between(1.0, 99.0, 40, |x| bottom(x) - rim(x, 0.5, 1.1), bottom), "#262626", 0.55), + wash(polygon(&[(34.0, -2.6), (39.0, -2.8), (36.0, 2.7), (31.0, 2.5)]), "#e6e6e6", 0.2), + wash(polygon(&[(70.0, -2.4), (74.0, -2.5), (72.0, 2.6), (68.0, 2.4)]), "#e6e6e6", 0.18), + ]) + }; + let brushes = vec![ + art("Tapered Ink", tapered, Colorization::Tints), + pen, + art("Soft Wash", soft, Colorization::Tints), + art("Pooling Wash", pooling, Colorization::Tints), + ]; + built("Ink and Wash", Document::new(100.0, 100.0), brushes) +} + fn nib(name: &str, angle: f64, roundness: f64, size: f64) -> Brush { Brush { name: name.into(), kind: BrushKind::Calligraphic(Calligraphic { angle, roundness, size, ..Default::default() }) } } diff --git a/crates/engine/src/cmd/buildcmds.rs b/crates/engine/src/cmd/buildcmds.rs index 53e1b655a1..94fe55c119 100644 --- a/crates/engine/src/cmd/buildcmds.rs +++ b/crates/engine/src/cmd/buildcmds.rs @@ -144,7 +144,7 @@ pub fn specs() -> Vec { "Make", ["Object", "Image Trace"], None, - "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", + "{id?: image or Image Trace group (default: selection), preset?: name (imageTrace.presets), savedPreset?: {settings} (a saved preset's settings, which the journal records so a replay traces with them after the preset is changed, renamed or deleted; used in place of the saved preset `preset` names), enhanced?: false (the preset's enhanced version: with Auto Grouping, and with Shapes in all but High Fidelity Photo; Flat Logo has none and is traced as it is), params?: {mode: \"blackAndWhite\"|\"grayscale\"|\"color\"|\"logo\", threshold: 0-255, palette: \"limited\"|\"fullTone\"|\"automatic\"|\"documentLibrary\" (color mode; limited: the `colors` best colours, fullTone: as many as the tones need, more with colorDetail, automatic: flat art with its own colours, else fullTone, documentLibrary: at most `colors` of the library's colours, as they are), colors: 2-256, colorDetail: 0-100 (fullTone, automatic), library: \"document\" (the document's swatches) or a swatch library id or name (swatch.library.list; documentLibrary), colorGroup?: name (color mode, documentLibrary with library \"document\": the colors of that document color group instead of every swatch; \"\" is every swatch), paths: 0-100, corners: 0-100, noise: px, method: \"abutting\"|\"overlapping\", ignoreWhite (leave out white areas), ignoreColor?: \"#rrggbb\" (with ignoreWhite: the colour left out instead of white), ignoreBackground: false (with ignoreWhite: only the areas of the ignored color that reach the image's edge are left out), keepColors?: [\"#rrggbb\"…] (not mode logo: only the palette colors within about 30 levels of a listed color are traced; empty: every color), snapCurvesToLines, shapes: false (filled outlines that are a circle, an ellipse, a rectangle or a regular polygon come out as that live shape), autoGrouping: false (each path that has others inside it is grouped with them), gradients: false (grayscale and color: an area whose colour changes evenly along one direction is one path with a linear gradient fill), fills: true, strokes: false (Create: lines up to strokeWidth wide become stroked centre lines; without fills, wider areas become stroked outlines), strokeWidth: px (10), logoColors?: [\"#rrggbb\"…] (mode logo: flat-colour logos only; colours found automatically or named here, at most ten; white is its own color, and a named color with every channel 245 or more is taken as that white, except with logoColors named, logoWhite false and no ignoreWhite; noise = areas under noise/10 px ignored; edges to a fraction of a pixel, sharp corners restored, lines and circles snapped; enclosed transparent areas are painted white while white is its own color, and stay holes with ignoreWhite or without a white of its own; fills/strokes are ignored; gradients and photos are refused, judged by the image's own colors also when logoColors are named), logoWhite: true (mode logo with logoColors named; false traces the named colors only: white areas take the nearest of them, a named white is kept and enclosed transparent areas stay holes; with ignoreWhite, white areas are left out either way)}, view?: as imageTrace.setView (default: the traced object's own, else tracingResult), ids?: [id…] (instead of id)} → {id, paths, anchors, colors, warnings: [text…] (the image is over 1500 px on a side, so it traces more slowly; or it was traced scaled down; Flat Logo names only the size it traced a larger image at; or a keepColors color matches none the trace found, or nothing was traced)}", has_selection_or_ids, |s, p| trace_make(s, p, false) ), diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 9463dd8cf1..e98dbd5619 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result { if text.trim().is_empty() { return Err(bad(C, "the text is empty")); } - let style = super::super::create::new_type_style(s, &Value::Null); + let style = super::super::create::new_type_style(s, &Value::Null, C)?; let system_layout = s.doc()?.doc.setup.system_layout; let layout = |t: &mut TextObject| { t.system_layout = system_layout; diff --git a/crates/engine/src/cmd/colormgmt.rs b/crates/engine/src/cmd/colormgmt.rs index 9ec21b6541..1046c4a948 100644 --- a/crates/engine/src/cmd/colormgmt.rs +++ b/crates/engine/src/cmd/colormgmt.rs @@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result { let remembered = s.store_profile(&info.name, &bytes); // A new file of a working space: the active settings still hold the old one. let working = cms::active_settings(); - if working.rgb == info.name || working.cmyk == info.name { + let is_working = working.rgb == info.name || working.cmyk == info.name; + if is_working { cms::set_active(&working).map_err(|e| bad(C, e.to_string()))?; + } + // A new file of a working space, or of the CMYK profile View → Proof Setup targets: redraw. + if is_working || proof_targets(&info.name) { touch_all(s); } Ok(json!({"name": info.name, "kind": info.kind, "remembered": remembered})) @@ -233,10 +237,17 @@ fn remove_profile(s: &mut Session, p: &Value) -> Result { return Err(bad(C, format!("no profile named `{name}`"))); } s.unstore_profile(&info.name); - touch_all(s); + if proof_targets(&info.name) { + touch_all(s); + } Ok(json!({"removed": info.name})) } +/// Whether View → Proof Setup targets the CMYK profile `name`. +fn proof_targets(name: &str) -> bool { + matches!(proof::view().setup.target, ProofTarget::Cmyk(t) if t == name) +} + #[cfg(target_arch = "wasm32")] fn load_profile(_: &mut Session, _: &Value) -> Result { Err(bad("color.loadProfile", "loading profiles from disk isn't available in the browser")) @@ -763,17 +774,33 @@ fn proof_setup(s: &mut Session, p: &Value) -> Result { if let Some(on) = on_param(p, "proof", C)? { v.proof_colors = on; } - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } +/// Make `v` the proof view and redraw every document. When `v` equals the current view, the view +/// and the revisions stay as they are; Separations Preview's visible plates are compared as a set. +fn store_view(s: &mut Session, v: proof::ProofView) { + let shown = |v: &proof::ProofView| proof::ProofView { + separations: v.separations.as_ref().map(|vis| { + let mut vis = vis.clone(); + vis.sort(); + vis.dedup(); + vis + }), + ..v.clone() + }; + if shown(&v) != shown(&proof::view()) { + proof::set_view(v); + touch_all(s); + } +} + fn toggle_view(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&mut proof::ProofView, Option)) -> Result { let on = on_param(p, "on", cmd)?; let mut v = proof::view(); f(&mut v, on); - proof::set_view(v.clone()); - touch_all(s); + store_view(s, v.clone()); Ok(view_json(&v)) } @@ -805,7 +832,15 @@ fn separations(s: &mut Session, p: &Value) -> Result { match p.get("plates") { None | Some(Value::Null) => {} Some(Value::Array(a)) if a.iter().all(Value::is_string) => { - v.separations = Some(a.iter().filter_map(Value::as_str).map(check).collect::>>()?); + let mut vis: Vec = Vec::new(); + for n in a.iter().filter_map(Value::as_str) { + let n = check(n)?; + // A plate named twice is shown once. + if !vis.contains(&n) { + vis.push(n); + } + } + v.separations = Some(vis); } Some(_) => { let key = "plates"; @@ -824,8 +859,7 @@ fn separations(s: &mut Session, p: &Value) -> Result { vis.push(n); } } - proof::set_view(v); - touch_all(s); + store_view(s, v); Ok(plates_json(s)) } diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 37473cc59f..ebef729fd3 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -109,7 +109,7 @@ pub fn specs() -> Vec { "Create Text", [], None, - "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt, font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", + "{x, y, text, rotation?: deg clockwise about x, y (0: level), vertical?: bool = false, size?: pt 0.1..1296 (outside it: an error, and no type is made), font?: family, style? (default Regular, or the family's closest style when it has no Regular), color? (#rrggbbaa and #rgba are refused), keepModel?: bool (as paint.setFill), area?: {width, height, fit?: none|autoHeight|shrinkText|block, fitMinPercent?, wordGap?, lineGap? (as text.areaOptions)}, placeholder?: bool (fill it with placeholder text instead, as the Type tools do with prefs placeholderText), justify?: \"auto\" (default: the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\" (point type: aligned on x, so \"center\" centres each line on the point and \"right\" ends it there; text.setStyle changes it later), leadingModel?: \"romanBaseline\"|\"emBoxTop\", charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (default: emBoxTop and emBoxCenter while the interface is in Japanese, else romanBaseline)} → {id, bounds: {x, y, width, height} (the new type's box as document.inspect reports it, after justify), overflow?: [id] (area type whose text doesn't fit its frame: the lines past it aren't drawn; left out when it fits), warning?: text (the font or style given isn't available: the type is drawn in the fallback font or the family's closest style)}; new area type gets Auto Size (fit autoHeight) when the autoSizeAreaType preference is on", has_doc, text_create ), @@ -739,7 +739,7 @@ fn text_create(s: &mut Session, p: &Value) -> Result { check_numbers(a, &["width", "height"], "text.create")?; } let text = str_param(p, "text").unwrap_or(""); - let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p)); + let mut t = TextObject::point(Point::new(x, y), text, new_type_style(s, p, "text.create")?); // Turned about the point (the first baseline's start, an area's top-left corner): type set // along a Constrain Angle. let rotation = if p.get("rotation").is_some() { f64_req(p, "rotation", "text.create")? } else { 0.0 }; @@ -848,11 +848,11 @@ pub(crate) fn new_area_fit(s: &Session, p: &Value, c: &str, t: &mut TextObject) /// The character style new type gets: `size`, `font`, `style` and `color` from `p` (an RGB /// `color` in a CMYK document as [`super::paint::new_color`] stores it), else the defaults, /// filled with the current fill unless that is None or white (black then), in the document's -/// language. -pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { +/// language. A `size` outside [`super::typecmd::TYPE_SIZES`] is an error. +pub(crate) fn new_type_style(s: &Session, p: &Value, cmd: &str) -> Result { let mut style = CharStyle::default(); if let Some(sz) = p.get("size").and_then(Value::as_f64) { - style.size = sz.clamp(0.1, 1296.0); + style.size = super::typecmd::given_type_size(sz, cmd)?; } if let Some(f) = str_param(p, "font") { style.font_family = f.to_string(); @@ -873,5 +873,5 @@ pub(crate) fn new_type_style(s: &Session, p: &Value) -> CharStyle { let chosen = |name: &str| vectorcraft_doc::setup::char_language(name).ok().flatten(); let document = s.doc().ok().and_then(|d| chosen(&d.doc.setup.language)); style.language = document.or_else(|| chosen(&s.prefs.hyphenation_language)).map(str::to_string); - style + Ok(style) } diff --git a/crates/engine/src/cmd/css.rs b/crates/engine/src/cmd/css.rs index ed83a01410..7cb2ff138d 100644 --- a/crates/engine/src/cmd/css.rs +++ b/crates/engine/src/cmd/css.rs @@ -7,8 +7,8 @@ use serde_json::{Value, json}; use vectorcraft_doc::NodeId; use vectorcraft_svg::{CssOptions, CssSheet, css_rules}; -use super::fileio::{default_name, encode_with_warnings, isolated, write_file}; -use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets}; +use super::fileio::{Note, default_name, encode_with_notes, isolated, note_texts, write_file}; +use super::{CommandSpec, Result, Session, bad, cmd, has_doc, named_ids, targets}; pub fn specs() -> Vec { vec![ @@ -21,13 +21,13 @@ pub fn specs() -> Vec { has_doc, selection ), - cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document", has_doc, generate), + cmd!(query "css.generate", "Generate CSS", [], None, "{…options as css.selection} css.selection of every object in the document; ids and id are not used, and a malformed one is an error", has_doc, generate), cmd!( "css.export", "Export CSS", [], None, - "{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", + "{path?: string, scope?: selection|all (default selection when left out; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} a path or scope given as anything but a string, null included, is an error, and so is a malformed ids or id with scope all, which does not use them; write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: .css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once", has_doc, export ), @@ -77,16 +77,34 @@ fn selection(s: &mut Session, p: &Value) -> Result { Ok(result(&sheet(s, p, ids, "css.selection")?)) } +/// Checks the form of the `ids` or `id` that `p` gives a command that exports every object and does +/// not use them: a malformed one is an error, as it is for the selection. +fn unused_ids(p: &Value) -> Result<()> { + named_ids(p).map(|_| ()) +} + fn generate(s: &mut Session, p: &Value) -> Result { + unused_ids(p)?; let ids = everything(s)?; Ok(result(&sheet(s, p, ids, "css.generate")?)) } +/// The string `p` gives for `key`, or `None` when `p` leaves it out. Any value given that is not a +/// string, null included, is an error: "`{key}` must be a string". +fn given_text<'a>(p: &'a Value, key: &str, cmd: &str) -> Result> { + p.get(key).map(|v| v.as_str().ok_or_else(|| bad(cmd, format!("`{key}` must be a string")))).transpose() +} + fn export(s: &mut Session, p: &Value) -> Result { const C: &str = "css.export"; - let ids = match str_param(p, "scope").unwrap_or("selection") { + let scope = given_text(p, "scope", C)?; + let path = given_text(p, "path", C)?; + let ids = match scope.unwrap_or("selection") { "selection" => targets(s, p)?, - "all" => everything(s)?, + "all" => { + unused_ids(p)?; + everything(s)? + } other => return Err(bad(C, format!("scope must be selection or all (got {other})"))), }; let sheet = sheet(s, p, ids, C)?; @@ -100,22 +118,24 @@ fn export(s: &mut Session, p: &Value) -> Result { } let css = format!("{}\n", sheet.text()); let doc = &s.doc()?.doc; - // Encode the pictures of rasterized art, and add each of their warnings once. + // Encode the pictures of rasterized art, and add each of their warnings once. What CSS can't + // describe is a loss. let mut images = Vec::new(); - let mut warnings = sheet.warnings.clone(); + let mut notes: Vec = sheet.warnings.iter().cloned().map(Note::loss).collect(); for r in &sheet.rules { let Some(name) = &r.image else { continue }; let (d, _) = isolated(doc, &[r.id], name).ok_or_else(|| bad(C, format!("{} paints nothing to rasterize", r.selector)))?; - let (png, notes) = encode_with_warnings(&d, "png", &json!({}))?; + let (png, given) = encode_with_notes(&d, "png", &json!({}))?; images.push((name, png)); - for w in notes { - if !warnings.contains(&w) { - warnings.push(w); + for n in given { + if !notes.iter().any(|x| x.text == n.text) { + notes.push(n); } } } + let warnings = note_texts(¬es); let n = sheet.rules.len(); - match str_param(p, "path") { + match path { Some(path) => { write_file(path, css.as_bytes())?; let folder = std::path::Path::new(path).parent(); diff --git a/crates/engine/src/cmd/dimension.rs b/crates/engine/src/cmd/dimension.rs index e1b88d5d9f..44ac08d7ad 100644 --- a/crates/engine/src/cmd/dimension.rs +++ b/crates/engine/src/cmd/dimension.rs @@ -568,7 +568,7 @@ fn appearance(o: &DimensionOptions, mode: ColorMode, ext: bool, arrows: bool) -> ArrowStyle::Tick => Some(Arrowhead::Slash), ArrowStyle::None => None, }; - st.start_arrow = head; + st.start_arrow = head.clone(); st.end_arrow = head; st.arrow_scale = (o.arrow_scale, o.arrow_scale); st.arrow_align = vectorcraft_doc::appearance::ArrowAlign::Tip; diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs index 4ed63e7ec1..ac79d0acc7 100644 --- a/crates/engine/src/cmd/docinfo.rs +++ b/crates/engine/src/cmd/docinfo.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { "Document Info", ["Window", "Document Info"], None, - "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the Brushes panel's brushes; with selectionOnly: the brushes the selected objects' strokes use), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", + "{selectionOnly?, category?: document|objects|graphicStyles|brushes|spotColors|patterns|gradients|symbols|fonts|fontDetails|linkedImages|embeddedImages|repeats (only that one in sections and the text), format?: \"text\" (→ {text}: the report Document Info › Save… and Package write)} → {document, objects: {paths, compoundPaths, groups, …}, pathLength: pt (the paths' total length, compound paths' included, guides left out), fonts, images, swatches, graphicStyleNames (with selectionOnly: the styles the selected objects are linked to), brushNames (the brushes the art's drawn strokes use, each once; with selectionOnly: those the selected objects' strokes use), brushes: [{name, type: calligraphic|scatter|art|pattern|bristle|null}] (those brushes with their types; null: not in the Brushes panel), panelBrushNames (the Brushes panel's brushes), repeatObjects: [{id, name, type: radial|grid|mirror, instances, rows?, cols?}] (the Repeat objects, those in groups and other repeats included; with selectionOnly: those in the selection), spotObjects: [{id, name, spotColors}] (the objects filled or stroked with a spot color swatch, as a solid color or in the stops of a gradient with a stop above opacity 0, at a tint above 0, and grayscale images colorized with one, each once, by the rules patternObjects follows, with the spot colors they use; Registration and process colors are not spot colors; objects.spots counts them), spotColorNames and spotColors (the spot colors those objects use), patternObjects: [{id, name, patterns}] (the objects filled or stroked with a pattern, each once, those in groups and type whose drawn characters use one included, hidden fills and strokes, fills and strokes at an opacity of 0, strokes with no width, guides, compound path members' own fills and strokes, images' fills and strokes without a shape and characters that draw nothing left out, with the patterns they use, which objects.patterns counts; with selectionOnly: those in the selection), patternNames (the patterns those objects use), gradientObjects: [{id, name, gradients}] (the objects filled or stroked with a gradient, by the same rules, with the gradients they use: a linked gradient swatch's name, else Linear gradient, Radial gradient or Freeform gradient; objects.gradients counts them), gradientNames (the gradients those objects use), …, sections: [{id, title, rows: [[label, value]]}] (the panel's categories; fontDetails: each font's file and whether its license lets it be embedded)}. An image linked to a file outside the automation read roots has link null, and its Linked Images row names the file alone", has_doc, info ), @@ -131,14 +131,16 @@ fn repeat_entry(d: &Document, n: &Node, spec: &RepeatSpec) -> (Value, (String, S } /// The Document Info categories of `d` (only `category` when given) from `info` (what -/// `document.info` reports), its fonts, its image objects, the rows of its Repeat objects and the -/// rows of its objects filled or stroked with a spot color, with a pattern and with a gradient. +/// `document.info` reports), its fonts, its image objects, the rows of the brushes its art uses, the +/// rows of its Repeat objects and the rows of its objects filled or stroked with a spot color, with a +/// pattern and with a gradient. #[allow(clippy::too_many_arguments)] fn sections( d: &Document, info: &Value, fonts: &BTreeSet, images: &[ImageRow], + brushes: &[(String, String)], repeats: &[(String, String)], spot_objects: &[(String, String)], pattern_objects: &[(String, String)], @@ -184,7 +186,7 @@ fn sections( rows } "graphicStyles" => names("graphicStyleNames"), - "brushes" => names("brushNames"), + "brushes" => brushes.to_vec(), "spotColors" => spot_objects.to_vec(), "patterns" => pattern_objects.to_vec(), "gradients" => gradient_objects.to_vec(), @@ -326,12 +328,18 @@ fn painted_runs<'a>(d: &Document, spots: &HashSet<&str>, n: &'a Node) -> Option< if !own && !t.runs.iter().any(|r| is_listed(&r.style.fill) || is_listed(&r.style.stroke)) { return None; } + Some((t, drawn_runs(d, t))) +} + +/// The runs of type `t` that its layout draws: those with a glyph outline (list markers and ruby +/// included) or an underline or strikethrough bar, laid out as the renderer lays it out. +fn drawn_runs(d: &Document, t: &vectorcraft_doc::TextObject) -> BTreeSet { let db = vectorcraft_text::FontDb::global(); let resolved = d.inline_resolved(t); let lay = vectorcraft_text::layout(db, &resolved); let mut painted: BTreeSet = lay.glyphs.iter().chain(&lay.ruby).filter(|g| !g.outline.elements().is_empty()).map(|g| g.run).collect(); painted.extend(vectorcraft_text::decorations(&lay, db, &resolved).into_iter().map(|(run, _)| run)); - Some((t, painted)) + painted } /// The names a paint is listed by in a Document Info category: its spot colors, its pattern or its @@ -340,7 +348,8 @@ type PaintNames<'a> = dyn Fn(&Paint) -> Vec + 'a; /// The spot colors, patterns or gradients (`label`: [`spot_labels`], [`pattern_label`] or /// [`gradient_label`], each paint's names) that `n`'s own fills and strokes paint, each once. A -/// hidden fill or stroke and a stroke with no width paint nothing, and neither does any fill or +/// hidden fill or stroke, a fill or stroke at an opacity of 0 and a stroke with no width paint +/// nothing, and neither does any fill or /// stroke of a guide (drawn in the guide color), of a compound path's member (`members`: the /// compound path's own fills and strokes paint it) or of type whose layout draws nothing /// (`painted_runs`). An image's fills and strokes paint only where an effect gives them a shape. @@ -364,7 +373,7 @@ fn own_paints( .then(|| vectorcraft_render::effects::image_paint(Some(d), n).into_iter().flat_map(|(_, _, item)| label(item.paint())).collect()); let mut out: Vec = vec![]; for item in &n.appearance.items { - if !item.visible() || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { + if !item.visible() || item.opacity() <= 0.0 || matches!(item, vectorcraft_doc::AppearanceItem::Stroke(s) if s.width <= 0.0) { continue; } for name in label(item.paint()) { @@ -381,6 +390,41 @@ fn one_label(label: fn(&Paint) -> Option) -> impl Fn(&Paint) -> Vec