mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 10:01:58 +00:00
Merge main into feat/actions-select-object
This commit is contained in:
Generated
+13
-13
@@ -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]]
|
||||
|
||||
+3
-3
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -187,6 +187,9 @@ pub struct HpglOutput {
|
||||
pub pens: Vec<[u8; 3]>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
/// Notes on the options: sorting the paths or drawing the inner paths first was applied only
|
||||
/// in part.
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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<Vec<(i64, i64)>>,
|
||||
mut at: (i64, i64),
|
||||
work: &mut u64,
|
||||
nearest: bool,
|
||||
inside_first: bool,
|
||||
warnings: &mut Vec<String>,
|
||||
notes: &mut Vec<String>,
|
||||
) -> (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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)][..]));
|
||||
|
||||
@@ -410,6 +410,9 @@ pub struct DxfOutput {
|
||||
pub images: Vec<DxfImage>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
/// What was left out because an option turns it off (Include Placed Images or Include Fill
|
||||
/// Hatches off).
|
||||
pub setting_notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// The smallest and largest `scale` (drawing units per unit).
|
||||
|
||||
+15
-3
@@ -520,6 +520,8 @@ pub(crate) struct Scene<'a> {
|
||||
/// Image keys → index into `images` (`None`: left out).
|
||||
by_key: HashMap<String, Option<usize>>,
|
||||
warnings: Vec<String>,
|
||||
/// What was left out because an option asked for it ([`DxfOutput::setting_notes`]).
|
||||
setting_notes: Vec<String>,
|
||||
brushes: Option<Vec<vectorcraft_brush::Brush>>,
|
||||
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 };
|
||||
|
||||
+13
-8
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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<Padded> {
|
||||
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<GradientStop> {
|
||||
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<GradientStop>, budget: usize, method: GradientMethod) -> Vec<GradientStop> {
|
||||
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<usize> = (0..n).map(|i| i.saturating_sub(1)).collect();
|
||||
let mut next: Vec<usize> = (1..=n).collect();
|
||||
let mut gone = vec![false; n];
|
||||
let mut version = vec![0u32; n];
|
||||
let mut heap: BinaryHeap<Reverse<(u32, usize, u32)>> = (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<GradientStop> {
|
||||
let mut g = Gradient::new(GradientKind::Linear, run.to_vec());
|
||||
|
||||
@@ -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<GradientStop> = (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<GradientStop> = (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<GradientStop> = (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<f32> = 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<f32> = 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<f32> = 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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<CustomArrowheadData> 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::<usize>();
|
||||
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<Rect> {
|
||||
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<CustomArrowhead>),
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
+302
-13
@@ -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<WidthProfile> {
|
||||
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<f64> = 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<f64> {
|
||||
let mut at: Vec<f64> = 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<V: Copy>(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<Vec2> {
|
||||
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<SubPath>,
|
||||
b: Vec<SubPath>,
|
||||
/// 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<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
// 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<Node>], spec: &BlendSpec) -> Vec<Node> {
|
||||
#[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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -119,6 +119,12 @@ impl BlendSpec {
|
||||
pub fn start(&self, i: usize) -> Option<usize> {
|
||||
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.
|
||||
|
||||
+237
-69
@@ -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<Item = (Point, Vec2,
|
||||
/// path: its tip and the corner's outer offset points, or `None` when the path doesn't turn there
|
||||
/// or the miter limit bevels it.
|
||||
fn miter(at: Point, t_in: Vec2, t_out: Vec2, side: f64, limit: f64) -> 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<Rect> {
|
||||
/// 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<Rect> {
|
||||
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<Rect>, 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<usize>) -> 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<usize>) -> 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]
|
||||
|
||||
@@ -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<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -401,6 +408,7 @@ impl From<ParaStyle> for ParaStyleFile {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -431,6 +439,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
@@ -456,6 +465,7 @@ impl From<ParaStyleFile> for ParaStyle {
|
||||
burasagari,
|
||||
kinsoku,
|
||||
kinsoku_type,
|
||||
bunri_kinshi,
|
||||
roman_hanging,
|
||||
kashida,
|
||||
korean_line_break_anywhere,
|
||||
|
||||
@@ -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<Node> {
|
||||
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<Node> {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EffectInfo> {
|
||||
"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<EffectInfo> {
|
||||
"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(
|
||||
|
||||
@@ -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<f64> = 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
|
||||
|
||||
@@ -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<Node> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<RasterFx> {
|
||||
effects
|
||||
followed(effects)
|
||||
.iter()
|
||||
.filter(|e| e.visible)
|
||||
.filter_map(|e| {
|
||||
@@ -426,6 +429,116 @@ pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
|
||||
.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<Vec<Effect>> = 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<Effect> {
|
||||
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<Affine> {
|
||||
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 {
|
||||
|
||||
+123
-28
@@ -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<Arc<Node>> = 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<usize> = (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<Effect> =
|
||||
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<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
let rest: Vec<Effect> = 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<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
*it.effects_mut() = rest;
|
||||
let rest: Vec<Effect> = 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<Option<Rect>>) {
|
||||
|
||||
@@ -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<Vec<PathData>, String> {
|
||||
let faces = |path: &PathData, b: Rect, options: Revolve| -> Result<Vec<PathData>, 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<Vec<crate::SolidMesh>, String> {
|
||||
let index = n
|
||||
@@ -558,17 +614,11 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
|
||||
}
|
||||
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<Node> {
|
||||
// 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();
|
||||
|
||||
@@ -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<Node> {
|
||||
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<Node> {
|
||||
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<Effect> {
|
||||
let (before, after) = effects.split_at(index.min(effects.len()));
|
||||
let after = after.get(1..).unwrap_or_default();
|
||||
let kept: Vec<Effect> = 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 {
|
||||
|
||||
@@ -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<Vec<PathData>, 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<PathData>, revolve: bool) -> Vec<PathData> {
|
||||
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<PathData>, later: &[Effect], ctx: &GeomContext) -> Vec<PathData> {
|
||||
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<Vec<PathData>, 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<Vec<PathData>, 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<Result<Vec<Facet>, 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<Vec<PathData>, String> {
|
||||
let mut seen: HashSet<[Snap; 4]> = HashSet::new();
|
||||
let mut segments: Vec<CubicBez> = 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<PathData> {
|
||||
let mut at: HashMap<Snap, Vec<usize>> = 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<bool>, point: Snap, leave: bool| -> Option<CubicBez> {
|
||||
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<CubicBez> = 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<CubicBez>| 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<CubicBez>, closed: bool) -> SubPath {
|
||||
let mut anchors: Vec<Anchor> = 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;
|
||||
@@ -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<PathData> {
|
||||
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());
|
||||
}
|
||||
@@ -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<Arrowhead>, 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<Arrowhead>, 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::<Vec<_>>());
|
||||
let ring_poly = |p: &mut BezPath, pts: &[(f64, f64)]| ring_polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>(), 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
|
||||
|
||||
@@ -134,6 +134,10 @@ impl Plan {
|
||||
|
||||
fn fitted(mut segs: Vec<PathSeg>, mut lens: Vec<f64>, 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<usize> = (0..n).filter(|&i| lens[i] > EPS).collect();
|
||||
let mut corners: Vec<usize> = live.windows(2).filter(|w| kink(&segs[w[0]], &segs[w[1]])).map(|w| w[1]).collect();
|
||||
|
||||
@@ -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<f64> {
|
||||
|
||||
/// 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<GradientMesh> {
|
||||
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<f64> = if across { vec![] } else { at_stops.clone() };
|
||||
let marks: Vec<f64> = 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<f64> = 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<f64> = 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<std::ops::Range<usize>> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<GradientStop> = (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.
|
||||
|
||||
@@ -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<Arrowhead> {
|
||||
use vectorcraft_geom::{Point as P, SubPath};
|
||||
let head = |name: &str, pts: &[(f64, f64)]| {
|
||||
let pts: Vec<P> = 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}%"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<f64>(), 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());
|
||||
|
||||
@@ -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<Effect>) -> 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<Effect>) -> 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<Effect> {
|
||||
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<f64> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -137,7 +137,7 @@ fn legend(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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.
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
"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"));
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -139,7 +139,8 @@ fn text_tweaker(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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) {
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
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<String>,
|
||||
heads: Vec<Arc<CustomArrowhead>>,
|
||||
skipped: Vec<(String, String)>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
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<CustomArrowhead>] {
|
||||
&self.heads
|
||||
}
|
||||
/// Head `name`.
|
||||
pub fn get(&self, name: &str) -> Option<Arc<CustomArrowhead>> {
|
||||
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<CustomArrowhead>,
|
||||
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<CustomArrowhead, &'static str> {
|
||||
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<PathData> {
|
||||
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<Node> {
|
||||
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<Option<Document>, 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<Value> {
|
||||
s.arrowheads.skipped.iter().map(|(name, reason)| json!({"name": name, "reason": reason})).collect()
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let heads: Vec<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
@@ -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<f64> = (0..=n).map(|i| from + (to - from) * i as f64 / n as f64).collect();
|
||||
let mut pts: Vec<Point> = 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() }) }
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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)
|
||||
),
|
||||
|
||||
@@ -491,7 +491,7 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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;
|
||||
|
||||
@@ -210,8 +210,12 @@ fn load_profile(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<bool>)) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Result<Vec<_>>>()?);
|
||||
let mut vis: Vec<String> = 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<Value> {
|
||||
vis.push(n);
|
||||
}
|
||||
}
|
||||
proof::set_view(v);
|
||||
touch_all(s);
|
||||
store_view(s, v);
|
||||
Ok(plates_json(s))
|
||||
}
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<Value> {
|
||||
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<CharStyle> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
vec![
|
||||
@@ -21,13 +21,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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: <document>.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: <document>.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<Value> {
|
||||
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<Value> {
|
||||
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<Option<&'a str>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
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<Note> = 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<super::fonts::UsedFont>,
|
||||
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<usize> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let resolved = d.inline_resolved(t);
|
||||
let lay = vectorcraft_text::layout(db, &resolved);
|
||||
let mut painted: BTreeSet<usize> = 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<String> + '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<String> = 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<String>) -> impl Fn(&Paint) -> Vec<Stri
|
||||
move |p| label(p).into_iter().collect()
|
||||
}
|
||||
|
||||
/// The brushes `n`'s own strokes draw with, in the order of its appearance. A hidden stroke, a
|
||||
/// stroke at an opacity of 0, a stroke with no paint and a stroke with no width draw nothing, and neither does any stroke of a
|
||||
/// guide (drawn in the guide color) or of a compound path's member (`members`: the compound path's
|
||||
/// own strokes draw it). An image's strokes draw only where an effect gives them a shape, and only
|
||||
/// paths, compound paths, type and images draw strokes of their own.
|
||||
fn applied_brushes(d: &Document, n: &Node, members: &HashSet<*const Node>) -> Vec<String> {
|
||||
if matches!(n.kind, NodeKind::Path { guide: true, .. }) || members.contains(&std::ptr::from_ref(n)) {
|
||||
return vec![];
|
||||
}
|
||||
let brush = |item: &vectorcraft_doc::AppearanceItem| match item {
|
||||
vectorcraft_doc::AppearanceItem::Stroke(st) if item.visible() && st.opacity > 0.0 && !st.paint.is_none() && st.width > 0.0 => {
|
||||
st.brush.clone().filter(|b| !b.is_empty())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if matches!(n.kind, NodeKind::Image(_)) {
|
||||
return vectorcraft_render::effects::image_paint(Some(d), n).iter().filter_map(|(_, _, item)| brush(item)).collect();
|
||||
}
|
||||
let used: Vec<String> = n.appearance.items.iter().filter_map(brush).collect();
|
||||
if used.is_empty() {
|
||||
return used;
|
||||
}
|
||||
// A path or compound path with no segment gives its strokes nothing to follow, and neither does
|
||||
// type whose layout draws nothing (spaces and line breaks without an underline or strikethrough).
|
||||
let segments = |pd: &vectorcraft_geom::PathData| pd.subpaths.iter().any(|sp| sp.segment_count() > 0);
|
||||
let follows = match &n.kind {
|
||||
NodeKind::Path { .. } => n.path_data().is_some_and(segments),
|
||||
NodeKind::Compound { children, .. } => children.iter().filter_map(|c| c.path_data()).any(segments),
|
||||
NodeKind::Text(t) => !drawn_runs(d, t).is_empty(),
|
||||
// A brush on any other kind of object (a layer, a group, a ruler guide) is not listed.
|
||||
_ => false,
|
||||
};
|
||||
if follows { used } else { vec![] }
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "document.info";
|
||||
let category = str_param(p, "category");
|
||||
@@ -425,8 +469,10 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut spot = BTreeSet::new();
|
||||
let (spot_label, pattern, gradient) = (|p: &Paint| spot_labels(&spot_swatches, p), one_label(pattern_label), one_label(gradient_label));
|
||||
let mut styles: Vec<&str> = if selection_only { vec![] } else { d.graphic_styles.iter().map(|g| g.name.as_str()).collect() };
|
||||
// `brushes` holds the Brushes panel's brushes, or with selectionOnly it holds the brushes that the selected objects' strokes use.
|
||||
let mut brushes: Vec<String> = if selection_only { vec![] } else { vectorcraft_brush::library(d).into_iter().map(|b| b.name).collect() };
|
||||
// `library` holds the Brushes panel's brushes, and `brushes` the brushes the visited objects' strokes draw with, each once,
|
||||
// in walk order.
|
||||
let library = vectorcraft_brush::library(d);
|
||||
let mut brushes: Vec<String> = vec![];
|
||||
// `repeats` and `repeat_rows` hold the `repeatObjects` entries and the rows of the Repeat objects that the walk visits, in walk order.
|
||||
let mut repeats: Vec<Value> = vec![];
|
||||
let mut repeat_rows: Vec<(String, String)> = vec![];
|
||||
@@ -569,14 +615,9 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
styles.push(&g.name);
|
||||
}
|
||||
if selection_only {
|
||||
for item in &n.appearance.items {
|
||||
if let vectorcraft_doc::AppearanceItem::Stroke(st) = item
|
||||
&& let Some(b) = &st.brush
|
||||
&& !brushes.contains(b)
|
||||
{
|
||||
brushes.push(b.clone());
|
||||
}
|
||||
for b in applied_brushes(d, n, &members) {
|
||||
if !brushes.contains(&b) {
|
||||
brushes.push(b);
|
||||
}
|
||||
}
|
||||
if let NodeKind::Repeat(spec) = &n.kind {
|
||||
@@ -587,6 +628,17 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
let font_names: Vec<String> = fonts.iter().map(super::fonts::font_label).collect();
|
||||
// Each brush the art uses with its type, found by name in the Brushes panel as the renderer finds it (the last of two
|
||||
// brushes with one name); a brush the panel does not hold draws as a plain stroke and has no type.
|
||||
let kinds: HashMap<&str, &vectorcraft_brush::BrushKind> = library.iter().map(|b| (b.name.as_str(), &b.kind)).collect();
|
||||
let brush_entries: Vec<Value> =
|
||||
brushes.iter().map(|name| json!({ "name": name, "type": kinds.get(name.as_str()).map(|k| k.type_id()) })).collect();
|
||||
let brush_rows: Vec<(String, String)> = brushes
|
||||
.iter()
|
||||
.map(|name| {
|
||||
(name.clone(), kinds.get(name.as_str()).map_or_else(|| "missing from the Brushes panel".to_string(), |k| format!("{} brush", k.label())))
|
||||
})
|
||||
.collect();
|
||||
let mut out = json!({
|
||||
"document": document_summary(d),
|
||||
"objects": counts,
|
||||
@@ -601,6 +653,8 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"graphicStyles": d.graphic_styles.len(),
|
||||
"graphicStyleNames": styles,
|
||||
"brushNames": brushes,
|
||||
"brushes": brush_entries,
|
||||
"panelBrushNames": library.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(),
|
||||
"repeatObjects": repeats,
|
||||
"characterStyles": d.char_styles.len(),
|
||||
"paragraphStyles": d.para_styles.len(),
|
||||
@@ -612,7 +666,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
// The document's name: its file's, else its title.
|
||||
out["document"]["name"] = json!(st.title());
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
let sections = sections(d, &out, &fonts, &image_rows, &brush_rows, &repeat_rows, &spot_rows, &pattern_rows, &gradient_rows, category);
|
||||
if text {
|
||||
return Ok(json!({ "text": report_text(&st.title(), selection_only, §ions) }));
|
||||
}
|
||||
@@ -725,38 +779,129 @@ mod tests {
|
||||
assert_eq!(length(&mut s, json!({"selectionOnly": true})), 0.0);
|
||||
}
|
||||
|
||||
/// Document Info › Brushes lists the Brushes panel's brushes, and with Selection Only it lists
|
||||
/// the brushes the selected objects' strokes use, each once.
|
||||
/// Document Info › Brushes lists the brushes the art's strokes use, each once with its type, and
|
||||
/// leaves out the Brushes panel's brushes no art uses (`panelBrushNames` lists those). With
|
||||
/// Selection Only it lists the brushes the selected objects' strokes use.
|
||||
#[test]
|
||||
fn info_lists_the_brushes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let panel: Vec<Value> =
|
||||
s.execute("brush.list", &json!({})).unwrap()["brushes"].as_array().unwrap().iter().map(|b| b["name"].clone()).collect();
|
||||
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
|
||||
assert_eq!(i["brushNames"], json!(panel));
|
||||
let brushes = |s: &mut Session, p: Value| s.execute("document.info", &p).unwrap();
|
||||
// No art uses a brush: the category is empty.
|
||||
let i = brushes(&mut s, json!({"category": "brushes"}));
|
||||
assert_eq!((i["brushNames"].clone(), i["brushes"].clone(), i["panelBrushNames"].clone()), (json!([]), json!([]), json!(panel)));
|
||||
assert_eq!((i["sections"][0]["id"].as_str(), i["sections"][0]["title"].as_str()), (Some("brushes"), Some("Brushes")));
|
||||
let rows: Vec<Value> = panel.iter().map(|n| json!([n, ""])).collect();
|
||||
assert_eq!(i["sections"][0]["rows"], json!(rows));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([]));
|
||||
let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 40, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("brush.apply", &json!({"ids": [a], "name": "Charcoal"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [b], "name": "Arrow"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [b], "name": "Dots"})).unwrap();
|
||||
s.execute("brush.apply", &json!({"ids": [c], "name": "Charcoal"})).unwrap();
|
||||
let names =
|
||||
|s: &mut Session| s.execute("document.info", &json!({"selectionOnly": true, "category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
// The whole document: the two brushes the art uses, each once, with their types; "3 pt. Round" (a Calligraphic
|
||||
// brush in the panel) is left out.
|
||||
let i = brushes(&mut s, json!({"category": "brushes"}));
|
||||
assert_eq!(i["brushNames"], json!(["Charcoal", "Dots"]));
|
||||
assert_eq!(i["brushes"], json!([{"name": "Charcoal", "type": "art"}, {"name": "Dots", "type": "scatter"}]));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([["Charcoal", "Art brush"], ["Dots", "Scatter brush"]]));
|
||||
assert!(panel.contains(&json!("3 pt. Round")) && i["panelBrushNames"] == json!(panel));
|
||||
let text = brushes(&mut s, json!({"category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nBRUSHES\nCharcoal: Art brush\nDots: Scatter brush\n") && !text.contains("3 pt. Round"), "{text}");
|
||||
assert!(!text.contains("OBJECTS"), "{text}");
|
||||
// Selection Only: the brushes the selected objects' strokes use.
|
||||
let selected = |s: &mut Session| brushes(s, json!({"selectionOnly": true, "category": "brushes"}));
|
||||
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Charcoal", "Arrow"]));
|
||||
assert_eq!(selected(&mut s)["brushNames"], json!(["Charcoal", "Dots"]));
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!(["Arrow"]));
|
||||
let i = selected(&mut s);
|
||||
assert_eq!(
|
||||
(i["brushes"].clone(), i["sections"][0]["rows"].clone()),
|
||||
(json!([{"name": "Dots", "type": "scatter"}]), json!([["Dots", "Scatter brush"]]))
|
||||
);
|
||||
let text = brushes(&mut s, json!({"selectionOnly": true, "category": "brushes", "format": "text"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains("\nBRUSHES\nDots: Scatter brush\n") && !text.contains("Charcoal"), "{text}");
|
||||
// A stroke without a brush lists none.
|
||||
let plain = s.execute("shape.rectangle", &json!({"x": 60, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [plain]})).unwrap();
|
||||
assert_eq!(names(&mut s), json!([]));
|
||||
let text = s.execute("document.info", &json!({"category": "brushes", "format": "text"})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
assert!(text.contains(&format!("\nBRUSHES\n{}\n", panel[0].as_str().unwrap())), "{text}");
|
||||
assert!(text.contains("\nCharcoal\n") && text.contains("\nArrow\n") && !text.contains("OBJECTS"), "{text}");
|
||||
assert_eq!(selected(&mut s)["brushNames"], json!([]));
|
||||
}
|
||||
|
||||
/// Document Info › Brushes leaves out brushes on strokes that draw nothing (hidden, at an
|
||||
/// opacity of 0, with no width or paint, a guide's, a compound path member's, a path's with no segment, type's that
|
||||
/// draws no characters, a group's own), lists a brush the Brushes panel does
|
||||
/// not hold with no type, and gives a name two panel brushes share the type the renderer uses.
|
||||
#[test]
|
||||
fn info_brushes_skip_undrawn_strokes_and_name_missing_brushes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
|
||||
let square = |s: &mut Session, x: f64| {
|
||||
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap()
|
||||
};
|
||||
let (hidden, thin, guide, missing) = (square(&mut s, 0.0), square(&mut s, 20.0), square(&mut s, 40.0), square(&mut s, 60.0));
|
||||
let (empty, faint) = (square(&mut s, 80.0), square(&mut s, 200.0));
|
||||
s.execute("brush.apply", &json!({"ids": [hidden, thin, guide, missing, empty, faint], "name": "Charcoal"})).unwrap();
|
||||
let (p, q) = (square(&mut s, 100.0), square(&mut s, 120.0));
|
||||
s.execute("select.set", &json!({"ids": [p, q]})).unwrap();
|
||||
let compound = s.execute("object.compoundPath.make", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
// A compound path's member: the compound path's own stroke draws it.
|
||||
let NodeKind::Compound { children, .. } = &mut d.node_mut(NodeId(compound)).unwrap().kind else { panic!("not a compound path") };
|
||||
let member = &mut std::sync::Arc::make_mut(&mut children[0]).appearance;
|
||||
member.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
member.stroke_mut().unwrap().brush = Some("Dots".into());
|
||||
d.node_mut(NodeId(hidden)).unwrap().appearance.stroke_mut().unwrap().visible = false;
|
||||
d.node_mut(NodeId(thin)).unwrap().appearance.stroke_mut().unwrap().width = 0.0;
|
||||
// A stroke at an opacity of 0 draws nothing.
|
||||
d.node_mut(NodeId(faint)).unwrap().appearance.stroke_mut().unwrap().opacity = 0.0;
|
||||
if let NodeKind::Path { guide: g, .. } = &mut d.node_mut(NodeId(guide)).unwrap().kind {
|
||||
*g = true;
|
||||
}
|
||||
// A path with no segment: its stroke follows nothing.
|
||||
if let NodeKind::Path { path, .. } = &mut d.node_mut(NodeId(empty)).unwrap().kind {
|
||||
*path = vectorcraft_geom::PathData::default();
|
||||
}
|
||||
d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Gone".into());
|
||||
let i = s.execute("document.info", &json!({"category": "brushes"})).unwrap();
|
||||
assert_eq!(i["brushes"], json!([{"name": "Gone", "type": null}]));
|
||||
assert_eq!(i["sections"][0]["rows"], json!([["Gone", "missing from the Brushes panel"]]));
|
||||
// A stroke with no paint draws nothing.
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
d.node_mut(NodeId(missing)).unwrap().appearance.stroke_mut().unwrap().paint = Paint::None;
|
||||
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([]));
|
||||
// Two panel brushes with one name: the type is the one the renderer draws with, the last one's.
|
||||
let twin = square(&mut s, 140.0);
|
||||
s.execute("brush.apply", &json!({"ids": [twin], "name": "Charcoal"})).unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let mut lib = vectorcraft_brush::library(d);
|
||||
let kinds: Vec<vectorcraft_brush::BrushKind> =
|
||||
["Charcoal", "Dots"].iter().map(|n| lib.iter().find(|b| b.name == *n).unwrap().kind.clone()).collect();
|
||||
lib.extend(kinds.into_iter().map(|kind| vectorcraft_brush::Brush { name: "Twin".into(), kind }));
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
d.node_mut(NodeId(twin)).unwrap().appearance.stroke_mut().unwrap().brush = Some("Twin".into());
|
||||
assert_eq!(s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushes"], json!([{"name": "Twin", "type": "scatter"}]));
|
||||
// Type: a brushed stroke on drawn characters is listed, one on spaces alone is not.
|
||||
let blank = s.execute("text.create", &json!({"x": 10, "y": 60, "text": " "})).unwrap()["id"].as_u64().unwrap();
|
||||
let word = s.execute("text.create", &json!({"x": 10, "y": 90, "text": "Hi"})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
for (id, name) in [(blank, "Dots"), (word, "Arrow")] {
|
||||
let ap = &mut d.node_mut(NodeId(id)).unwrap().appearance;
|
||||
ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
ap.stroke_mut().unwrap().brush = Some(name.into());
|
||||
}
|
||||
let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
assert_eq!(names, json!(["Twin", "Arrow"]));
|
||||
// A group's own stroke: the group draws its members.
|
||||
let (m1, m2) = (square(&mut s, 160.0), square(&mut s, 180.0));
|
||||
s.execute("select.set", &json!({"ids": [m1, m2]})).unwrap();
|
||||
let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let ap = &mut d.node_mut(NodeId(group)).unwrap().appearance;
|
||||
ap.set_stroke(Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
ap.stroke_mut().unwrap().brush = Some("Leaves".into());
|
||||
let names = s.execute("document.info", &json!({"category": "brushes"})).unwrap()["brushNames"].clone();
|
||||
assert_eq!(names, json!(["Twin", "Arrow"]));
|
||||
}
|
||||
|
||||
/// Document Info › Repeat Objects lists each Repeat object by name with its kind and the number
|
||||
@@ -867,6 +1012,11 @@ mod tests {
|
||||
s.execute("paint.setFill", &json!({"ids": [e], "none": true})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"ids": [e], "swatch": "Dots"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [e], "index": 1, "weight": 0})).unwrap();
|
||||
// Nor does a pattern fill at an opacity of 0.
|
||||
let clear = square(&mut s, 220.0);
|
||||
s.execute("paint.setStroke", &json!({"ids": [clear], "none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [clear], "swatch": "Dots"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [clear], "index": 0, "opacity": 0})).unwrap();
|
||||
// A type object with no characters paints nothing either.
|
||||
let empty = s.execute("text.create", &json!({"x": 0, "y": 300, "text": ""})).unwrap()["id"].clone();
|
||||
s.execute("paint.setFill", &json!({"ids": [empty], "swatch": "Dots"})).unwrap();
|
||||
@@ -927,7 +1077,7 @@ mod tests {
|
||||
assert_eq!(i["patternNames"], json!(["Dots"]));
|
||||
// Objects counts the objects that Pattern Objects lists.
|
||||
assert_eq!(i["objects"]["patterns"].as_u64(), Some(5), "{}", i["objects"]);
|
||||
s.execute("select.set", &json!({"ids": [d, e, empty, blank, guide, compound, image.0]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [d, e, clear, empty, blank, guide, compound, image.0]})).unwrap();
|
||||
let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap();
|
||||
assert!(i["objects"]["patterns"].is_null() && i["patternObjects"] == json!([]), "{}", i["objects"]);
|
||||
let i = s.execute("document.info", &json!({"category": "patterns"})).unwrap();
|
||||
|
||||
@@ -38,7 +38,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_move
|
||||
),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.inspect", "Inspect Document", [], None, "{depth?, childLimit?, ids?: [id…] (list just these nodes, of any level, in this order, under `nodes` in place of `layers`; an unknown id is an error; at most 10000), kind?: panel label such as Layer; Group; Path; Type or Image (exact, ignoring case; list every such node under `nodes`, each with `path`, its ancestor ids with the layer first, searching the subtrees of `ids` when given), limit?: at most this many entries of the list, offset?: entries skipped first (default 0; with `ids`, `kind`, `limit` or `offset` the reply adds `total`, the entries before paging)} → path (the document's file; null when automation read roots are in force and the file is outside them), layer tree (each node: id, name, kind, visible, locked, bounds, opacity (0–1) and opacityPct (0–100 %, the scale object.setProps takes) when below 1, blend when not Normal, fill, stroke and strokeWidth of objects and compound paths; a compound path's members have compoundMember: true instead of paint, which is the compound's), artboards, selection, history (a sliced tree reports childCount); without the scope options the reply is the whole layer tree", has_doc, doc_inspect),
|
||||
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → one object", has_doc, doc_node),
|
||||
cmd!(query "document.find", "Find Objects", [], None, "{name?: substring of the Layers panel name, kind?: panel label such as Group; Path; Type or Image (exact), text?: substring of type content, within?: [x, y, width, height] (an object's geometric bounds inside this rectangle, edges included; document points as document.inspect's bounds; layers never match this or the size filters), intersects?: [x, y, width, height] (bounds touching or overlapping it), minWidth?, maxWidth?, minHeight?, maxHeight?: pt (the bounds' size), minThickness?, maxThickness?: pt (the smaller of the bounds' width and height), minArea?, maxArea?: pt² (the bounds' width × height), minAnchors?, maxAnchors?: anchor points of a path (a compound path's members together; other objects never match), select?: bool (also select the matches, leaving out hidden and locked ones, those inside another match, and a compound path's members unless the compound path is isolated; Select > Reselect repeats it), limit?: max matches listed (default 100, 0 counts only)} → {matches: [{id, name, kind, path}], total, selected (with select: the count selected)}; at least one filter, all of them matching", has_doc, doc_find),
|
||||
cmd!(
|
||||
@@ -50,7 +50,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
doc_compare
|
||||
),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
|
||||
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model (with automation read roots, a linked or placed file outside them is named by its file name alone, without its relative path, size or date, in brush art too, and so are a data set's image value and a linked JavaScript file in svg_scripts outside them (a URL is kept); a relative path that leads outside them is left out; a stored brush entry that does not read as a brush is left out; a folder outside them is left out of export_settings)", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
let d = super::links::with_hidden_paths_named(&st.doc, super::links::LinkBase::of(st));
|
||||
Ok(serde_json::to_value(&*d).unwrap_or(Value::Null))
|
||||
}),
|
||||
cmd!(
|
||||
"document.setUnits",
|
||||
"Units",
|
||||
|
||||
@@ -47,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients, wedges around the center for conic ones, nested squares for diamond ones) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255; a gradient that repeats or reflects gets the same whole number of them in each run, at least one, so a run starts on an object's edge; one that needs more than 256 runs across the box stays a gradient)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -194,10 +194,12 @@ impl Expandable {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| r.style.has_stroke()),
|
||||
_ => shape && !c.appearance.stroke_paint().is_none(),
|
||||
};
|
||||
e.gradient |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && c.appearance.items.iter().any(|i| gradient_fill(i).is_some()),
|
||||
};
|
||||
// Once one is found, the next path is not measured.
|
||||
e.gradient = e.gradient
|
||||
|| match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(r.style.fill, Paint::Gradient(_))),
|
||||
_ => shape && gradient_expands(d, c),
|
||||
};
|
||||
e.fill |= e.gradient;
|
||||
e.fill |= match &c.kind {
|
||||
NodeKind::Text(t) => t.runs.iter().any(|r| matches!(&r.style.fill, Paint::Pattern { pattern, .. } if d.pattern(pattern).is_some())),
|
||||
@@ -222,6 +224,43 @@ fn gradient_fill(i: &AppearanceItem) -> Option<(&FillLayer, &GradientPaint)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether path `l` has a gradient fill that Fill expands ([`expand_fills`]): one that repeats or
|
||||
/// reflects stays a gradient when its box needs more runs than are written ([`padded_on`]). For a
|
||||
/// path that live-effect plug-ins paint, whether a piece of the art they paint has one
|
||||
/// ([`expand_pieces`]).
|
||||
fn gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().any(|i| gradient_fill(i).is_some()) {
|
||||
return false;
|
||||
}
|
||||
if !vectorcraft_render::effects::has_painted_effects(l) {
|
||||
return piece_gradient_expands(d, l);
|
||||
}
|
||||
let Some(art) = vectorcraft_render::effects::reshape(Some(d), l, None) else { return false };
|
||||
let mut any = false;
|
||||
art.walk(&mut |c| {
|
||||
any = any
|
||||
|| (matches!(c.kind, NodeKind::Path { .. } | NodeKind::Compound { .. })
|
||||
&& !c.is_ruler_guide()
|
||||
&& c.appearance.items.iter().any(|i| gradient_fill(i).is_some())
|
||||
&& piece_gradient_expands(d, c));
|
||||
});
|
||||
any
|
||||
}
|
||||
|
||||
/// [`gradient_expands`] for a path that no live-effect plug-in paints ([`expand_piece_fills`]).
|
||||
fn piece_gradient_expands(d: &Document, l: &Node) -> bool {
|
||||
if !l.appearance.items.iter().filter_map(gradient_fill).any(|(_, g)| g.gradient.is_spread()) {
|
||||
return l.appearance.items.iter().any(|i| gradient_fill(i).is_some());
|
||||
}
|
||||
let reshaped = reshaped(d, l);
|
||||
let l = reshaped.as_ref().unwrap_or(l);
|
||||
let Some((path, _)) = node_path(l) else { return false };
|
||||
let Some(bounds) = path.bounds() else { return false };
|
||||
l.appearance.items.iter().filter(|i| gradient_fill(i).is_some()).any(|item| {
|
||||
fill_shape(d, l, item, &path, bounds).is_some_and(|(_, _, b, moved)| gradient_fill(&moved).is_some_and(|(_, g)| padded_on(g, b).is_some()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The definition and placement of a visible pattern fill whose pattern `d` defines.
|
||||
fn pattern_fill<'a, 'd>(d: &'d Document, i: &'a AppearanceItem) -> Option<(&'a FillLayer, &'d PatternDef, Affine)> {
|
||||
match i {
|
||||
@@ -476,8 +515,9 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
let m = GradientMesh::for_path_with(path, FREEFORM_MESH, FREEFORM_MESH, &*gradient_sampler(g, bounds, d.color_mode.model()))?;
|
||||
vec![Node::new(d.alloc_id(), NodeKind::Mesh(m))]
|
||||
} else {
|
||||
// A gradient that repeats or reflects expands as its stops repeated over the box.
|
||||
let (g, runs) = padded_on(g, bounds);
|
||||
// A gradient that repeats or reflects expands as its stops repeated over the box (and
|
||||
// stays a gradient when the box needs more runs than are written).
|
||||
let (g, runs) = padded_on(g, bounds)?;
|
||||
let g = &*g;
|
||||
let frame = Frame::new(g, bounds);
|
||||
match to {
|
||||
@@ -546,12 +586,14 @@ fn expand_piece_leaves(
|
||||
}
|
||||
|
||||
/// Path `l` as the canvas paints its fills and strokes: its visible geometry effects applied to its
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// path (a plain path then), and every geometry effect of its own removed, with each raster effect
|
||||
/// following the Transform effects after it
|
||||
/// ([`without_geometry`](vectorcraft_render::effects::without_geometry)). `None` when it has no
|
||||
/// visible geometry effect. The swatches and patterns that live-effect plug-ins painting their
|
||||
/// pieces name are looked up in `doc`. This merges the pieces of such a plug-in into one path, so a
|
||||
/// path that one paints goes through [`expand_pieces`] instead.
|
||||
pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, without_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
@@ -562,7 +604,7 @@ pub(crate) fn reshaped(doc: &Document, l: &Node) -> Option<Node> {
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
m.appearance.effects = without_geometry(&m.appearance.effects);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
@@ -604,9 +646,10 @@ fn with_path(l: &Node, path: PathData) -> Node {
|
||||
}
|
||||
|
||||
/// The effects of fill `f` that stay on its expanded art: all but its geometry effects, which
|
||||
/// shape the outline it paints ([`fill_shape`]).
|
||||
/// shape the outline it paints ([`fill_shape`]), with each raster effect following the Transform
|
||||
/// effects after it ([`without_geometry`](vectorcraft_render::effects::without_geometry)).
|
||||
fn fill_effects(f: &FillLayer) -> Vec<vectorcraft_doc::Effect> {
|
||||
f.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect()
|
||||
vectorcraft_render::effects::without_geometry(&f.effects)
|
||||
}
|
||||
|
||||
/// Replace every pattern fill of the selected paths by the tiles it paints (see [`expand_patterns`]).
|
||||
@@ -1241,26 +1284,32 @@ pub(crate) fn gradient_sampler(g: &GradientPaint, bounds: Rect, doc_model: Model
|
||||
Box::new(move |p| g.gradient.sample_keep_model(spread(geom.param_at(g.gradient.kind, p)) as f32))
|
||||
}
|
||||
|
||||
/// For stops repeated by [`padded_on`]: where along the vector (0 to 1) their first run starts and
|
||||
/// how many runs lie evenly from there to 1.
|
||||
type Runs = Option<(f64, usize)>;
|
||||
|
||||
/// `g` placed on box `bounds` as a gradient that pads past its ends: a linear, radial or diamond
|
||||
/// gradient that repeats or reflects with its stops repeated as many times as the box needs
|
||||
/// ([`vectorcraft_color::Gradient::padded_over`]), a conic one with its stops repeated around the
|
||||
/// turn ([`vectorcraft_color::Gradient::unrolled`]); others as they are. With it, for the repeated
|
||||
/// stops, where along the vector (0 to 1) their first run starts and how many runs lie evenly from
|
||||
/// there to 1.
|
||||
fn padded_on(g: &GradientPaint, bounds: Rect) -> (std::borrow::Cow<'_, GradientPaint>, Option<(f64, usize)>) {
|
||||
/// there to 1. `None` when the box needs more runs than are written
|
||||
/// ([`vectorcraft_color::Padded::capped`]): the gradient stays.
|
||||
fn padded_on(g: &GradientPaint, bounds: Rect) -> Option<(std::borrow::Cow<'_, GradientPaint>, Runs)> {
|
||||
use std::borrow::Cow;
|
||||
if !g.gradient.is_spread() {
|
||||
return (Cow::Borrowed(g), None);
|
||||
return Some((Cow::Borrowed(g), None));
|
||||
}
|
||||
let geom = g.resolve(bounds);
|
||||
match g.gradient.padded_over(&geom, bounds) {
|
||||
Some(p) if p.capped => None,
|
||||
Some(p) => {
|
||||
let runs = Some((p.first, p.runs));
|
||||
(Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs)
|
||||
Some((Cow::Owned(GradientPaint { gradient: p.gradient, geom: Some(p.geom), angle: p.geom.angle_deg(), ..g.clone() }), runs))
|
||||
}
|
||||
None => {
|
||||
let runs = Some((0.0, g.gradient.repeat_count() as usize));
|
||||
(Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs)
|
||||
Some((Cow::Owned(GradientPaint { gradient: g.gradient.unrolled().into_owned(), geom: Some(geom), ..g.clone() }), runs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ use serde_json::{Value, json};
|
||||
|
||||
pub use action_batch::batch_overwrites_one_file;
|
||||
pub use dxf::{UNSUPPORTED, Unsupported, unsupported};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings};
|
||||
pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, Note, NoteKind, encode, encode_all, encode_with_notes, encode_with_warnings, note_texts};
|
||||
pub(crate) use encode::{anti_alias, background, with_single_artboard};
|
||||
pub(crate) use export::with_artboard_fills;
|
||||
pub(crate) use export::{export_crop, isolated};
|
||||
@@ -130,7 +130,8 @@ mod tests_readonly;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use access::{Placed, copy_new, file_metadata};
|
||||
pub(crate) use access::{
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, read_file, read_file_head, shown_link, shown_path, write_file, write_new_file,
|
||||
absolute_path, create_dir, document_path, file_created, file_stamp, naming_in_error, read_file, read_file_head, shown_link, shown_path,
|
||||
write_file, write_new_file,
|
||||
};
|
||||
#[cfg(all(test, windows))]
|
||||
pub(crate) use access::{document_path_on, document_path_with};
|
||||
|
||||
@@ -65,6 +65,62 @@ pub(crate) fn shown_link<'a>(stored: &'a str, resolved: Option<&str>) -> std::bo
|
||||
std::borrow::Cow::Borrowed(if matches!(name, "." | "..") { "" } else { name })
|
||||
}
|
||||
|
||||
/// What an error names a file or folder outside the read roots by when [`shown_path`] gives no
|
||||
/// name for it, or gives one that is not valid text.
|
||||
pub(crate) const UNNAMED: &str = "a file or folder outside the read roots";
|
||||
|
||||
/// An error about `path`, a file or folder the client did not give, as the request being served
|
||||
/// may see it: where the message names `path` ([`naming_in_message`]), it names it as
|
||||
/// [`shown_path`] does, or as [`UNNAMED`] when that is empty or holds a replacement character. The
|
||||
/// roots the message names and the reason stay whole. Unchanged when `path` is [`shown`].
|
||||
pub(crate) fn naming_in_error(e: EngineError, path: &str) -> EngineError {
|
||||
if path.is_empty() || shown(path) {
|
||||
return e;
|
||||
}
|
||||
naming_as(e, path, &error_name(path))
|
||||
}
|
||||
|
||||
/// What an error names `path` by when it is outside the read roots: [`shown_path`], or
|
||||
/// [`UNNAMED`] when that is empty or holds a replacement character.
|
||||
pub(crate) fn error_name(path: &str) -> String {
|
||||
let name = shown_path(path);
|
||||
if name.is_empty() || name.contains('\u{FFFD}') { UNNAMED.to_string() } else { name.into_owned() }
|
||||
}
|
||||
|
||||
/// [`naming_in_error`] with `path` named `name`, whether or not it is [`shown`] (a name worked out
|
||||
/// earlier, for the request that prepared the work).
|
||||
pub(crate) fn naming_as(e: EngineError, path: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(naming_in_message(&msg, path, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: naming_in_message(&msg, path, name) },
|
||||
EngineError::ReadOnlyLocation(msg) => EngineError::ReadOnlyLocation(naming_in_message(&msg, path, name)),
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `path` given as `name` where it begins a path the message is about: at the start of
|
||||
/// `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or after
|
||||
/// `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message names
|
||||
/// stays whole, even one inside `path`.
|
||||
pub(crate) fn naming_in_message(msg: &str, path: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(path)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// `path` checked for writing against the automation roots in force.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn may_write(path: &str) -> Result<()> {
|
||||
@@ -466,3 +522,52 @@ pub(crate) fn create_dir(path: &str) -> Result<()> {
|
||||
pub(crate) fn write_new_file(path: &str, _: &[u8]) -> Result<()> {
|
||||
Err(no_fs(path))
|
||||
}
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
/// An error about a path the client did not give, under roots that don't let it be read: the
|
||||
/// path is named by its file name (after a `/` or a `\`), or as [`UNNAMED`] when it ends in no
|
||||
/// name or its name is not valid text, and every root and the reason stay whole. A path inside the
|
||||
/// read roots, and any path without roots in force, stay whole.
|
||||
#[test]
|
||||
fn an_error_names_a_path_outside_the_read_roots_by_its_name() {
|
||||
let base = std::env::temp_dir().join(format!("vc-access-naming-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let (a, b, out) = (base.join("a"), base.join("b"), base.join("kept-out"));
|
||||
for f in [&a, &b, &out] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let text = |p: &std::path::Path| p.to_string_lossy().into_owned();
|
||||
let roots = AutomationRoots::new(&[a.as_path(), b.as_path()], &[a.as_path(), b.as_path()]).unwrap();
|
||||
let file = text(&out.join("doc.svg"));
|
||||
let raw = confine(roots.as_ref(), || crate::file_access::check_write(&file)).unwrap_err();
|
||||
let shown_root = |p: &std::path::Path| crate::cmd::findfiles::plain(std::fs::canonicalize(p).unwrap()).display().to_string();
|
||||
let (ra, rb) = (shown_root(&a), shown_root(&b));
|
||||
let named = |e: EngineError, path: &str| confine(roots.as_ref(), || naming_in_error(e, path)).to_string();
|
||||
|
||||
assert_eq!(named(EngineError::Other(raw.clone()), &file), format!("automation path rejected: outside the write roots {ra}, {rb}: doc.svg"));
|
||||
let io = named(EngineError::Other(format!("{file}: disk full")), &file);
|
||||
assert_eq!(io, "doc.svg: disk full");
|
||||
let bad = named(EngineError::BadParams { cmd: "x".into(), msg: format!("{file}: not a file") }, &file);
|
||||
assert_eq!(bad, "invalid parameters for `x`: doc.svg: not a file");
|
||||
// A save refused by the file system keeps its type and its code.
|
||||
let read_only = confine(roots.as_ref(), || naming_in_error(EngineError::ReadOnlyLocation(format!("{file}: Permission denied")), &file));
|
||||
assert!(matches!(&read_only, EngineError::ReadOnlyLocation(_)));
|
||||
assert_eq!(read_only.to_string(), format!("{}: doc.svg: Permission denied", crate::READ_ONLY_LOCATION));
|
||||
// A Windows path written with `\`, a path that ends in no name and a name that isn't text.
|
||||
let win = r"C:\Users\me\secret\doc.svg";
|
||||
assert_eq!(named(EngineError::Other(format!("{win}: denied")), win), "doc.svg: denied");
|
||||
let folder = format!("{}/", text(&out));
|
||||
assert_eq!(named(EngineError::Other(format!("{folder}: denied")), &folder), format!("{UNNAMED}: denied"));
|
||||
let lossy = text(&out.join("bad\u{FFFD}.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{lossy}: denied")), &lossy), format!("{UNNAMED}: denied"));
|
||||
// Readable, or nothing confined: whole.
|
||||
let inside = text(&a.join("x.svg"));
|
||||
assert_eq!(named(EngineError::Other(format!("{inside}: denied")), &inside), format!("{inside}: denied"));
|
||||
assert_eq!(naming_in_error(EngineError::Other(raw.clone()), &file).to_string(), raw);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1429,6 +1429,42 @@ mod tests {
|
||||
assert_eq!(written.len(), 2, "{written:?}");
|
||||
}
|
||||
|
||||
/// A Data Sets batch whose Save step has no path saves the document's own file. When that file is
|
||||
/// outside the client's read and write roots, each row's error keeps the write root and names the
|
||||
/// file by its file name, with Stop on Error on and off. A source folder the client gives stays
|
||||
/// whole in the error.
|
||||
#[test]
|
||||
fn dataset_batch_step_error_hides_document_path() {
|
||||
use crate::file_access::{AutomationRoots, confine};
|
||||
|
||||
let base = tmp("roots-error");
|
||||
let (inside, outside) = (base.join("in"), base.join("kept-out"));
|
||||
for f in [&inside, &outside] {
|
||||
std::fs::create_dir_all(f).unwrap();
|
||||
}
|
||||
let mut s = with_data_sets(&["Alpha", "Beta"]);
|
||||
let own = outside.join("doc.vectorcraft").to_string_lossy().into_owned();
|
||||
s.execute("document.save", &json!({"path": own})).unwrap();
|
||||
let roots = AutomationRoots::new(&[inside.as_path()], &[inside.as_path()]).unwrap();
|
||||
let root = crate::cmd::findfiles::plain(std::fs::canonicalize(&inside).unwrap()).display().to_string();
|
||||
let hidden = outside.to_string_lossy().into_owned();
|
||||
for (stop, rows) in [(true, 1), (false, 2)] {
|
||||
let p = json!({"source": "datasets", "stopOnError": stop, "commands": [{"command": "document.save", "params": {}}]});
|
||||
let r = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap();
|
||||
assert_eq!((r["failed"].as_u64(), r["processed"].as_array().map(Vec::len)), (Some(rows), Some(rows as usize)), "{r}");
|
||||
for row in r["processed"].as_array().unwrap() {
|
||||
let e = row["error"].as_str().unwrap_or_default();
|
||||
let want = format!("batch step 0 (`document.save`) failed: automation path rejected: outside the write root {root}: doc.vectorcraft");
|
||||
assert_eq!(e, want, "{r}");
|
||||
}
|
||||
assert!(!r.to_string().contains(&hidden), "{r}");
|
||||
}
|
||||
let p = json!({"source": "folder", "folder": hidden, "commands": [{"command": "object.unlockAll", "params": {}}]});
|
||||
let e = confine(roots.as_ref(), || s.execute("action.batch", &p)).unwrap_err().to_string();
|
||||
assert!(e.contains(&hidden), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
/// The names of the files in `dir`, sorted.
|
||||
fn file_names(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> =
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -241,6 +241,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
for img in out.images {
|
||||
if !enc.linked.iter().any(|l| l.name == img.name) {
|
||||
enc.linked.push(vectorcraft_svg::LinkedImage { name: img.name, bytes: Arc::new(img.bytes) });
|
||||
|
||||
@@ -74,18 +74,104 @@ pub struct Encoded {
|
||||
pub linked: Vec<vectorcraft_svg::LinkedImage>,
|
||||
/// Image maps, each written beside its image under the image's name (raster `imageMap`).
|
||||
pub maps: Vec<imagemap::Map>,
|
||||
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
||||
/// The encoder's warnings: what the file leaves out of the document or writes differently
|
||||
/// ([`NoteKind::Loss`]).
|
||||
pub warnings: Vec<String>,
|
||||
/// The encoder's notes on the options: options not applied yet or applied another way, and
|
||||
/// what an option turns off ([`NoteKind::Setting`]). A reply gives them after the warnings
|
||||
/// ([`Self::notes`]).
|
||||
pub setting_notes: Vec<String>,
|
||||
/// What joins the file's stem and the artboard name when there is a file per artboard
|
||||
/// (default `-`; EPS uses `_`, as the reference app names them).
|
||||
pub joiner: Option<&'static str>,
|
||||
}
|
||||
|
||||
/// What a note an export gives with its file is about.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum NoteKind {
|
||||
/// Something of the document (its art, type, layers or artboards) that the file leaves out or
|
||||
/// that reaches it changed: the format, its version or a limit can't hold it, or the export
|
||||
/// couldn't write it.
|
||||
Loss,
|
||||
/// An option the export didn't apply or applied another way while the art reaches the file as
|
||||
/// it looks, or something left out because an option turns it off.
|
||||
Setting,
|
||||
}
|
||||
|
||||
/// A note an export gives with its file.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Note {
|
||||
/// What the note is about.
|
||||
pub kind: NoteKind,
|
||||
/// The note, in English.
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl Note {
|
||||
/// A note about what the file loses ([`NoteKind::Loss`]).
|
||||
pub fn loss(text: impl Into<String>) -> Self {
|
||||
Self { kind: NoteKind::Loss, text: text.into() }
|
||||
}
|
||||
|
||||
/// A note on the options ([`NoteKind::Setting`]).
|
||||
pub fn setting(text: impl Into<String>) -> Self {
|
||||
Self { kind: NoteKind::Setting, text: text.into() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The texts of `notes` in the order a reply gives them: the losses first, then the notes on the
|
||||
/// options, each kind in the order given (the status bar names the first).
|
||||
pub fn note_texts(notes: &[Note]) -> Vec<String> {
|
||||
let of = |kind| notes.iter().filter(move |n| n.kind == kind).map(|n| n.text.clone());
|
||||
of(NoteKind::Loss).chain(of(NoteKind::Setting)).collect()
|
||||
}
|
||||
|
||||
/// Add `note` to `notes` unless a note with its text is there already.
|
||||
pub(super) fn add_note(notes: &mut Vec<Note>, note: Note) {
|
||||
if !notes.iter().any(|n| n.text == note.text) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
|
||||
impl Encoded {
|
||||
pub(super) fn one(bytes: Vec<u8>) -> Self {
|
||||
Self { files: vec![(None, bytes)], ..Self::default() }
|
||||
}
|
||||
|
||||
/// The file `bytes` with `notes`, each in the list of its kind, in the order given.
|
||||
pub(super) fn one_with_notes(bytes: Vec<u8>, notes: Vec<Note>) -> Self {
|
||||
let mut enc = Self::one(bytes);
|
||||
for n in notes {
|
||||
match n.kind {
|
||||
NoteKind::Loss => enc.warnings.push(n.text),
|
||||
NoteKind::Setting => enc.setting_notes.push(n.text),
|
||||
}
|
||||
}
|
||||
enc
|
||||
}
|
||||
|
||||
/// Add each of `texts` to [`Self::setting_notes`] unless a note with its text is there already.
|
||||
pub(super) fn add_setting_notes(&mut self, texts: impl IntoIterator<Item = String>) {
|
||||
for t in texts {
|
||||
if !self.warnings.contains(&t) && !self.setting_notes.contains(&t) {
|
||||
self.setting_notes.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every note with its kind: the warnings, then the notes on the options that aren't also
|
||||
/// warnings.
|
||||
pub fn kinded_notes(&self) -> Vec<Note> {
|
||||
let settings = self.setting_notes.iter().filter(|t| !self.warnings.contains(t)).map(Note::setting);
|
||||
self.warnings.iter().map(Note::loss).chain(settings).collect()
|
||||
}
|
||||
|
||||
/// The notes' texts in the order a reply gives them ([`note_texts`]): the warnings, then the
|
||||
/// notes on the options, each once.
|
||||
pub fn notes(&self) -> Vec<String> {
|
||||
note_texts(&self.kinded_notes())
|
||||
}
|
||||
|
||||
/// Every file to write for the destination `path` (a path or a file name): the file itself
|
||||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it (see `joiner`; a file of no artboard
|
||||
/// among several, as Save's master file, is the file itself), then the linked images beside it
|
||||
@@ -128,13 +214,14 @@ impl Encoded {
|
||||
out
|
||||
}
|
||||
|
||||
/// The one file and the warnings, when the export wrote nothing else.
|
||||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
/// The one file and the notes, when the export wrote nothing else.
|
||||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
if !self.linked.is_empty() || !self.maps.is_empty() {
|
||||
return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label)));
|
||||
}
|
||||
let notes = self.kinded_notes();
|
||||
match <[_; 1]>::try_from(self.files) {
|
||||
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
|
||||
Ok([(_, bytes)]) => Ok((bytes, notes)),
|
||||
Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))),
|
||||
}
|
||||
}
|
||||
@@ -419,8 +506,14 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||||
|
||||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||||
/// features approximated or left out; every format that draws type: fonts written in the fallback
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame).
|
||||
/// font; PDF, EPS, SVG, EMF, WMF and the raster formats: type that doesn't fit its frame), what
|
||||
/// the file loses first ([`note_texts`]).
|
||||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
encode_with_notes(doc, format, p).map(|(bytes, notes)| (bytes, note_texts(¬es)))
|
||||
}
|
||||
|
||||
/// Like [`encode_with_warnings`], with each note's kind ([`Encoded::kinded_notes`]).
|
||||
pub fn encode_with_notes(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
let f = super::writable(C, Some(format), None)?;
|
||||
let enc = encode_all(doc, f.id, p)?;
|
||||
// The error for a version 7 .ai of several artboards names the legacy .ai.
|
||||
@@ -476,7 +569,7 @@ fn encode_all_of(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
/// ([`vectorcraft_plugins::export`]). The plug-in is given the native file's JSON
|
||||
/// ([`vectorcraft_format::json_part`]) and `p`'s `params`, for the file `p`'s `path` names (else
|
||||
/// one named after the document) with that path's extension when the plug-in lists it, else its
|
||||
/// first. Its warnings come back with the file.
|
||||
/// first. Its warnings come back with the file, as losses (the plug-in gives them no kind).
|
||||
fn plugin_file(doc: &Document, plugin: &vectorcraft_plugins::Plugin, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
use vectorcraft_plugins::{Error, export};
|
||||
let doc = &*doc.without_edit_modes();
|
||||
@@ -539,8 +632,8 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
"hpgl" => return super::hpgl::encode(doc, p, use_artboards),
|
||||
"ai" => return super::save::encode_ai(C, f, doc, p),
|
||||
"pdf" => {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
let (bytes, notes) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded::one_with_notes(bytes, notes));
|
||||
}
|
||||
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" | "psd" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
@@ -607,7 +700,7 @@ fn encode_files(doc: &Document, f: &Format, p: &Value) -> Result<Encoded> {
|
||||
let regions: Vec<Rect> = enc.files.iter().filter_map(|(b, _)| b.and_then(|b| doc.artboards.get(b))).map(|a| a.rect).collect();
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
if format == RasterFormat::WebP && o.lossless == Some(false) {
|
||||
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
enc.setting_notes.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
|
||||
}
|
||||
return Ok(enc);
|
||||
}
|
||||
|
||||
@@ -382,6 +382,7 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
}
|
||||
enc.warnings.extend(crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, Some(®ions)));
|
||||
Ok(enc)
|
||||
|
||||
@@ -42,7 +42,7 @@ pub(super) fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
_ => json!({ "dataBase64": vectorcraft_format::base64_encode(bytes) }),
|
||||
};
|
||||
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.warnings }));
|
||||
let mut out = merge(data(main.first().map_or(&[][..], |m| &m.1)), json!({ "warnings": enc.notes() }));
|
||||
if main.len() > 1 {
|
||||
out["files"] = main.iter().map(|(name, bytes)| merge(json!({ "name": name }), data(bytes))).collect();
|
||||
}
|
||||
@@ -69,7 +69,7 @@ pub(super) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||||
}
|
||||
let enc = encode_all(doc, f.id, &q)?;
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.warnings }))
|
||||
write_encoded(path, &default_name(doc, f.extensions[0]), doc, &enc, json!({ "format": f.id, "warnings": enc.notes() }))
|
||||
}
|
||||
|
||||
/// The document an export of `st` in format `f` writes ([`export_source`]): a `.ai` or `.ait`
|
||||
|
||||
@@ -116,6 +116,7 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>) ->
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
|
||||
@@ -136,10 +136,12 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
}
|
||||
let images = see_through_images(&d, mode);
|
||||
if !images.is_empty() {
|
||||
note(&mut enc.warnings, if mode == SeeThrough::Flatten { FLATTENED } else { OMITTED });
|
||||
if mode == SeeThrough::Flatten {
|
||||
note(&mut enc.warnings, FLATTENED);
|
||||
flatten_images(&mut d, &images)?;
|
||||
} else {
|
||||
// A note on the options: `transparentImages: omit` leaves them out.
|
||||
enc.add_setting_notes([OMITTED.to_string()]);
|
||||
for (_, id) in images {
|
||||
let _ = d.remove(id); // Inside an image left out before: gone already.
|
||||
}
|
||||
@@ -150,6 +152,7 @@ pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result
|
||||
for w in placed.iter().chain(&out.warnings) {
|
||||
note(&mut enc.warnings, w);
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
}
|
||||
// Expanding raster effects and flattening images draw the art, and an image the renderer
|
||||
|
||||
@@ -398,7 +398,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let editing = vectorcraft_svg::editing(&text).map(|e| (e.intact, move || vectorcraft_format::base64_decode(&e.data)));
|
||||
// Relative links are found in the SVG's folder (when `name` is a path).
|
||||
let folder = std::path::Path::new(name).parent().map(|f| f.to_string_lossy()).filter(|f| !f.is_empty()).map(|f| absolute_path(&f));
|
||||
restore_or_import(editing, || import_svg(&text, folder.as_deref()))?
|
||||
restore_or_import(editing, || open_svg(&text, folder.as_deref()))?
|
||||
}
|
||||
"pdf" | "ai" | "ait" => {
|
||||
// The file is read and checked once, for the document it carries, its pages, the
|
||||
@@ -573,6 +573,33 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
/// own): rasters stay linked, SVG files become art, missing ones placeholders (see
|
||||
/// [`vectorcraft_svg::ImportOptions`]).
|
||||
pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
import_svg_finding(text, folder, &std::collections::HashSet::new()).map(|(doc, warnings, _)| (doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`] for a document opened from an SVG: with the variable data the file carries (SVG
|
||||
/// Options › Include Variable Data), read as [`crate::cmd::variablecmds::svg_library`] says. Variable
|
||||
/// data that can't be read is left out with a warning; the art opens either way.
|
||||
fn open_svg(text: &str, folder: Option<&str>) -> Result<(Document, Vec<String>)> {
|
||||
let lib = match crate::cmd::variablecmds::svg_library(text) {
|
||||
None => return import_svg(text, folder),
|
||||
Some(Err(e)) => {
|
||||
let (doc, mut warnings) = import_svg(text, folder)?;
|
||||
warnings.push(format!("the file's variable data wasn't read: {e}"));
|
||||
return Ok((doc, warnings));
|
||||
}
|
||||
Some(Ok(lib)) => lib,
|
||||
};
|
||||
let (mut doc, mut warnings, found) = import_svg_finding(text, folder, &lib.elements())?;
|
||||
warnings.extend(crate::cmd::variablecmds::give_svg_library(&mut doc, lib, &found));
|
||||
Ok((doc, warnings))
|
||||
}
|
||||
|
||||
/// [`import_svg`], also returning the object made of each element whose id `ids` lists.
|
||||
fn import_svg_finding(
|
||||
text: &str,
|
||||
folder: Option<&str>,
|
||||
ids: &std::collections::HashSet<String>,
|
||||
) -> Result<(Document, Vec<String>, vectorcraft_svg::Found)> {
|
||||
// The importer reads on a thread of its own (for its stack): what may be read is what may be
|
||||
// read here.
|
||||
let scope = crate::file_access::ScopeHandle::capture();
|
||||
@@ -588,7 +615,7 @@ pub(crate) fn import_svg(text: &str, folder: Option<&str>) -> Result<(Document,
|
||||
Some((bytes, link))
|
||||
})
|
||||
};
|
||||
vectorcraft_svg::import_with(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }).map_err(err)
|
||||
vectorcraft_svg::import_finding(text, &vectorcraft_svg::ImportOptions { folder, read: Some(&read) }, ids).map_err(err)
|
||||
}
|
||||
|
||||
/// Open a file's bytes as the new active document (what `document.open` does) →
|
||||
|
||||
@@ -12,7 +12,7 @@ use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, S
|
||||
|
||||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||||
use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{ArtboardPick, FormatOption, write_or_return};
|
||||
use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return};
|
||||
use crate::EngineError;
|
||||
|
||||
const C: &str = "document.exportPdf";
|
||||
@@ -284,21 +284,22 @@ pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
|
||||
/// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]).
|
||||
pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings";
|
||||
|
||||
/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered
|
||||
/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered
|
||||
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
|
||||
/// when the PDF has every artboard (a PDF of some reopens as just those).
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
encode_at(cmd, doc, p, None)
|
||||
}
|
||||
|
||||
/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing.
|
||||
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
|
||||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||||
let (bytes, drawn_warnings) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||||
warnings.extend(drawn_warnings);
|
||||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||||
Ok((bytes, warnings))
|
||||
let (full, placed) = crate::cmd::place::document::full_documents(doc);
|
||||
let mut notes: Vec<Note> = placed.into_iter().map(Note::loss).collect();
|
||||
let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||||
notes.extend(drawn);
|
||||
notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss));
|
||||
Ok((bytes, notes))
|
||||
}
|
||||
|
||||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||||
@@ -321,7 +322,7 @@ pub(super) fn encode_carrying(
|
||||
p: &Value,
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
encode_carrying_at(cmd, doc, p, None, native, private)
|
||||
}
|
||||
|
||||
@@ -334,7 +335,7 @@ fn encode_carrying_at(
|
||||
modified: Option<i64>,
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||||
let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?;
|
||||
// The pages' background colors, behind the art (the editing data keeps them as colors).
|
||||
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
|
||||
@@ -345,21 +346,22 @@ fn encode_carrying_at(
|
||||
if opts.settings.thumbnails {
|
||||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
}
|
||||
let mut warnings = vec![];
|
||||
// The status bar shows the first warning, so this one goes ahead of the flattening note.
|
||||
let mut notes = vec![];
|
||||
// A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document
|
||||
// Setup gives.
|
||||
if opts.settings.leaves_out_bleed(doc) {
|
||||
warnings.push(BLEED_LEFT_OUT.to_string());
|
||||
notes.push(Note::loss(BLEED_LEFT_OUT));
|
||||
}
|
||||
if opts.settings.preserve_editing {
|
||||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||||
opts.native = Some(native()?);
|
||||
if let Some((data, lost)) = private()? {
|
||||
opts.private_data = Some(data);
|
||||
warnings.extend(lost);
|
||||
notes.extend(lost.into_iter().map(Note::loss));
|
||||
}
|
||||
} else {
|
||||
opts.settings.preserve_editing = false;
|
||||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||||
notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD));
|
||||
}
|
||||
}
|
||||
let set = &opts.settings;
|
||||
@@ -370,12 +372,13 @@ fn encode_carrying_at(
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
if flattened {
|
||||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||||
warnings.push(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset"));
|
||||
notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")));
|
||||
}
|
||||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||||
warnings.extend(r.warnings);
|
||||
warnings.extend(crate::cmd::fonts::substitution_warning(doc));
|
||||
Ok((r.bytes, warnings))
|
||||
notes.extend(r.warnings.into_iter().map(Note::loss));
|
||||
notes.extend(r.setting_notes.into_iter().map(Note::setting));
|
||||
notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss));
|
||||
Ok((r.bytes, notes))
|
||||
}
|
||||
|
||||
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
|
||||
@@ -479,8 +482,8 @@ fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
q = json!({ "_displayDocTitle": display_doc_title });
|
||||
}
|
||||
let (bytes, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||||
let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) }))
|
||||
}
|
||||
|
||||
/// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`).
|
||||
@@ -512,7 +515,7 @@ fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let set = settings_with(Q, p, saved)?;
|
||||
let diff = changes(&set, &preset_settings(Q, p, saved)?);
|
||||
let warnings = match s.active() {
|
||||
Some(st) if bool_or(p, "includeDocument", false) => encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1,
|
||||
Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1),
|
||||
_ => set.warnings(),
|
||||
};
|
||||
let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
|
||||
@@ -307,7 +307,9 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
};
|
||||
let modified = date_param(p, "modified", cmd)?;
|
||||
if let Some(path) = &path {
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd)?;
|
||||
// Without a path, it is the document's own file: the error names it as the request may see it.
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
super::check_not_lossy_overwrite(st, path, p, cmd).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })?;
|
||||
}
|
||||
let written = written_as(cmd, format, &Value::Object(options.clone()));
|
||||
let top = NATIVE_OPTIONS.iter().filter(|k| p.get(**k).is_some_and(|v| !v.is_null())).map(|k| k.to_string());
|
||||
@@ -750,12 +752,14 @@ pub(super) fn encode_ai(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Res
|
||||
carries_editing.set(true);
|
||||
Ok(Some((data, resolved_warnings)))
|
||||
};
|
||||
let (bytes, mut warnings) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?;
|
||||
warnings.extend(placed_warnings);
|
||||
let (bytes, notes) = super::pdf::encode_carrying(cmd, &pages, ¶ms, native, private)?;
|
||||
let mut enc = Encoded::one_with_notes(bytes, notes);
|
||||
enc.warnings.extend(placed_warnings);
|
||||
// A note on the options: `pdfCompatible: false` writes blank pages.
|
||||
if !pdf_compatible {
|
||||
warnings.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string());
|
||||
enc.setting_notes.push(if carries_editing.get() { NOT_PDF_COMPATIBLE_EDITING } else { NOT_PDF_COMPATIBLE }.to_string());
|
||||
}
|
||||
Ok(Encoded { warnings, ..Encoded::one(bytes) })
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// Snapshot the active document for `plan` (stamping File Info's dates on the snapshot when the
|
||||
@@ -843,8 +847,8 @@ impl SaveJob {
|
||||
}
|
||||
|
||||
/// Encode the file → it (and, with `separateArtboards`, a file per artboard after it), with
|
||||
/// the warnings (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…, then the plan's notes on its params).
|
||||
/// the notes (what the format loses first, then the encoder's own notes: PDF options not
|
||||
/// applied yet…, then the plan's notes on its params, which are notes on the options).
|
||||
pub fn encode(&self) -> Result<Encoded> {
|
||||
let mut enc = self.encode_one(&self.doc)?;
|
||||
for &b in &self.boards {
|
||||
@@ -852,7 +856,7 @@ impl SaveJob {
|
||||
enc.files.extend(one.files.into_iter().map(|(_, bytes)| (Some(b), bytes)));
|
||||
}
|
||||
enc.warnings.splice(0..0, self.format_losses());
|
||||
enc.warnings.extend(self.plan.notes.iter().cloned());
|
||||
enc.setting_notes.extend(self.plan.notes.iter().cloned());
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
@@ -903,9 +907,8 @@ impl SaveJob {
|
||||
/// order: when `stop` returns a value, nothing is written and that value is returned.
|
||||
pub fn write_unless(&self, stop: impl FnOnce(&[&str]) -> Option<Value>, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
|
||||
let (plan, doc) = (&self.plan, &*self.doc);
|
||||
let mut enc = self.encode()?;
|
||||
let warnings = std::mem::take(&mut enc.warnings);
|
||||
let extra = json!({ "format": plan.format.id, "warnings": warnings });
|
||||
let enc = self.encode()?;
|
||||
let extra = json!({ "format": plan.format.id, "warnings": enc.notes() });
|
||||
let Some(path) = &plan.path else {
|
||||
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
|
||||
out["name"] = json!(plan.name);
|
||||
@@ -1029,6 +1032,8 @@ impl SaveJob {
|
||||
}
|
||||
// The other open documents that place this one show the new version.
|
||||
let written = self.plan.path.clone().filter(|_| matches!(self.plan.format.id, "vectorcraft" | "template"));
|
||||
// A save of the arrowheads file lists its heads in the arrowhead menus.
|
||||
let native = self.plan.format.id == "vectorcraft";
|
||||
if let Some(st) = s.document_mut(uid) {
|
||||
self.finish(st);
|
||||
}
|
||||
@@ -1036,6 +1041,9 @@ impl SaveJob {
|
||||
crate::cmd::recovery::forget(s, uid);
|
||||
}
|
||||
if let Some(path) = written {
|
||||
if native {
|
||||
s.arrowheads_saved(&path);
|
||||
}
|
||||
crate::cmd::links::refresh_placed(s, uid, &path);
|
||||
}
|
||||
}
|
||||
@@ -1056,7 +1064,12 @@ pub(crate) fn save(s: &mut Session, mode: SaveMode, p: &Value) -> Result<Value>
|
||||
let plan = save_plan(s, mode, p)?;
|
||||
let job = save_job(s, plan)?;
|
||||
let uid = s.doc()?.uid;
|
||||
let mut out = job.write_streamed(&mut super::write_save_file_with)?;
|
||||
let given = str_param(p, "path").is_some_and(|g| !g.is_empty());
|
||||
// Without a path, the files written are the document's own file and those beside it.
|
||||
let mut write = |path: &str, fill: &mut dyn FnMut(&mut dyn std::io::Write) -> std::io::Result<()>| {
|
||||
super::write_save_file_with(path, fill).map_err(|e| if given { e } else { super::access::naming_in_error(e, path) })
|
||||
};
|
||||
let mut out = job.write_streamed(&mut write)?;
|
||||
job.complete(s, uid);
|
||||
leave_out_unreadable_save_paths(&mut out, str_param(p, "path"));
|
||||
Ok(out)
|
||||
@@ -1111,7 +1124,8 @@ fn can_revert(s: &Session) -> std::result::Result<(), String> {
|
||||
fn revert(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let index = s.active_index().ok_or(crate::EngineError::NoDocument)?;
|
||||
let path = s.doc()?.path.clone().ok_or_else(|| bad("file.revert", "the document has never been saved"))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
let bytes = read_file(&path).map_err(|e| super::access::naming_in_error(e, &path))?;
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &bytes)?.with_grid_prefs(&s.prefs).with_unit_prefs(&s.prefs);
|
||||
doc.template = false;
|
||||
// Back to the saved file: its recovery copy has nothing left to recover.
|
||||
let uid = s.doc()?.uid;
|
||||
|
||||
@@ -17,12 +17,12 @@ use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::super::{EngineError, NodeId, Result, Session, bad, bool_or, str_param};
|
||||
use super::encode::single_artboard;
|
||||
use super::encode::{add_note, single_artboard};
|
||||
use super::export::{export_crop, isolated, selected_artwork, without_artboards};
|
||||
use super::naming::{self, NameParts};
|
||||
use super::{
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_warnings, merge,
|
||||
unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
ARTBOARD_PARAMS, ArtboardPick, Format, Note, artboard_file_names_keeping_dots, create_dir, distinct_names_by, encode_with_notes, merge,
|
||||
note_texts, unique_file_names_keeping_dots, with_black_output, writable, write_file,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -649,14 +649,14 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let p = with_preset(s, p)?;
|
||||
let hidden = if given { None } else { str_param(&p, "folder").filter(|f| !f.is_empty() && !super::access::shown(f)) };
|
||||
let mut r = export(s, &p).map_err(|e| match hidden {
|
||||
Some(folder) => leave_out_folder_from_error(e, folder),
|
||||
Some(folder) => super::access::naming_in_error(e, folder),
|
||||
None => e,
|
||||
})?;
|
||||
remember(s, &p);
|
||||
// A prepared export is written later, maybe on another thread: it names the folder as this
|
||||
// request may see it.
|
||||
if let (Some(folder), Some(Some(export))) = (hidden, s.screens_hand_off.as_mut()) {
|
||||
export.hidden = Some((folder.to_string(), super::access::shown_path(folder).into_owned()));
|
||||
export.hidden = Some((folder.to_string(), super::access::error_name(folder)));
|
||||
}
|
||||
if !given && let Some(folder) = str_param(&p, "folder").filter(|f| !f.is_empty()) {
|
||||
leave_out_unreadable_folder(&mut r, folder);
|
||||
@@ -664,45 +664,6 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// An error of an export into `folder`, a folder outside the read roots that the client did not
|
||||
/// give: `folder` as the path the error is about names it by its own name
|
||||
/// ([`super::access::shown_path`]), and the rest of the message stays.
|
||||
fn leave_out_folder_from_error(e: EngineError, folder: &str) -> EngineError {
|
||||
leave_out_folder_named(e, folder, &super::access::shown_path(folder))
|
||||
}
|
||||
|
||||
/// [`leave_out_folder_from_error`] with `folder` named `name`.
|
||||
fn leave_out_folder_named(e: EngineError, folder: &str, name: &str) -> EngineError {
|
||||
match e {
|
||||
EngineError::Other(msg) => EngineError::Other(leave_out_folder_from_message(&msg, folder, name)),
|
||||
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: leave_out_folder_from_message(&msg, folder, name) },
|
||||
e => e,
|
||||
}
|
||||
}
|
||||
|
||||
/// `msg` with `folder` given as `name` where it begins a path the message is about: at the start
|
||||
/// of `msg` (`<path>: <reason>`), after a `: ` (`... outside the write root <root>: <path>`) or
|
||||
/// after `cannot resolve `, followed by the end, a separator, a `:` or a space. A root the message
|
||||
/// names stays whole, even one inside `folder`.
|
||||
fn leave_out_folder_from_message(msg: &str, folder: &str, name: &str) -> String {
|
||||
const BEFORE: [&str; 2] = [": ", "cannot resolve "];
|
||||
let mut out = String::with_capacity(msg.len());
|
||||
let mut rest = msg;
|
||||
loop {
|
||||
if let Some(after) = rest.strip_prefix(folder)
|
||||
&& after.chars().next().is_none_or(|c| matches!(c, '/' | '\\' | ':' | ' '))
|
||||
{
|
||||
out.push_str(name);
|
||||
rest = after;
|
||||
}
|
||||
let Some((i, len)) = BEFORE.iter().filter_map(|b| rest.find(b).map(|i| (i, b.len()))).min() else { break };
|
||||
out.push_str(&rest[..i + len]);
|
||||
rest = &rest[i + len..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// The reply `r` of an export into `folder`, a folder the client did not give, as the request being
|
||||
/// served may see it: when `folder` is outside the read roots ([`super::access::shown`]), the ZIP's
|
||||
/// `path` is left out and `files` names each file written relative to `folder` (`2x/Logo@2x.png`),
|
||||
@@ -1060,7 +1021,8 @@ pub(crate) fn with_export_preset(s: &Session, cmd: &str, settings: &mut Map<Stri
|
||||
/// holds its artboard alone), or with `fullDocument` one file per row (a PDF of every artboard,
|
||||
/// else the bounds of all art). Items that share a name get `-2`, `-3`… instead of overwriting, and
|
||||
/// so do different files that would share a name, with or without the `name` pattern. The reply's
|
||||
/// `warnings` lists every warning the encoders give for the files, each once.
|
||||
/// `warnings` lists every warning the encoders give for the files, each once, what the files lose
|
||||
/// first ([`super::note_texts`]).
|
||||
pub(crate) fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
check_passwords(p)?;
|
||||
let o = ScreenParams::of(p)?;
|
||||
@@ -1162,7 +1124,7 @@ impl ScreenExport {
|
||||
let r = encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) });
|
||||
match hidden {
|
||||
Some((folder, name)) => {
|
||||
let mut r = r.map_err(|e| leave_out_folder_named(e, &folder, &name))?;
|
||||
let mut r = r.map_err(|e| super::access::naming_as(e, &folder, &name))?;
|
||||
leave_out_folder(&mut r, &folder);
|
||||
Ok(r)
|
||||
}
|
||||
@@ -1179,18 +1141,16 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
create_dir(dir)?;
|
||||
}
|
||||
let mut out = Output::default();
|
||||
// The encoders' warnings for every file, each once.
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
// The encoders' notes for every file, each once.
|
||||
let mut notes: Vec<Note> = Vec::new();
|
||||
// Include Bleed: a grown artboard already holds the bleed. Assets, layers and a selection are
|
||||
// written on a page of their own bounds, which it doesn't grow.
|
||||
let bleed_on_page = o.include_bleed && o.assets.is_none() && o.layers.is_none() && o.selection.is_none();
|
||||
let mut write = |doc: &Document, format: &str, options: &Value| -> Result<Vec<u8>> {
|
||||
// The steps are the files: an encoder's own steps (artboards, pages) are not reported.
|
||||
let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?;
|
||||
for w in notes {
|
||||
if !(bleed_on_page && w == super::pdf::BLEED_LEFT_OUT) && !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
let (bytes, given) = vectorcraft_doc::cancel::quiet(|| encode_with_notes(doc, format, options))?;
|
||||
for n in given.into_iter().filter(|n| !(bleed_on_page && n.text == super::pdf::BLEED_LEFT_OUT)) {
|
||||
add_note(&mut notes, n);
|
||||
}
|
||||
Ok(bytes)
|
||||
};
|
||||
@@ -1265,6 +1225,8 @@ fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) ->
|
||||
if o.zip || folder.is_none() {
|
||||
vectorcraft_doc::cancel::check()?;
|
||||
}
|
||||
// What the files lose first, across every file.
|
||||
let warnings = note_texts(¬es);
|
||||
let names: Vec<&str> = out.kept.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if o.zip {
|
||||
let zip = super::zip::deflate(&out.kept, super::zip::FIXED_TIME, vectorcraft_doc::cancel::is_cancelled);
|
||||
@@ -1516,7 +1478,8 @@ pub(super) fn export_settings(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, ScreenSize, leave_out_folder_from_message, safe_file_name};
|
||||
use super::super::access::naming_in_message;
|
||||
use super::{Output, ScreenSize, safe_file_name};
|
||||
|
||||
/// A hidden export folder is named by its name where a message is about it, and a root that
|
||||
/// starts with the same text, or a longer path, stays whole.
|
||||
@@ -1533,7 +1496,7 @@ mod tests {
|
||||
("automation path rejected: outside the write root /a: /work/outer", "automation path rejected: outside the write root /a: /work/outer"),
|
||||
];
|
||||
for (msg, want) in cases {
|
||||
assert_eq!(leave_out_folder_from_message(msg, f, "out"), want, "{msg}");
|
||||
assert_eq!(naming_in_message(msg, f, "out"), want, "{msg}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,12 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
default: "false",
|
||||
description: "embed the fonts type uses as @font-face, subset to the characters used (whole when the font's licence forbids subsetting, left out with a warning when it forbids embedding)",
|
||||
},
|
||||
FormatOption {
|
||||
name: "variableData",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
description: "Include Variable Data: write the text variables, the data sets and the type object each text variable is bound to as a variable library (variableSets) that document.open and variable.loadLibrary read back; variables of other kinds, and what is past the limits variable.loadLibrary reads (256 text variables, 1,000 data sets, 8 MiB), are left out with a warning",
|
||||
},
|
||||
FormatOption {
|
||||
name: "svg",
|
||||
ty: "object",
|
||||
@@ -198,6 +204,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
enc.add_setting_notes(out.setting_notes);
|
||||
enc.files.push((artboard, if compressed { vectorcraft_svg::compress_bytes(&bytes) } else { bytes }));
|
||||
}
|
||||
// Live type names its fonts; outlines and embedded fonts are the fallback font's.
|
||||
|
||||
@@ -194,6 +194,21 @@ fn placed_images_and_fill_hatches_can_be_left_out() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The reply gives what the drawings lose before a note on the options, across the files: the
|
||||
/// first artboard's drawing notes the fill hatches that `includeHatches: false` leaves out, and
|
||||
/// the second's also has a see-through rectangle that DXF 2000 writes opaque.
|
||||
#[test]
|
||||
fn what_the_drawings_lose_comes_before_what_an_option_left_out() {
|
||||
let mut s = two_boards();
|
||||
s.execute("object.setProps", &json!({"opacity": 50})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "version": "2000", "includeHatches": false, "useArtboards": true})).unwrap();
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
let Some((option, losses)) = warnings.split_last() else { panic!("no notes") };
|
||||
assert!(option.starts_with("fill hatches are left out"), "{warnings:?}");
|
||||
assert!(losses.iter().any(|w| w.starts_with("transparency needs DXF 2004")), "{warnings:?}");
|
||||
assert!(!losses.iter().any(|w| w.starts_with("fill hatches")), "each note once: {warnings:?}");
|
||||
}
|
||||
|
||||
/// Two placed images whose pixel data can't be decoded are left out of the drawing and get no
|
||||
/// linked file, and `warnings` holds the note raster exports give, once, without naming either
|
||||
/// image's key.
|
||||
|
||||
@@ -117,3 +117,35 @@ fn formats_list_use_artboards() {
|
||||
}
|
||||
assert!(opts("vectorcraft").get("useArtboards").is_none());
|
||||
}
|
||||
|
||||
/// An export's reply gives what the file loses before its notes on the options, whatever order
|
||||
/// the encoder gave them: the WebP encoder notes that lossy WebP isn't applied before the export
|
||||
/// notes the type it drew in the fallback font. With only notes on the options, they keep their
|
||||
/// order.
|
||||
#[test]
|
||||
fn what_the_file_loses_comes_before_a_note_on_the_options() {
|
||||
let lossy = "lossy WebP isn't available yet: the file is lossless (exact, but larger)";
|
||||
let warnings = |s: &mut Session, p: Value| -> Vec<String> {
|
||||
let r = s.execute("document.export", &p).unwrap();
|
||||
r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect()
|
||||
};
|
||||
let mut s = session(60.0, 40.0, 1);
|
||||
assert_eq!(warnings(&mut s, json!({"format": "webp", "lossless": false})), [lossy]);
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
let w = warnings(&mut s, json!({"format": "webp", "lossless": false}));
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!(w[0].contains("fallback font") && w[0].contains("Nonexistent Sans Probe"), "{w:?}");
|
||||
assert_eq!(w[1], lossy);
|
||||
// document.serialize gives them in the same order.
|
||||
let r = s.execute("document.serialize", &json!({"format": "webp", "lossless": false})).unwrap();
|
||||
assert_eq!(r["warnings"], json!(w));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn note_texts_give_the_losses_first_each_kind_in_order() {
|
||||
use super::encode::{Note, note_texts};
|
||||
let notes = [Note::setting("s1"), Note::loss("l1"), Note::setting("s2"), Note::loss("l2")];
|
||||
assert_eq!(note_texts(¬es), ["l1", "l2", "s1", "s2"]);
|
||||
assert_eq!(note_texts(&[Note::setting("s1"), Note::setting("s2")]), ["s1", "s2"]);
|
||||
assert!(note_texts(&[]).is_empty());
|
||||
}
|
||||
|
||||
@@ -56,3 +56,21 @@ fn permissions_without_a_password_warn_and_a_password_applies_them() {
|
||||
let bytes = vectorcraft_format::base64_decode(locked["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(String::from_utf8_lossy(&bytes).contains("/Encrypt"));
|
||||
}
|
||||
|
||||
/// The writer notes the permissions it didn't apply before the export notes the type it drew in
|
||||
/// the fallback font: the reply gives what the file loses first, through both commands.
|
||||
#[test]
|
||||
fn the_fallback_font_note_comes_before_the_permissions_note() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 80})).unwrap();
|
||||
s.execute("text.create", &json!({"x": 5, "y": 20, "text": "Ab", "font": "Nonexistent Sans Probe"})).unwrap();
|
||||
for r in [
|
||||
s.execute("document.exportPdf", &json!({"security": {"printing": "none"}})).unwrap(),
|
||||
s.execute("document.export", &json!({"format": "pdf", "security": {"printing": "none"}})).unwrap(),
|
||||
] {
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
let at = |s: &str| warnings.iter().position(|w| w.contains(s));
|
||||
let (font, permissions) = (at("Nonexistent Sans Probe"), at("permissions need a password"));
|
||||
assert!(font.is_some() && permissions.is_some() && font < permissions, "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@ fn type_in_a_missing_family_is_named_once_for_every_format() {
|
||||
assert!(fallback[0].contains("Nonexistent Sans Probe"), "{fallback:?}");
|
||||
}
|
||||
|
||||
/// What a later file loses comes before a note on the options that an earlier file gave: the
|
||||
/// first artboard's WebP gives only the lossy WebP note, the second's also says its type doesn't
|
||||
/// fit.
|
||||
#[test]
|
||||
fn what_any_file_loses_comes_before_the_notes_on_the_options() {
|
||||
let mut s = session(2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect;
|
||||
let area = json!({"width": 30, "height": 8});
|
||||
s.execute("text.create", &json!({"x": second.x0 + 2.0, "y": second.y0 + 2.0, "size": 12, "area": area, "text": "Ab\nCd\nEf\nGh"})).unwrap();
|
||||
let r = export(&mut s, json!({"formats": [{"format": "webp", "lossless": false}]}));
|
||||
let warnings: Vec<&str> = r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(warnings.len(), 2, "{warnings:?}");
|
||||
assert!(warnings[0].contains("doesn't fit its frame"), "{warnings:?}");
|
||||
assert!(warnings[1].starts_with("lossy WebP"), "{warnings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_document_gives_one_file_per_format() {
|
||||
let mut s = session(3);
|
||||
|
||||
@@ -43,12 +43,62 @@ impl Visitor for Blocked {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's folder tree, removed with everything in it when dropped, also when the test panics,
|
||||
/// and also when the test locked a folder in it. A removal that fails (a walker still holding a
|
||||
/// file open) is ignored.
|
||||
struct Tree(PathBuf);
|
||||
|
||||
impl std::ops::Deref for Tree {
|
||||
type Target = PathBuf;
|
||||
fn deref(&self) -> &PathBuf {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tree {
|
||||
fn drop(&mut self) {
|
||||
if std::fs::remove_dir_all(&self.0).is_err() {
|
||||
unlock(&self.0);
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives the owner read, write and search permission on `folder` and on every folder in it, so
|
||||
/// that a tree holding a folder a test locked can be removed. Links are not followed.
|
||||
#[cfg(unix)]
|
||||
fn unlock(folder: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let Ok(meta) = std::fs::symlink_metadata(folder) else { return };
|
||||
if !meta.is_dir() {
|
||||
return;
|
||||
}
|
||||
let mode = meta.permissions().mode() & 0o7777;
|
||||
if mode & 0o700 != 0o700 {
|
||||
let _ = std::fs::set_permissions(folder, std::fs::Permissions::from_mode(mode | 0o700));
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(folder) else { return };
|
||||
for entry in entries.flatten() {
|
||||
if entry.file_type().is_ok_and(|t| t.is_dir()) {
|
||||
unlock(&entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders on other systems are removed as they are.
|
||||
#[cfg(not(unix))]
|
||||
fn unlock(_: &Path) {}
|
||||
|
||||
/// A fresh folder (canonical, as a search reports it) holding `paths`: files, and folders where
|
||||
/// they end in `/`.
|
||||
fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
let root = vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
/// they end in `/`. The folder is removed when the returned [`Tree`] is dropped.
|
||||
fn tree(tag: &str, paths: &[&str]) -> Tree {
|
||||
let mut root = Tree(vectorcraft_testkit::temp_dir(&format!("findfiles-{tag}")));
|
||||
// Empties what a tree whose removal failed left, keeping the folder as `temp_dir` made it
|
||||
// (owner-only on Unix).
|
||||
for entry in std::fs::read_dir(&root.0).unwrap().flatten() {
|
||||
let path = entry.path();
|
||||
let _ = if entry.file_type().is_ok_and(|t| t.is_dir()) { std::fs::remove_dir_all(&path) } else { std::fs::remove_file(&path) };
|
||||
}
|
||||
for p in paths {
|
||||
let path = root.join(p);
|
||||
if p.ends_with('/') {
|
||||
@@ -58,7 +108,54 @@ fn tree(tag: &str, paths: &[&str]) -> PathBuf {
|
||||
std::fs::write(&path, b"font").unwrap();
|
||||
}
|
||||
}
|
||||
plain(std::fs::canonicalize(&root).unwrap())
|
||||
root.0 = plain(std::fs::canonicalize(&root.0).unwrap());
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_ends() {
|
||||
let path = {
|
||||
let root = tree("removed", &["a/b/one.ttf", "c/"]);
|
||||
assert!(root.join("a/b/one.ttf").is_file() && root.join("c").is_dir());
|
||||
root.to_path_buf()
|
||||
};
|
||||
assert!(!path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tree_is_removed_when_its_test_panics() {
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("unwound", &["a/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_is_private_to_this_user() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let root = tree("private", &["a/one.ttf"]);
|
||||
let mode = std::fs::metadata(&*root).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "{}: mode {mode:o}", root.display());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_tree_with_a_locked_folder_is_removed_when_its_test_panics() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut path = PathBuf::new();
|
||||
let r = vectorcraft_testkit::catch_quiet::<()>(|| {
|
||||
let root = tree("locked", &["a/locked/b/one.ttf"]);
|
||||
path = root.to_path_buf();
|
||||
std::fs::set_permissions(root.join("a/locked"), std::fs::Permissions::from_mode(0o000)).unwrap();
|
||||
panic!("a failed assertion");
|
||||
});
|
||||
assert_eq!(r.err().as_deref(), Some("a failed assertion"));
|
||||
assert!(!path.as_os_str().is_empty() && !path.exists(), "{} is left", path.display());
|
||||
}
|
||||
|
||||
/// The rules of a search of `root`: its `Users/me` is the home folder, `refused` an app's folder.
|
||||
|
||||
@@ -850,14 +850,23 @@ fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
/// The rectangle from `x`, `y`, `width` and `height`, each defaulting to `default`'s. Sides are kept as given.
|
||||
fn rect_unclamped(p: &Value, default: Rect) -> Rect {
|
||||
let x = f64_or(p, "x", default.x0);
|
||||
let y = f64_or(p, "y", default.y0);
|
||||
let w = f64_or(p, "width", default.width()).max(1.0);
|
||||
let h = f64_or(p, "height", default.height()).max(1.0);
|
||||
let w = f64_or(p, "width", default.width());
|
||||
let h = f64_or(p, "height", default.height());
|
||||
Rect::new(x, y, x + w, y + h)
|
||||
}
|
||||
|
||||
/// [`rect_unclamped`] with each supplied side raised to at least 1 pt, as `artboard.setProps` uses it.
|
||||
/// A side left out keeps `default`'s, even below 1 pt.
|
||||
fn rect_from(p: &Value, default: Rect) -> Rect {
|
||||
let r = rect_unclamped(p, default);
|
||||
let floor = |key: &str, side: f64| if p.get(key).and_then(Value::as_f64).is_some() { side.max(1.0) } else { side };
|
||||
Rect::new(r.x0, r.y0, r.x0 + floor("width", r.width()), r.y0 + floor("height", r.height()))
|
||||
}
|
||||
|
||||
/// An artboard command's `color` param, its background color: None when absent, `Some(None)` for
|
||||
/// null (no color), else `Some(Some(color))`.
|
||||
fn artboard_color(p: &Value, cmd: &str) -> Result<Option<Option<vectorcraft_color::Color>>> {
|
||||
@@ -942,7 +951,7 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
src + side.offset(src)
|
||||
}
|
||||
None if p.get("beside").is_some_and(|v| !v.is_null()) => return Err(bad("artboard.new", "`beside` needs `side`")),
|
||||
None => rect_from(p, def),
|
||||
None => rect_unclamped(p, def),
|
||||
};
|
||||
// A size taken from another artboard (`side`, or width or height left out) keeps to the limit too.
|
||||
if !super::newdoc::side_in_range(r.width()) || !super::newdoc::side_in_range(r.height()) {
|
||||
|
||||
@@ -28,7 +28,9 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::links::{Align, Preserve, hash_bytes};
|
||||
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, DataValue, Document, ImageBlob, ImageObject, LinkInfo, Node, NodeId, NodeKind, PageBox, PlacedDocument, PlacementOptions,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, PathData, Rect, shapes};
|
||||
|
||||
use super::fileio::{self, RasterImage, absolute_path, file_created, file_stamp, read_file, write_file};
|
||||
@@ -68,7 +70,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Relink",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy)}",
|
||||
"{ids?: [image or placed document ids] (default: the selected ones; with folder and nothing selected, every missing link), path | folder, scope?: selected (default: ids) | all (ids and every other object in the layers linked to the same file as one of them) | missingInFolder (with path: ids, then every other missing link whose file name is in path's folder, each to the file of its name there; names not there are in notFound; with copy, only images are looked for) | each (with paths), paths?: [{ids, path}] (with scope each, in place of ids, path and folder: each link to its own file; an id in two entries is an error), copy?: false (with path or paths, for images of a saved document: copy the file into the document's folder and link to the copy; the names Name.png, Name 2.png … Name 99.png are tried in turn until one is free, which gets the copy, or is a file with the same contents, which is used), preserve?: transforms|bounds|fileDimensions|fit|fill (how the new file takes each image's place this time, as links.placementOptions describes; default: each image's Placement Options; fit keeps the new file's aspect ratio inside the old bounds: Replace)} link images (or placed documents, each showing the same artboard or page of the new file) to the file at path, or each to the file of its link's name in folder; embedded images become linked; each keeps its bounds. One undo step → {relinked: [ids], notFound: [file names], copied?: [{from, to}], kept?: [{from, to}] (with copy; to is the file name alone when the document's folder is outside the automation read roots)}",
|
||||
has_doc,
|
||||
relink
|
||||
),
|
||||
@@ -1195,6 +1197,83 @@ pub(crate) fn with_found_paths(d: &mut Document, base: LinkBase) {
|
||||
});
|
||||
}
|
||||
|
||||
/// `d`, a document saved at `doc_path`, as a reply to the caller asking now may show it: without
|
||||
/// read roots in force, `d` itself; with them, a copy in which each link [`path_shown`] does not
|
||||
/// allow names its file alone, without its relative path or the size and date kept of its file,
|
||||
/// and each data set's image value and linked JavaScript file (`svg_scripts`) it does not allow
|
||||
/// names its file alone too, while a JavaScript file given as a URL stays as it is. The folder Export
|
||||
/// for Screens last wrote to is left out of the export settings when it is outside the read roots.
|
||||
/// A link whose path is allowed but whose relative path leads outside the read roots loses its
|
||||
/// relative path. Links in the layers, symbols, graph designs, patterns, the pattern being edited
|
||||
/// as it was before, opacity masks and the art of the stored brushes are all checked. A stored
|
||||
/// brush entry that does not read as a brush is left out of the copy, since its links cannot be
|
||||
/// checked, and a stored brush library that is not a list becomes an empty one.
|
||||
pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Cow<'a, Document> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return Cow::Borrowed(d);
|
||||
}
|
||||
let doc_path = base.doc;
|
||||
// A stored relative path is followed from the folder of the file the document was read from,
|
||||
// and a link is also looked for from the document's own folder: it is shown only when both
|
||||
// places it names are inside the read roots.
|
||||
let relative_hidden = |r: &str| [base.relative, base.doc].into_iter().any(|from| !path_shown(r, from));
|
||||
let mut d = d.clone();
|
||||
// The pattern being edited as it was before is checked as one more pattern, then put back.
|
||||
let original = d.pattern_edit.as_mut().and_then(|e| e.original.take());
|
||||
let edited = original.is_some();
|
||||
d.patterns.extend(original);
|
||||
// The art of each stored brush is checked as more layers, then put back.
|
||||
let stored = d.unknown.get(vectorcraft_brush::DOC_KEY).map(|v| v.as_array().cloned().unwrap_or_default());
|
||||
let mut brushes: Vec<vectorcraft_brush::Brush> =
|
||||
stored.iter().flatten().filter_map(|raw| vectorcraft_brush::Brush::from_json(raw.clone()).ok()).collect();
|
||||
let layers = d.layers.len();
|
||||
d.layers.extend(brushes.iter().flat_map(|b| b.art()).map(|n| Arc::new(n.clone())));
|
||||
d.update_links_where(
|
||||
|l| !path_shown(&l.path, base.doc) || l.relative.as_deref().is_some_and(relative_hidden),
|
||||
|l| {
|
||||
if path_shown(&l.path, base.doc) {
|
||||
l.relative = None;
|
||||
} else {
|
||||
let name = l.name().to_string();
|
||||
*l = LinkInfo { path: name, relative: None, modified: None, size: None, ..l.clone() };
|
||||
}
|
||||
},
|
||||
);
|
||||
let mut checked = d.layers.split_off(layers).into_iter();
|
||||
if edited && let (Some(def), Some(e)) = (d.patterns.pop(), d.pattern_edit.as_mut()) {
|
||||
e.original = Some(def);
|
||||
}
|
||||
if stored.is_some() {
|
||||
// Each brush is written as it reads, so fields it does not read are left out too.
|
||||
let mut kept = vec![];
|
||||
for b in &mut brushes {
|
||||
for (slot, art) in b.art_mut().into_iter().zip(&mut checked) {
|
||||
*slot = Arc::unwrap_or_clone(art);
|
||||
}
|
||||
kept.extend(serde_json::to_value(&*b).ok());
|
||||
}
|
||||
d.unknown.insert(vectorcraft_brush::DOC_KEY.into(), Value::Array(kept));
|
||||
}
|
||||
if d.export_settings.get("folder").and_then(Value::as_str).is_some_and(|f| crate::file_access::check_read(f).is_err()) {
|
||||
d.export_settings.remove("folder");
|
||||
}
|
||||
let hidden = |v: &DataValue| matches!(v, DataValue::Image(path) if !path_shown(path, doc_path));
|
||||
if d.variables.datasets.iter().any(|set| set.values.values().any(hidden)) {
|
||||
for v in d.variables.datasets.iter_mut().flat_map(|set| set.values.values_mut()) {
|
||||
if hidden(&*v)
|
||||
&& let DataValue::Image(path) = v
|
||||
{
|
||||
*path = LinkInfo::new(path.as_str()).name().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url));
|
||||
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) {
|
||||
*f = LinkInfo::new(f.as_str()).name().to_string();
|
||||
}
|
||||
Cow::Owned(d)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn check(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1337,7 +1416,9 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
(true, false) => return Err(bad(C, "copy needs path: the file to copy into the document's folder")),
|
||||
(true, true) => {
|
||||
let doc = st.path.as_deref().map(absolute_path).ok_or_else(|| bad(C, "save the document first: copy puts the file in its folder"))?;
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(|| bad(C, format!("{doc} has no folder")))?)
|
||||
// The document's path is not the client's: the error names it as the request may see it.
|
||||
let no_folder = || fileio::naming_in_error(bad(C, format!("{doc} has no folder")), &doc);
|
||||
Some(Path::new(&doc).parent().map(Path::to_path_buf).ok_or_else(no_folder)?)
|
||||
}
|
||||
};
|
||||
// An image or a placed document, with its link.
|
||||
@@ -1472,6 +1553,12 @@ fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
not_found.dedup();
|
||||
let mut out = json!({ "relinked": relinked, "notFound": not_found });
|
||||
if copy_to.is_some() {
|
||||
// The copies are in the document's folder, which the client did not give.
|
||||
for pair in copied.iter_mut().chain(kept.iter_mut()) {
|
||||
if let Some(to) = pair.get("to").and_then(Value::as_str).map(str::to_string) {
|
||||
pair["to"] = json!(fileio::shown_path(&to));
|
||||
}
|
||||
}
|
||||
out["copied"] = json!(copied);
|
||||
out["kept"] = json!(kept);
|
||||
}
|
||||
@@ -1521,9 +1608,14 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
use std::io::Write as _;
|
||||
let source = Path::new(from);
|
||||
let name = source.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
// Each name it takes is checked against the automation roots in force.
|
||||
// Each name it takes is checked against the automation roots in force. The folder is the
|
||||
// document's, so a refusal names it and the file as the request may see them.
|
||||
let create = |to: &Path| {
|
||||
crate::file_access::check_write(&to.to_string_lossy()).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
let to_text = to.to_string_lossy();
|
||||
crate::file_access::check_write(&to_text).map_err(|e| {
|
||||
let e = fileio::naming_in_error(EngineError::Other(e), &to_text);
|
||||
std::io::Error::new(std::io::ErrorKind::PermissionDenied, e.to_string())
|
||||
})?;
|
||||
vectorcraft_format::write_new_with(to, |f| f.write_all(bytes))
|
||||
};
|
||||
// A file there that automation may not read isn't taken for the same file.
|
||||
@@ -1534,7 +1626,10 @@ fn copy_into_folder(from: &str, bytes: &[u8], dir: &Path) -> Result<(String, boo
|
||||
Ok(Some(fileio::Placed::Copied(to))) => Ok((to.to_string_lossy().into_owned(), true)),
|
||||
Ok(Some(fileio::Placed::Kept(to))) => Ok((to.to_string_lossy().into_owned(), false)),
|
||||
Ok(None) => Err(EngineError::Other(format!("every numbered name for {name} is taken in the document's folder"))),
|
||||
Err(e) => Err(EngineError::Other(format!("{}: {e}", dir.display()))),
|
||||
Err(e) => {
|
||||
let dir = dir.to_string_lossy();
|
||||
Err(fileio::naming_in_error(EngineError::Other(format!("{dir}: {e}")), &dir))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -389,6 +389,39 @@ pub(super) fn blend_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn blend_set_easing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "object.blend.setEasing";
|
||||
let id = match crate::cmd::object_param(p, "id", C)? {
|
||||
Some(id) => id,
|
||||
None => *selected_of(s, is_blend).first().ok_or_else(|| bad(C, "select a blend or give `id`"))?,
|
||||
};
|
||||
let doc = &s.doc()?.doc;
|
||||
let Some(NodeKind::Blend { children, spec }) = doc.node(id).map(|n| &n.kind) else { return Err(bad(C, "not a blend")) };
|
||||
if !doc.is_editable(id) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
let ease = ease_param(p, C, spec.ease)?;
|
||||
let color_shift = percent_param(p, C, "colorShift", spec.color_shift, -100.0)?;
|
||||
// Easing moves the steps along a spine, so it needs one with a length to run along.
|
||||
let easing = ["easingType", "ramp"].iter().any(|key| p.get(*key).is_some_and(|v| !v.is_null()));
|
||||
let spined = spec.eases_along_spine()
|
||||
&& live::blend_spine(children, spec).and_then(|(path, _)| Spine::new(&path)).is_some_and(|sp| sp.length().is_finite() && sp.length() > 0.0);
|
||||
if easing && !spined {
|
||||
return Err(bad(C, "the blend has no spine"));
|
||||
}
|
||||
let changed = ease != spec.ease || color_shift != spec.color_shift;
|
||||
if changed {
|
||||
s.edit("Blend Options", |d, _| {
|
||||
if let Some(NodeKind::Blend { spec, .. }) = d.node_mut(id).map(|n| &mut n.kind) {
|
||||
spec.ease = ease;
|
||||
spec.color_shift = color_shift;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({ "id": id.0, "easingType": ease.kind.id(), "ramp": ease.ramp * 100.0, "colorShift": color_shift, "changed": changed }))
|
||||
}
|
||||
|
||||
/// Spacing and orientation as `object.blend.options` takes them; the step count and distance
|
||||
/// not in use show their defaults.
|
||||
fn blend_options_json(spacing: BlendSpacing, orientation: BlendOrientation) -> Value {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::blend::{
|
||||
blend_create_spine, blend_expand, blend_info, blend_make, blend_options, blend_release, blend_replace_spine, blend_reverse_spine,
|
||||
blend_reverse_stack, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
blend_reverse_stack, blend_set_easing, spine_add_anchor, spine_convert_anchor, spine_move_anchor, spine_remove_anchor, spine_reshape_segment,
|
||||
};
|
||||
use super::common::{has_blend, has_envelope, has_mesh};
|
||||
use super::envelope::{
|
||||
@@ -49,6 +49,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always,
|
||||
blend_create_spine
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.setEasing",
|
||||
"Blend Easing",
|
||||
[],
|
||||
None,
|
||||
"{id? (default: the first selected blend), easingType?: easeIn|easeOut|easeInOut|easeOutIn, ramp?: pct (0..100), colorShift?: pct (-100..100)} set one blend's easing and color shift, as the blend widgets on the canvas do; omitted ones keep their value. easingType and ramp need a blend whose steps follow a spine (a replaced or edited spine, or Align to Path); a blend made without a spine takes them once Replace Spine has given it one; out of range, a hidden or locked blend and a blend with no spine are errors. A value that changes nothing records no undo step → {id, easingType, ramp, colorShift, changed}",
|
||||
has_doc,
|
||||
blend_set_easing
|
||||
),
|
||||
cmd!(
|
||||
"object.blend.expand",
|
||||
"Expand",
|
||||
|
||||
@@ -133,16 +133,16 @@ fn bounds_transform(s: &Session, p: &Value, ids: Vec<NodeId>, least: f64, anchor
|
||||
// the objects differ, the members of groups and the objects in symbols' art included, and with
|
||||
// strokes that scale, when the sizes are out of proportion. The objects are then measured one
|
||||
// by one ([`Spans::measure`]), and the scales solved for. Objects with geometry effects,
|
||||
// objects with a butt-capped stroke along a segment at a slant, whose margins change with the
|
||||
// segment's direction, and type measured by its glyphs, are measured as drawn after each scale
|
||||
// tried instead ([`refit_drawn`]).
|
||||
// objects with a stroke that stops at the corners of a segment at a slant (butt caps, bevel and
|
||||
// miter joins), whose margins change with the segment's direction, and type measured by its
|
||||
// glyphs, are measured as drawn after each scale tried instead ([`refit_drawn`]).
|
||||
let to_local = frame.to_doc().inverse();
|
||||
let drawn = || {
|
||||
by_glyphs
|
||||
|| ids
|
||||
.iter()
|
||||
.filter_map(|id| d.node(*id))
|
||||
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_butt_stroke(&symbols, to_local))
|
||||
.any(|n| effects::preview::draws_geometry(&symbols, n) || n.has_slanted_stroke_corners(&symbols, to_local))
|
||||
};
|
||||
// Without Use Preview Bounds the box has no margins (`v`, `vs` and `g` are one box, glyph ink
|
||||
// included), so the scales above are exact and nothing is refitted.
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::borrow::Cow;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{BlendMode, Color, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, Node, NodeId, NodeKind};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Document, GraySource, Node, NodeId, NodeKind};
|
||||
|
||||
use super::appearance::{ItemTarget, appearance_targets, edit_items, edit_items_then, item_target};
|
||||
use super::gradient::{item_paint_bounds, place_paint, place_run_paint, run_paint_mut, run_stroke_weight, unplaced};
|
||||
@@ -718,7 +718,8 @@ fn colorized_images(d: &Document, ids: &[NodeId], paint: &Paint) -> std::result:
|
||||
}
|
||||
|
||||
/// The message for a process color on a grayscale image with an alpha channel.
|
||||
const PROCESS_ON_TRANSPARENCY: &str = "a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
pub(super) const PROCESS_ON_TRANSPARENCY: &str =
|
||||
"a process color can't colorize a grayscale image with transparency: use a spot color, black or white";
|
||||
|
||||
/// Can `paint` colorize a grayscale image with an alpha channel: a color linked to a spot swatch
|
||||
/// of `d`, black or white?
|
||||
@@ -737,11 +738,7 @@ pub(super) fn check_colorized(d: &Document) -> std::result::Result<(), String> {
|
||||
let mut refused = false;
|
||||
let mut visit = |n: &Node| {
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |m| {
|
||||
if let (NodeKind::Image(im), Some(paint)) = (&m.kind, m.extras().colorize.as_deref())
|
||||
&& !refused
|
||||
&& !colorizes_transparency(d, paint)
|
||||
&& d.image_blob(im).and_then(|b| b.gray_source()).is_some_and(|g| g.alpha)
|
||||
{
|
||||
if !refused && gray_colorized_with_process(d, m).is_some_and(|g| g.alpha) {
|
||||
refused = true;
|
||||
}
|
||||
})
|
||||
@@ -761,6 +758,13 @@ pub(super) fn check_colorized_kept(was_ok: bool, d: &Document) -> std::result::R
|
||||
if was_ok { check_colorized(d) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// How the pixels of `n` are gray, when `n` is a grayscale or 1-bit image colorized with a color
|
||||
/// that [`colorizes_transparency`] refuses. Such an image can't have an alpha channel.
|
||||
pub(super) fn gray_colorized_with_process(d: &Document, n: &Node) -> Option<GraySource> {
|
||||
let (NodeKind::Image(im), Some(paint)) = (&n.kind, n.extras().colorize.as_deref()) else { return None };
|
||||
if colorizes_transparency(d, paint) { None } else { d.image_blob(im).and_then(|b| b.gray_source()) }
|
||||
}
|
||||
|
||||
/// Is `e` the refusal of [`check_colorized`]?
|
||||
pub(super) fn is_colorize_refusal(e: &EngineError) -> bool {
|
||||
e.to_string().contains(PROCESS_ON_TRANSPARENCY)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -896,7 +896,7 @@ pub(crate) fn outlined_stroke(
|
||||
};
|
||||
let art = d.reid(&art);
|
||||
let mut art = with_transparency(d, art, st.opacity, st.blend);
|
||||
let raster: Vec<_> = st.effects.iter().filter(|e| !vectorcraft_render::effects::is_geometry(&e.id)).cloned().collect();
|
||||
let raster = vectorcraft_render::effects::without_geometry(&st.effects);
|
||||
if !raster.is_empty() {
|
||||
if !art.appearance.effects.is_empty() {
|
||||
art = Node::group(d.alloc_id(), vec![Arc::new(art)]);
|
||||
@@ -946,7 +946,7 @@ pub(crate) fn outlined_stroke(
|
||||
/// geometry effects leave nothing to paint, an empty group takes the path's place.
|
||||
/// `None` when `l` has no stroke to outline.
|
||||
pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Brush], l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, is_geometry, reshape};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, has_transform_art, reshape, without_geometry};
|
||||
if !l.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Stroke(st) if outlinable(st))) {
|
||||
return None;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = moved;
|
||||
plain.effects_mut().retain(|e| !is_geometry(&e.id));
|
||||
*plain.effects_mut() = without_geometry(plain.effects());
|
||||
parts.push(styled_copy(d, &shape, vec![plain]));
|
||||
}
|
||||
AppearanceItem::Stroke(st) if outlinable(st) => {
|
||||
@@ -983,7 +983,7 @@ pub(crate) fn outline_strokes(d: &mut Document, brushes: &[vectorcraft_brush::Br
|
||||
parts.push(styled_copy(d, l, std::mem::take(&mut fills)));
|
||||
}
|
||||
let mut plain = st.clone();
|
||||
plain.effects.retain(|e| !is_geometry(&e.id));
|
||||
plain.effects = without_geometry(&st.effects);
|
||||
let stroked = match path.bounds() {
|
||||
Some(b) if has_geometry(&st.effects) => apply_geometry_with(&st.effects, &path, b, &ctx.item(item)),
|
||||
_ => path.clone(),
|
||||
|
||||
@@ -169,6 +169,10 @@ pub(crate) const NOT_FILES: &[(&str, &str)] = &[
|
||||
("document.exportForScreens", "folders"),
|
||||
];
|
||||
|
||||
/// Commands and the keys from the top of their params down to a key in [`FILE_KEYS`] that, there,
|
||||
/// names part of the document, not a file: a custom arrowhead's outline in `stroke.set`.
|
||||
const NESTED_NOT_FILES: &[(&str, &[&str])] = &[("stroke.set", &["startArrow", "Custom", "path"]), ("stroke.set", &["endArrow", "Custom", "path"])];
|
||||
|
||||
/// Is command id `id` one a plug-in may never call, from its id alone ([`DENIED_PREFIXES`],
|
||||
/// [`FILE_WORDS`])?
|
||||
fn denied_id(id: &str) -> bool {
|
||||
@@ -232,20 +236,32 @@ pub(crate) fn names_key(text: &str, key: &str) -> bool {
|
||||
|
||||
/// Does `args`, the arguments of a call of command `id`, carry a key in [`FILE_KEYS`] at any depth
|
||||
/// (in objects and in arrays), such as an effect's `textureImage.path`? Only a key [`NOT_FILES`]
|
||||
/// names for `id`, at the top level, isn't counted. The walk keeps its own stack, so deep
|
||||
/// arguments can't overflow the thread's.
|
||||
/// names for `id`, at the top level, and one at the end of a [`NESTED_NOT_FILES`] key list for
|
||||
/// `id`, aren't counted. The walk keeps its own stack, so deep arguments can't overflow the
|
||||
/// thread's.
|
||||
fn names_file(id: &str, args: &Value) -> bool {
|
||||
let mut stack = vec![(args, true)];
|
||||
while let Some((v, top)) = stack.pop() {
|
||||
// Each value, whether it is the params themselves, and the keys of a NESTED_NOT_FILES list
|
||||
// still to follow below it.
|
||||
let mut stack: Vec<(&Value, bool, Option<&[&str]>)> = vec![(args, true, None)];
|
||||
while let Some((v, top, trail)) = stack.pop() {
|
||||
match v {
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false))),
|
||||
Value::Array(a) => stack.extend(a.iter().map(|x| (x, false, None))),
|
||||
Value::Object(o) => {
|
||||
for (k, x) in o {
|
||||
let k = k.as_str();
|
||||
if FILE_KEYS.contains(&k) && !(top && NOT_FILES.contains(&(id, k))) {
|
||||
let rest = if top {
|
||||
NESTED_NOT_FILES
|
||||
.iter()
|
||||
.find(|(c, keys)| *c == id && keys.first() == Some(&k))
|
||||
.map(|(_, keys)| keys.get(1..).unwrap_or_default())
|
||||
} else {
|
||||
trail.filter(|keys| keys.first() == Some(&k)).map(|keys| keys.get(1..).unwrap_or_default())
|
||||
};
|
||||
let document = (top && NOT_FILES.contains(&(id, k))) || rest.is_some_and(<[&str]>::is_empty);
|
||||
if FILE_KEYS.contains(&k) && !document {
|
||||
return true;
|
||||
}
|
||||
stack.push((x, false));
|
||||
stack.push((x, false, rest.filter(|keys| !keys.is_empty())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -585,6 +601,16 @@ mod tests {
|
||||
assert!(!names_file("object.setLiveShape", &json!({"path": [[0, 0], [1, 1]]})));
|
||||
assert!(names_file("object.setLiveShape", &json!({"options": {"path": "/tmp/x"}})));
|
||||
assert!(names_file("object.move", &json!({"path": [[0, 0]]})));
|
||||
// A custom arrowhead's outline in stroke.set, only where stroke.set takes one.
|
||||
let outline = json!({"subpaths": []});
|
||||
assert!(!names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline}}})));
|
||||
assert!(!names_file("stroke.set", &json!({"startArrow": {"Custom": {"name": "A", "path": outline}}, "endArrow": "Triangle"})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"name": "A", "path": outline, "file": "/tmp/x"}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"Custom": {"path": {"path": "/tmp/x"}}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": {"path": "/tmp/x"}})));
|
||||
assert!(names_file("stroke.set", &json!({"options": {"endArrow": {"Custom": {"path": "/tmp/x"}}}})));
|
||||
assert!(names_file("stroke.set", &json!({"endArrow": [{"Custom": {"path": "/tmp/x"}}]})));
|
||||
assert!(names_file("object.move", &json!({"endArrow": {"Custom": {"path": "/tmp/x"}}})));
|
||||
// Deep nesting is walked without recursion.
|
||||
let mut deep = json!({"file": "/tmp/x"});
|
||||
for _ in 0..10_000 {
|
||||
|
||||
@@ -242,6 +242,7 @@ pub(super) const ALLOWED: &[&str] = &[
|
||||
"object.blend.replaceSpine",
|
||||
"object.blend.reverseFrontToBack",
|
||||
"object.blend.reverseSpine",
|
||||
"object.blend.setEasing",
|
||||
"object.blend.spine.addAnchor",
|
||||
"object.blend.spine.convertAnchor",
|
||||
"object.blend.spine.moveAnchor",
|
||||
@@ -689,5 +690,6 @@ pub(super) const DENIED: &[&str] = &[
|
||||
"object.blend.options",
|
||||
"object.liquify",
|
||||
"object.pathfinder.options",
|
||||
"stroke.arrowheads.edit",
|
||||
"type.compositeFonts.set",
|
||||
];
|
||||
|
||||
@@ -645,8 +645,8 @@ pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::re
|
||||
export_pdf_with_report(doc, opts).map(|r| r.bytes)
|
||||
}
|
||||
|
||||
/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features
|
||||
/// approximated or left out).
|
||||
/// Like [`export_pdf`], also returning the writer's warnings (features approximated or left out)
|
||||
/// and its notes on the options (options not applied yet).
|
||||
pub fn export_pdf_with_report(
|
||||
doc: &Document,
|
||||
opts: &vectorcraft_pdf::PdfOptions,
|
||||
|
||||
@@ -1276,15 +1276,16 @@ enum Owner {
|
||||
/// dropped) and, with `take` in a store without locks, taken over with a fresh heartbeat, so
|
||||
/// another app leaves it alone meanwhile. In a store without locks an area is gone only when
|
||||
/// nothing says its app runs: no announcement (or the store can't tell) and no heartbeat for three
|
||||
/// intervals but the one this session wrote taking it over.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool) -> Owner {
|
||||
/// intervals but the one this session wrote taking it over. `held` names the gone apps' areas the
|
||||
/// caller already holds.
|
||||
fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<Hold>, take: bool, held: &[&str]) -> Owner {
|
||||
if s.recovery.own.as_ref().is_some_and(|o| o.area == area && o.store.location() == store.location()) {
|
||||
return Owner::Mine;
|
||||
}
|
||||
match store.lock(area) {
|
||||
Ok(Lock::Held(h)) => {
|
||||
// Copies an app left here when it had to leave the area stay its own while it runs.
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
hold.push(h);
|
||||
@@ -1295,7 +1296,7 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
if store.announced(area).unwrap_or(false) || beating(store, area, stale_after(s), s.recovery.adopted.get(area).copied()) {
|
||||
return Owner::Running;
|
||||
}
|
||||
if let Some(who) = keeper(s, store, area) {
|
||||
if let Some(who) = keeper(s, store, area, held) {
|
||||
return who;
|
||||
}
|
||||
// Best effort: without it another app might offer the copies too, which loses nothing.
|
||||
@@ -1311,12 +1312,14 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
|
||||
/// `<its area>/keeps-<area>` ([`mark_left`]): this session, or another app that is running. `None`:
|
||||
/// no app that runs keeps them. An entry counts only in an area this account can lock or tell is
|
||||
/// held ([`holder`]), so an entry in a folder of another account, or in one whose lock can't be
|
||||
/// checked, keeps nothing. A store that can't be listed now keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str) -> Option<Owner> {
|
||||
/// checked, keeps nothing. So does an entry in one of the gone apps' areas the caller holds
|
||||
/// (`held`), whose lock this session would otherwise find taken. A store that can't be listed now
|
||||
/// keeps them (a copy kept loses nothing).
|
||||
fn keeper(s: &Session, store: &dyn RecoveryStore, area: &str, held: &[&str]) -> Option<Owner> {
|
||||
let Ok(names) = store.list() else { return Some(Owner::Running) };
|
||||
let mark = format!("/{KEEPS}{area}");
|
||||
let mut who = None;
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area) {
|
||||
for k in names.iter().filter_map(|n| n.strip_suffix(mark.as_str())).filter(|k| *k != area && !held.contains(k)) {
|
||||
match holder(s, store, k) {
|
||||
Owner::Mine => return Some(Owner::Mine),
|
||||
Owner::Running => who = Some(Owner::Running),
|
||||
@@ -1378,7 +1381,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let mut rows = vec![];
|
||||
for area in store.areas().map_err(EngineError::Other)? {
|
||||
let mut hold = vec![];
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false);
|
||||
let who = owner(s, store.as_ref(), &area, &mut hold, false, &[]);
|
||||
let files: Vec<&String> = copies.iter().filter(|(a, _)| *a == area).map(|(_, f)| f).collect();
|
||||
for f in &files {
|
||||
let meta = read_meta(store.as_ref(), f);
|
||||
@@ -1418,10 +1421,11 @@ fn targets(s: &mut Session, store: &dyn RecoveryStore, cmd: &str, p: &Value) ->
|
||||
}
|
||||
let picked = |c: &str| file.is_none_or(|f| f == c) && !left.iter().any(|l| l == c);
|
||||
let areas: BTreeSet<&str> = copies.iter().filter(|(_, c)| picked(c)).map(|(a, _)| a.as_str()).collect();
|
||||
let mut out = vec![];
|
||||
let mut out: Vec<Target> = vec![];
|
||||
for area in areas {
|
||||
let mut hold = vec![];
|
||||
match (owner(s, store, area, &mut hold, true), file) {
|
||||
let held: Vec<&str> = out.iter().map(|(a, _, _)| a.as_str()).collect();
|
||||
match (owner(s, store, area, &mut hold, true, &held), file) {
|
||||
(Owner::Gone, _) => {
|
||||
let files = copies.iter().filter(|(a, c)| a == area && picked(c)).map(|(_, c)| c.clone()).collect();
|
||||
out.push((area.to_string(), files, hold));
|
||||
|
||||
@@ -297,7 +297,8 @@ fn list_installed(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
return Ok(json!({"folder": null, "scripts": [], "truncated": false, "unreadable": false}));
|
||||
};
|
||||
let folder = root.to_string_lossy();
|
||||
crate::file_access::check_read(&folder).map_err(EngineError::Other)?;
|
||||
// The Scripts folder is the app's: a refusal names it as the request may see it.
|
||||
crate::file_access::check_read(&folder).map_err(|e| super::fileio::naming_in_error(EngineError::Other(e), &folder))?;
|
||||
let listing = script::folder::list(root);
|
||||
let scripts: Vec<Value> = listing
|
||||
.scripts()
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
//! art retained underneath. Recipes select topology regions; source edits re-evaluate them in
|
||||
//! the same undo transaction. Rendering and exporters only see ordinary groups and paths.
|
||||
use super::pathops::Beside;
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, object_param, point_param};
|
||||
use super::{
|
||||
CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, f64_or, has_doc, id_param, number_or, object_param, point_param,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashMap},
|
||||
@@ -34,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Shaper Face",
|
||||
[],
|
||||
None,
|
||||
"{point: [x,y], source?: bool} click selects a group, then a face; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
"{point: [x,y], source?: bool, tolerance?: pt (3)} click selects a group, then a face; with the group or one of its faces selected, a click within tolerance of an original shape's outline enters construction isolation and selects that original; source enters construction isolation and selects an original (while an opacity mask or a pattern is edited, it selects the original and the editing layer stays isolated); while a group's originals are isolated, it selects one of that group's originals → {id}",
|
||||
has_doc,
|
||||
select
|
||||
),
|
||||
@@ -734,16 +736,50 @@ fn group_at(d: &Document, point: Point, construction: bool, within: Option<NodeI
|
||||
});
|
||||
groups.last().copied()
|
||||
}
|
||||
/// The topmost editable Shaper Group that is selected, or has a face selected, with a visible,
|
||||
/// unlocked original shape whose outline lies within `tolerance` of `point`, and the topmost such
|
||||
/// shape in it; `None` when a Shaper Group that [`group_at`] finds at `point` lies above it.
|
||||
fn outline_at(d: &Document, selection: &vectorcraft_doc::Selection, point: Point, tolerance: f64) -> Option<(NodeId, NodeId)> {
|
||||
let mut hit = None;
|
||||
let editable = d.editable();
|
||||
d.walk(|n| {
|
||||
if n.extras().shaper.is_none() || !editable(n.id) {
|
||||
return;
|
||||
}
|
||||
let picked = selection.contains(n.id) || selection.objects.iter().any(|id| d.parent_of(*id) == Some(n.id));
|
||||
let on_outline =
|
||||
|s: &&Arc<Node>| b::node_outline(s).is_some_and(|(path, _)| path.nearest(point).is_some_and(|(_, _, _, _, dist)| dist <= tolerance));
|
||||
let source = if picked { sources(n).iter().rev().filter(|s| s.visible && !s.locked).find(on_outline).map(|s| s.id) } else { None };
|
||||
if let Some(source) = source {
|
||||
hit = Some((n.id, source));
|
||||
} else if n.geometric_bounds().is_some_and(|b| b.inflate(6.0, 6.0).contains(point)) {
|
||||
hit = None;
|
||||
}
|
||||
});
|
||||
hit
|
||||
}
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let point = point_param(p, "point").filter(|p| finite(*p)).ok_or_else(|| bad("shaper.select", "missing point"))?;
|
||||
let st = s.doc()?;
|
||||
let source = bool_or(p, "source", false);
|
||||
let tolerance = number_or(p, "tolerance", 3.0, "shaper.select")?;
|
||||
if !tolerance.is_finite() || !(0.0..=1000.0).contains(&tolerance) {
|
||||
return Err(bad("shaper.select", "invalid tolerance"));
|
||||
}
|
||||
// In Construction mode a source pick stays in the group whose source shapes are isolated.
|
||||
let isolated =
|
||||
st.isolation.and_then(|i| st.doc.parent_of(i)).filter(|g| st.doc.node(*g).and_then(source_group).is_some_and(|s| st.isolation == Some(s.id)));
|
||||
let Some(group) = group_at(&st.doc, point, source, isolated.filter(|_| source)) else { return Ok(json!({"id": null})) };
|
||||
// With a group or one of its faces selected, a click on one of its original shapes' outlines
|
||||
// enters Construction mode with the topmost such shape selected, wherever the outline lies.
|
||||
let outline = if source { None } else { outline_at(&st.doc, &st.selection, point, tolerance) };
|
||||
let Some(group) = outline.map(|(g, _)| g).or_else(|| group_at(&st.doc, point, source, isolated.filter(|_| source))) else {
|
||||
return Ok(json!({"id": null}));
|
||||
};
|
||||
let node = st.doc.node(group).ok_or(EngineError::NoNode(group))?;
|
||||
let target = if source {
|
||||
let source = source || outline.is_some();
|
||||
let target = if let Some((_, id)) = outline {
|
||||
id
|
||||
} else if source {
|
||||
sources(node)
|
||||
.iter()
|
||||
.rev()
|
||||
|
||||
@@ -4,8 +4,10 @@
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
ArrowAlign, Arrowhead, CharStyle, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer, Unit, WidthProfile,
|
||||
ArrowAlign, Arrowhead, CharStyle, CustomArrowhead, Dash, LineCap, LineJoin, Node, NodeKind, SavedProfile, Scaling, StrokeAlign, StrokeLayer,
|
||||
Unit, WidthProfile,
|
||||
};
|
||||
use vectorcraft_geom::PathData;
|
||||
|
||||
use super::appearance::{ItemTarget, edit_items, item_target};
|
||||
use super::paint::painted;
|
||||
@@ -20,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Stroke Options",
|
||||
["Window", "Stroke"],
|
||||
None,
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
"{weight?: pt, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null (a 0 dash with a round or projecting cap draws dots or squares; a new pattern keeps the current offset and alignment), dashOffset? (exact dashes only), alignDashes?: bool (true: dashes fitted to corners and path ends, every run between them holding whole periods with a dash centred on each corner and end; false, the default for a new pattern: exact lengths), startArrow?, endArrow?: Arrow|ArrowOpen|LineArrow|Barbed|Concave|DoubleArrow|HalfArrowLeft|HalfArrowRight|Chevron|Feather|Swallowtail|Triangle|TriangleOpen|TriangleReverse|TriangleBar|Kite|Leaf|Drop|Circle|CircleOpen|HalfCircle|Oval|Target|Square|SquareOpen|Tag|TagOpen|Diamond|DiamondOpen|Hexagon|HexagonOpen|Star|Cross|Plus|Bar|DoubleBar|DotOnBar|Slash|DoubleSlash|Bracket|Fork|null|{custom: name} (a custom arrowhead of the arrowheads file, stroke.arrowheads.list)|{Custom: {name, path}} (a custom head with its art, as document.inspect reports one; refused when the path fills no area, parts thinner than a millionth of a head unit counting as none, or has more than 4096 anchors, more than 4096 subpaths or a point more than 64 head units from the tip) (HalfArrowLeft/Right: one barb, left or right of the direction the head points), arrowAlign?: \"extend\" (tip past the end point, default)|\"tip\" (tip on the end point; the stroke is shortened), profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\"|\"pinch\"|\"teardrop\"|\"wave\"|a saved profile's name (stroke.widthProfile.list), widthPoints?, gradientMode? (as document.inspect reports them in strokeOptions, so those read back unchanged; ignored here: a custom profile is set with the Width tool, gradientMode with paint.editGradient), item?: stroke item index|null (omitted: the Appearance panel's active item if it is a stroke, else the top stroke, created when missing), ids?|id?, target?: \"object\"|\"contents\", aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} Without a stroke item, type takes weight, cap, join, miterLimit, align (around its glyph outlines; the type stays live) and the dash options as its characters' stroke (every run; text.setRangeStyle `strokeOptions` styles a range), and images and symbol instances (also in groups) are left alone. With nothing selected (and no ids) they set up the next object drawn (appearance.newArt; weight always does) → {objects: how many objects took the stroke options}",
|
||||
has_doc,
|
||||
stroke_set
|
||||
),
|
||||
@@ -242,6 +244,34 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
|
||||
// A custom head by name, from the arrowheads file (stroke.arrowheads.list).
|
||||
Some(Value::Object(o)) if o.len() == 1 && o.contains_key("custom") => {
|
||||
let Some(Value::String(name)) = o.get("custom") else { return Err(bad(C, format!("{k}: custom must be a name"))) };
|
||||
let head = s.arrowheads.get(name).ok_or_else(|| bad(C, format!("no custom arrowhead {name} (see stroke.arrowheads.list)")))?;
|
||||
Ok(Some(Some(Arrowhead::Custom(head))))
|
||||
}
|
||||
// A custom head with its art: an outline the head would drop is refused, not drawn empty.
|
||||
Some(Value::Object(o)) if o.contains_key("Custom") => {
|
||||
let raw = o.get("Custom").and_then(|c| c.get("path")).cloned().unwrap_or(Value::Null);
|
||||
let path: PathData = serde_json::from_value(raw).map_err(|e| bad(C, format!("{k}: bad custom arrowhead path: {e}")))?;
|
||||
// The region the outline fills (non-zero rule), down to slivers a millionth of a
|
||||
// head unit thin. Measured only once `fits` has bounded the anchors.
|
||||
let fills = |p: &PathData| {
|
||||
let area = vectorcraft_pathops::unite_all_with(&[(p, vectorcraft_geom::FillRule::NonZero)], 1e-6);
|
||||
area.subpaths.iter().map(|sp| sp.area().abs()).sum::<f64>() > 1e-12
|
||||
};
|
||||
if !CustomArrowhead::fits(&path) || !fills(&path) {
|
||||
let (most, far) = (CustomArrowhead::MAX_ANCHORS, CustomArrowhead::MAX_UNITS);
|
||||
return Err(bad(
|
||||
C,
|
||||
format!(
|
||||
"{k}: a custom arrowhead's path needs an area to fill and at most {most} anchors and {most} subpaths, each anchor within {far} head units of its tip"
|
||||
),
|
||||
));
|
||||
}
|
||||
let head = p.get(k).cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value::<Arrowhead>(head).map(|a| Some(Some(a))).map_err(|e| bad(C, format!("{k}: bad custom arrowhead: {e}")))
|
||||
}
|
||||
Some(v) => serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad(C, format!("unknown arrowhead {v}"))),
|
||||
}
|
||||
};
|
||||
@@ -258,11 +288,11 @@ fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let set = |st: &mut StrokeLayer| {
|
||||
change.apply(st);
|
||||
if let Some(a) = sa {
|
||||
st.start_arrow = a;
|
||||
if let Some(a) = &sa {
|
||||
st.start_arrow = a.clone();
|
||||
}
|
||||
if let Some(a) = ea {
|
||||
st.end_arrow = a;
|
||||
if let Some(a) = &ea {
|
||||
st.end_arrow = a.clone();
|
||||
}
|
||||
if let Some(a) = arrow_align {
|
||||
st.arrow_align = a;
|
||||
|
||||
@@ -85,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy to User Defined",
|
||||
["Window", "Swatch Libraries"],
|
||||
None,
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path, copied: false when nothing was written}",
|
||||
"{library: id or name of a loaded library (category \"loaded\", swatch.library.load)} copy it into the user library folder of the desktop app, whose libraries VectorCraft lists as User Defined every time it starts (a loaded library is listed until the app quits): a library file as it is, the swatches of a document or a library loaded from data as a .vcswatches file. A name the folder already has gets a number (\"Brand 2.ase\") and no file is replaced; when the folder already holds the same file, that file is used. The library is then listed as User Defined only, and until the app quits commands given its loaded id use the copy; a User Defined library comes back as it is → {library: its User Defined id, name, count, path (left out when it is outside the read roots), copied: false when nothing was written}",
|
||||
has_user_folder,
|
||||
copy_to_user
|
||||
),
|
||||
@@ -368,7 +368,9 @@ impl<L: LibraryFile> Libraries<L> {
|
||||
.ok_or_else(|| bad(cmd, format!("`{id}` isn't a loaded library")))?;
|
||||
let (file, bytes) = match source.filter(|p| L::EXTS.contains(&extension(p).as_str())) {
|
||||
Some(p) => {
|
||||
// Outside the request's read roots: the error names no path.
|
||||
// The file's bytes are read: outside the request's read roots, the error names no
|
||||
// path. A document's swatches (the arm below) are written from the loaded library,
|
||||
// and the document is not read again.
|
||||
if !readable(&p) {
|
||||
return Err(bad(cmd, "the library's file is outside the read roots"));
|
||||
}
|
||||
@@ -469,19 +471,34 @@ fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
/// The file name a concealed library or path is shown by: the last part of `path` as this platform
|
||||
/// splits a path, so that outside Windows a `\` stays part of the name. Another platform's absolute
|
||||
/// path ([`crate::file_access::written_elsewhere`]), and a path this platform gives no file name (one
|
||||
/// that ends in `..`), are split after the last `/` or `\` instead. `path` itself when it has no such part.
|
||||
pub fn concealed_file_name(path: &str) -> String {
|
||||
let native = (!crate::file_access::written_elsewhere(path)).then(|| std::path::Path::new(path).file_name()).flatten();
|
||||
native.map_or_else(|| path.rsplit(['/', '\\']).find(|part| !part.is_empty()).unwrap_or(path).to_string(), |n| n.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// May the request being served read `path` ([`crate::file_access::check_read`])? Always, without
|
||||
/// roots in force.
|
||||
/// roots in force. Another platform's absolute path ([`crate::file_access::written_elsewhere`]) is
|
||||
/// never readable under a read root.
|
||||
fn readable(path: &str) -> bool {
|
||||
crate::file_access::check_read(path).is_ok()
|
||||
!crate::file_access::written_elsewhere(path) && crate::file_access::check_read(path).is_ok()
|
||||
}
|
||||
|
||||
/// The loaded library ids (`loaded/<path>`) whose file the request being served may not read, each
|
||||
/// with the id a reply shows instead: `loaded/<file name>`, as a library loaded without a path is
|
||||
/// named. The ids are those of the session's swatch, brush, symbol and graphic style libraries, then
|
||||
/// `others` (ids the UI state refers to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. The
|
||||
/// session's libraries are numbered first, in the sorted order of their full ids, so a library shows
|
||||
/// the same id whatever `others` holds; then `others`, in the same order. Without roots in force, none.
|
||||
/// named. The file name is [`concealed_file_name`], and a path saved as another platform's absolute
|
||||
/// path counts as one the request may not read. The ids are those of the
|
||||
/// session's swatch, brush, symbol and graphic style libraries, then `others` (ids the UI state refers
|
||||
/// to). When another id already is or shows that id, the next free
|
||||
/// one of `loaded/<file name> (2)`, `(3)` and so on is shown, so that two libraries stay two. A library
|
||||
/// keeps the id it was first shown under for the rest of the session, so an id a client was given
|
||||
/// finds the same library after another one with the same file name is loaded; only a library that
|
||||
/// shows that id itself (one loaded later without a file under that name) moves it to the next free
|
||||
/// one. Libraries shown for the first time are numbered the session's first, in the sorted order of
|
||||
/// their full ids, then `others`, in the same order. Without roots in force, none.
|
||||
pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = String>) -> Vec<(String, String)> {
|
||||
if crate::file_access::current().is_none() {
|
||||
return vec![];
|
||||
@@ -493,7 +510,7 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
let mut taken = BTreeSet::new();
|
||||
for ids in [session, others] {
|
||||
for id in ids {
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (file_name(path), path));
|
||||
let name = id.strip_prefix("loaded/").filter(|path| !path.is_empty()).map(|path| (concealed_file_name(path), path));
|
||||
match name {
|
||||
Some((name, path)) if name != path && !readable(path) => hidden.push((id, name)),
|
||||
_ => {
|
||||
@@ -502,16 +519,32 @@ pub fn concealed_library_ids(s: &Session, others: impl IntoIterator<Item = Strin
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut assigned = s.shown_library_ids.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut kept = vec![None; hidden.len()];
|
||||
for (slot, (id, _)) in kept.iter_mut().zip(&hidden) {
|
||||
if let Some(shown) = assigned.get(id).filter(|shown| !taken.contains(*shown)) {
|
||||
taken.insert(shown.clone());
|
||||
*slot = Some(shown.clone());
|
||||
}
|
||||
}
|
||||
// A new id is never one given to another library earlier in the session.
|
||||
let mut given: BTreeSet<String> = assigned.values().cloned().collect();
|
||||
hidden
|
||||
.into_iter()
|
||||
.map(|(id, name)| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
.zip(kept)
|
||||
.map(|((id, name), kept)| {
|
||||
let shown = kept.unwrap_or_else(|| {
|
||||
let mut shown = format!("loaded/{name}");
|
||||
let mut n = 2;
|
||||
while taken.contains(&shown) || given.contains(&shown) {
|
||||
shown = format!("loaded/{name} ({n})");
|
||||
n += 1;
|
||||
}
|
||||
taken.insert(shown.clone());
|
||||
given.insert(shown.clone());
|
||||
assigned.insert(id.clone(), shown.clone());
|
||||
shown
|
||||
});
|
||||
(id, shown)
|
||||
})
|
||||
.collect()
|
||||
@@ -746,8 +779,12 @@ fn copy_to_user(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"user" => (info, lib, false),
|
||||
_ => return Err(bad(C, format!("`{}` is built in and always listed", info.name))),
|
||||
};
|
||||
let path = s.swatch_libraries.shown_path(&info.id);
|
||||
Ok(json!({"library": info.id, "name": info.name, "count": lib.len(), "path": path, "copied": copied}))
|
||||
let mut out = json!({"library": info.id, "name": info.name, "count": lib.len(), "copied": copied});
|
||||
// As the saves with `user: true`: no `path` when the request may not read the file.
|
||||
if let Some(path) = s.swatch_libraries.shown_path(&info.id) {
|
||||
out["path"] = json!(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The library swatches `names` stand for, each once, with the colour group it goes into (`None`:
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -243,6 +243,7 @@ pub fn para_attrs(v: &ParaStyle) -> Map<String, Value> {
|
||||
("burasagari", json!(v.burasagari)),
|
||||
("kinsoku", json!(v.kinsoku)),
|
||||
("kinsoku_type", json!(v.kinsoku_type)),
|
||||
("bunri_kinshi", json!(v.bunri_kinshi)),
|
||||
("roman_hanging", json!(v.roman_hanging)),
|
||||
("kashida", json!(v.kashida)),
|
||||
("korean_line_break_anywhere", json!(v.korean_line_break_anywhere)),
|
||||
|
||||
@@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
|
||||
let cached = t.cached_bounds.unwrap();
|
||||
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
|
||||
}
|
||||
|
||||
/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside
|
||||
/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames.
|
||||
fn split_story() -> (Session, [u64; 3]) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
|
||||
let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3);
|
||||
let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::<Vec<_>>().join("\n");
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap();
|
||||
for id in [b, c] {
|
||||
assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph");
|
||||
}
|
||||
(s, [a, b, c])
|
||||
}
|
||||
|
||||
/// Each frame's text, paragraph styles and whether it continues a paragraph.
|
||||
fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec<vectorcraft_doc::ParaStyle>, bool)> {
|
||||
frames
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
|
||||
(t.plain_text(), t.paragraph_styles(), t.first_para_continues)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the
|
||||
/// later frame. Each takes indents of its own, wide enough to move text between the frames.
|
||||
fn indent_entries(s: &Session, frames: &[u64]) -> Vec<Value> {
|
||||
let mut out = vec![];
|
||||
for (k, id) in frames.iter().enumerate() {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
|
||||
let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues);
|
||||
let plain = t.plain_text();
|
||||
let pieces: Vec<&str> = plain.split('\n').collect();
|
||||
let mut start = 0;
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 < pieces.len() || !next_continues {
|
||||
let n = out.len() as f64;
|
||||
out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n}));
|
||||
}
|
||||
start += piece.len() + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for
|
||||
/// each paragraph would, the story's last paragraph first, with a paragraph split between frames
|
||||
/// set once; the story flows again once, and Undo and Redo take the whole change as one step.
|
||||
#[test]
|
||||
fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() {
|
||||
let (mut one, frames) = split_story();
|
||||
let (mut each, _) = split_story();
|
||||
let entries = indent_entries(&one, &frames);
|
||||
assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}");
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.rev()
|
||||
.map(|e| {
|
||||
json!({"command": "text.setFormat", "params": {
|
||||
"ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"]
|
||||
}})
|
||||
})
|
||||
.collect();
|
||||
each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap();
|
||||
let start = frames_of(&one, &frames);
|
||||
let undo = one.doc().unwrap().history.undo.len();
|
||||
let copied = COPIED.with(std::cell::Cell::get);
|
||||
let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
|
||||
assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once");
|
||||
// The first frame holds only the start of the paragraph that runs on into the second.
|
||||
assert_eq!(r, json!({"ids": frames[1..]}));
|
||||
assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1);
|
||||
let set = frames_of(&one, &frames);
|
||||
assert_ne!(set, start);
|
||||
assert_eq!(set, frames_of(&each, &frames));
|
||||
one.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(frames_of(&one, &frames), start);
|
||||
one.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(frames_of(&one, &frames), set);
|
||||
}
|
||||
|
||||
/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again
|
||||
/// once.
|
||||
#[test]
|
||||
fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
|
||||
let story = vec!["x"; 5000].join("\n");
|
||||
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap();
|
||||
let entries: Vec<Value> = [a, b]
|
||||
.into_iter()
|
||||
.flat_map(|id| {
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap();
|
||||
let count = t.paragraph_count();
|
||||
(0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64}))
|
||||
})
|
||||
.collect();
|
||||
assert!(entries.len() >= 5000, "{}", entries.len());
|
||||
let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get));
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1));
|
||||
let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap();
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0));
|
||||
}
|
||||
|
||||
/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and
|
||||
/// a bad one is refused before anything changes.
|
||||
#[test]
|
||||
fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() {
|
||||
let (mut s, [a, b, c]) = split_story();
|
||||
let start = frames_of(&s, &[a, b, c]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let copied = COPIED.with(std::cell::Cell::get);
|
||||
let r =
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]}));
|
||||
assert_eq!(r.unwrap(), json!({"ids": [a, c]}));
|
||||
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change");
|
||||
let paragraph = text(&s, c).find('\n').unwrap() + 1;
|
||||
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
let ok = json!({"id": c, "start": paragraph, "leftIndent": 30});
|
||||
for bad in [
|
||||
json!({"paragraphIndents": []}),
|
||||
json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}),
|
||||
json!({"paragraphIndents": [ok, 3]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}),
|
||||
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}),
|
||||
json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}),
|
||||
] {
|
||||
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
|
||||
assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}");
|
||||
}
|
||||
// The same entry twice is one change.
|
||||
s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||||
assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Character",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
"{ids?|id?, font?, style? (font alone: the style is kept when the new family has it, else its closest), size?: pt 0.1..1296 (outside it: an error), leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"auto\" (the start of each paragraph's direction)|\"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour|\"none\", stroke?: colour|\"none\" (characters without a stroke weight get 1 pt; #rrggbbaa and #rgba are refused in both), keepModel?: bool (fill and stroke, as paint.setFill), strokeWidth?: pt (the character stroke's weight, in document points as stroke.set), features?: [\"dlig\", \"-liga\", \"salt=2\", …] OpenType (swsh, ss01–ss20, salt, titl, pnum, lnum, sups, subs, numr, dnom and aalt take the font's alternate number as `tag=N`, N 0–65535: \"salt=2\" draws the second alternate of each glyph that has one, \"salt\" is \"salt=1\", \"-salt\" and \"salt=0\" are off), featureChanges?: [\"tnum\", \"-onum\", \"salt=2\", …] (OpenType features turned on, or with - off, on top of each character's own, a value replacing the feature's last one; its other features are kept), start?: byte, end?: byte} (size/leading are document points after object scaling; with a range: the character attributes style that range and justify the paragraphs it touches; without: all the text) → {ids, overflow?: [id…] (area type and type on a path whose text no longer fits; left out when all fits), warning?: text (the font or style set isn't available: its type is drawn in the fallback font or the family's closest style)}",
|
||||
has_doc,
|
||||
set_style
|
||||
),
|
||||
@@ -715,6 +715,20 @@ pub(crate) fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<Node
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
/// The type sizes a command takes, in points.
|
||||
pub(crate) const TYPE_SIZES: (f64, f64) = (0.1, 1296.0);
|
||||
|
||||
/// A type size `size` that a command was given: an error naming the range when it is outside
|
||||
/// [`TYPE_SIZES`].
|
||||
pub(crate) fn given_type_size(size: f64, cmd: &str) -> Result<f64> {
|
||||
let (lo, hi) = TYPE_SIZES;
|
||||
if !(lo..=hi).contains(&size) {
|
||||
let key = "size";
|
||||
return Err(bad(cmd, format!("`{key}` must be from {lo} to {hi}")));
|
||||
}
|
||||
Ok(size)
|
||||
}
|
||||
|
||||
/// Clamp in the units shown to the user, then compensate the text's affine. Clamping
|
||||
/// the local result would make small sizes impossible after enlarging an object.
|
||||
pub(crate) fn local_type_value(t: &TextObject, value: f64, limits: (f64, f64), horizontal: bool, cmd: &str) -> Result<f64> {
|
||||
@@ -820,10 +834,7 @@ fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "text.setStyle";
|
||||
let font = text_param(p, "font", C)?.map(str::to_string);
|
||||
let style = text_param(p, "style", C)?.map(str::to_string);
|
||||
let size = length_given(p, "size", C)?;
|
||||
if size.is_some_and(|v| v <= 0.0) {
|
||||
return Err(bad(C, "size must be positive"));
|
||||
}
|
||||
let size = length_given(p, "size", C)?.map(|v| given_type_size(v, C)).transpose()?;
|
||||
let leading = match p.get("leading") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None),
|
||||
|
||||
@@ -440,6 +440,59 @@ fn korean_line_break_anywhere_is_set_per_paragraph_and_saved_only_when_off() {
|
||||
assert!(!text(&s).para_at(0).korean_line_break_anywhere && !text(&s).para_at(1).korean_line_break_anywhere);
|
||||
}
|
||||
|
||||
/// Paragraph panel menu › Bunri-Kinshi: off for new type and for a paragraph saved before it, and
|
||||
/// saved only when on; `text.setFormat {bunriKinshi}` sets it per paragraph in one undo step and
|
||||
/// refuses a value that is not true or false, kinsoku None keeps it, and paragraph styles carry it.
|
||||
#[test]
|
||||
fn bunri_kinshi_is_set_per_paragraph_and_saved_only_when_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 10, "size": 20, "text": "あ——い\nう……え"})).unwrap()["id"].as_u64().unwrap();
|
||||
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => (**t).clone(),
|
||||
_ => panic!("text"),
|
||||
};
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi, "new type has it off");
|
||||
assert!(serde_json::to_value(text(&s).para_at(0)).unwrap().get("bunri_kinshi").is_none());
|
||||
let old: vectorcraft_doc::ParaStyle = serde_json::from_value(json!({"justify": "Left"})).unwrap();
|
||||
assert!(!old.bunri_kinshi, "saved before the option: off");
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
// Only the second paragraph.
|
||||
let second = "あ——い\n".len();
|
||||
s.execute("text.setFormat", &json!({"bunriKinshi": true, "start": second, "end": second})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
|
||||
let saved = serde_json::to_value(text(&s).para_at(1)).unwrap();
|
||||
assert_eq!(saved["bunri_kinshi"], true);
|
||||
let back: vectorcraft_doc::ParaStyle = serde_json::from_value(saved).unwrap();
|
||||
assert!(back.bunri_kinshi);
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
|
||||
assert!(!back.para_at(0).bunri_kinshi && back.para_at(1).bunri_kinshi, "the file keeps it per paragraph");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
for bad in [json!("yes"), json!(1)] {
|
||||
assert!(s.execute("text.setFormat", &json!({"ids": [id], "bunriKinshi": bad, "kinsoku": "none"})).is_err(), "{bad}");
|
||||
}
|
||||
assert!(s.execute("text.setRangeStyle", &json!({"id": id, "bunriKinshi": false})).is_err());
|
||||
assert!(text(&s).para_at(1).bunri_kinshi && text(&s).para_at(1).kinsoku != vectorcraft_doc::Kinsoku::None, "a refused call changes nothing");
|
||||
// Kinsoku None keeps the setting, without effect, and turning kinsoku back on restores it.
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "none"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("text.setFormat", &json!({"ids": [id], "kinsoku": "hard"})).unwrap();
|
||||
assert!(text(&s).para_at(1).bunri_kinshi);
|
||||
// A paragraph style carries it.
|
||||
s.execute("paraStyle.new", &json!({"name": "Pairs", "attrs": {"bunri_kinshi": true}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Pairs", "id": id})).unwrap();
|
||||
assert!(text(&s).para_at(0).bunri_kinshi && text(&s).para_at(1).bunri_kinshi);
|
||||
s.execute("paraStyle.new", &json!({"name": "Split", "attrs": {"bunri_kinshi": false}})).unwrap();
|
||||
s.execute("paraStyle.apply", &json!({"name": "Split", "id": id})).unwrap();
|
||||
assert!(!text(&s).para_at(0).bunri_kinshi && !text(&s).para_at(1).bunri_kinshi);
|
||||
}
|
||||
|
||||
/// Paragraph panel › Insert Kashidas: `text.setFormat {kashida}` sets it per paragraph in one
|
||||
/// undo step, the paragraph is saved with it only when it is not None and opens with it, an
|
||||
/// unknown value changes nothing, and paragraph styles carry it.
|
||||
@@ -2156,3 +2209,127 @@ fn set_path_handle_reshapes_type_on_a_path() {
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor");
|
||||
assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type");
|
||||
}
|
||||
|
||||
/// `text.setFormat` refuses a number or switch given as another type, naming the key, and
|
||||
/// changes nothing; null leaves the attribute as it is.
|
||||
#[test]
|
||||
fn set_format_refuses_a_param_of_the_wrong_type() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||||
let before = state(&s);
|
||||
for key in [
|
||||
"baselineShift",
|
||||
"hScale",
|
||||
"vScale",
|
||||
"rotation",
|
||||
"leftIndent",
|
||||
"rightIndent",
|
||||
"firstLineIndent",
|
||||
"spaceBefore",
|
||||
"spaceAfter",
|
||||
"wordsLongerThan",
|
||||
"afterFirst",
|
||||
"beforeLast",
|
||||
"hyphenLimit",
|
||||
"hyphenationZone",
|
||||
] {
|
||||
for wrong in [json!("30"), json!(true), json!([30])] {
|
||||
let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}");
|
||||
}
|
||||
}
|
||||
for key in ["underline", "allCaps", "hyphenate", "noBreak"] {
|
||||
let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}");
|
||||
}
|
||||
let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`firstLineIndent` must be a number"), "{err}");
|
||||
assert!(state(&s) == before, "a refused call changes nothing and records no undo step");
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap();
|
||||
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0));
|
||||
}
|
||||
|
||||
/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has
|
||||
/// nothing to change, and records no edit, no undo step and no new revision.
|
||||
#[test]
|
||||
fn set_format_with_only_null_values_changes_nothing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len(), st.revision)
|
||||
};
|
||||
let before = state(&s);
|
||||
for params in [
|
||||
json!({"leftIndent": null}),
|
||||
json!({"leftIndent": null, "firstLineIndent": null, "underline": null}),
|
||||
json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}),
|
||||
json!({"mojikumi": null}),
|
||||
json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}),
|
||||
json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}),
|
||||
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}),
|
||||
] {
|
||||
let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string();
|
||||
assert!(err.contains("nothing to change"), "{params}: {err}");
|
||||
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
|
||||
}
|
||||
// A null beside a value leaves only its own attribute.
|
||||
let para = |d: &vectorcraft_doc::Document| {
|
||||
let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
(t.para_at(0).mojikumi, t.para_at(0).kinsoku)
|
||||
};
|
||||
let mojikumi = para(&before.0).0;
|
||||
s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap();
|
||||
assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step");
|
||||
}
|
||||
|
||||
/// `text.setFormat` with the values the text already has succeeds and names its objects, but
|
||||
/// records no edit, no undo step and no new revision.
|
||||
#[test]
|
||||
fn set_format_with_the_current_values_records_nothing() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.history.undo.len(), st.revision)
|
||||
};
|
||||
let before = state(&s);
|
||||
let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
let hyphenate = t.para_at(0).hyphenate;
|
||||
for params in [
|
||||
json!({"leftIndent": 0, "firstLineIndent": 0}),
|
||||
json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}),
|
||||
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}),
|
||||
json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}),
|
||||
] {
|
||||
let out = s.execute("text.setFormat", ¶ms).unwrap();
|
||||
assert_eq!(out["ids"], json!([id]), "{params}");
|
||||
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
|
||||
}
|
||||
// A call that changes a value is one undo step; the same call again records nothing.
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
|
||||
let changed = state(&s);
|
||||
assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision");
|
||||
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
|
||||
assert!(state(&s) == changed, "the same value again records nothing");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc == before.0, "one undo restores the text");
|
||||
// The anti-aliasing method is compared the same way.
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap();
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
|
||||
let strong = state(&s);
|
||||
assert!(strong.1 > before.1, "a new method is an undo step");
|
||||
let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
|
||||
assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong);
|
||||
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
|
||||
assert!(state(&s) == strong, "the same method again records nothing");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user