mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 09:33:54 +00:00
M2.1: integrate current main and Pen Cut regression (#3380)
This commit is contained in:
Generated
+40
-4
@@ -1442,7 +1442,7 @@ dependencies = [
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4438,11 +4438,29 @@ dependencies = [
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.15.3",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
"usvg 0.45.1",
|
||||
"zune-jpeg 0.4.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "resvg"
|
||||
version = "0.48.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"gif 0.14.2",
|
||||
"image-webp",
|
||||
"log",
|
||||
"pico-args",
|
||||
"rgb",
|
||||
"svgtypes 0.16.1",
|
||||
"tiny-skia 0.12.0",
|
||||
"usvg 0.48.1",
|
||||
"zune-jpeg 0.5.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.17.2"
|
||||
@@ -4727,7 +4745,7 @@ checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
"tiny-skia 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5593,6 +5611,21 @@ dependencies = [
|
||||
"tiny-skia-path 0.11.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"png 0.18.1",
|
||||
"tiny-skia-path 0.12.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
@@ -6235,7 +6268,9 @@ version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"resvg 0.48.1",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-geom",
|
||||
@@ -6303,6 +6338,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"ruzstd",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
@@ -6491,7 +6527,7 @@ dependencies = [
|
||||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"resvg",
|
||||
"resvg 0.45.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simplecss",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
//! vectorcraft-cli run [--in FILE] [--cmd id [--params '{json}'] | --commands FILE|-]... [--export out.svg]... [--scale 2] [--ppi 300]
|
||||
//! vectorcraft-cli commands
|
||||
@@ -72,6 +73,11 @@ const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli --safe-mode SUBCOMMAND ...
|
||||
Run any subcommand without plug-ins: none loads or runs, and plug-in commands report that
|
||||
Safe Mode is on. Plug-ins stay installed. `--safe-mode mcp` serves a headless session and
|
||||
does not take --connect.
|
||||
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
@@ -128,12 +134,17 @@ USAGE:
|
||||
Render FILE (any readable format) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N] [--deep]
|
||||
vectorcraft-cli perf [--paths N] [--deep] [--native-io [--artboards 1..4]]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder, handle
|
||||
and segment drags) on a synthetic N-path document (default 50000). Exits non-zero if a
|
||||
budget is exceeded, unless the report says the machine is busy. --deep also times work with
|
||||
no budget yet and prints memory use (the working set on Windows), with the peak so far on
|
||||
Windows and Linux.
|
||||
--native-io instead measures the application's native save/open commands (one warmup,
|
||||
five samples, no disk I/O), including preview, PDF, compression and API base64 costs.
|
||||
It reports the default and explicit PDF opt-out, with and without preview/compression;
|
||||
--artboards repeats the same artwork
|
||||
on 1 to 4 overlapping artboards. This mode allows at most 50000 paths.
|
||||
";
|
||||
|
||||
/// The usage text plus the formats `document.open` reads and `document.export` writes.
|
||||
@@ -175,12 +186,18 @@ fn main() -> ExitCode {
|
||||
#[cfg(target_os = "macos")]
|
||||
mac_fonts::install();
|
||||
prefs_dir::install_fonts();
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
// Before any subcommand attaches the plug-in folder.
|
||||
let safe_mode = args.first().is_some_and(|a| a == "--safe-mode");
|
||||
if safe_mode {
|
||||
args.remove(0);
|
||||
vectorcraft_engine::cmd::plugin::start_in_safe_mode();
|
||||
}
|
||||
let r = match args.first().map(String::as_str) {
|
||||
// `commands` and `info` take no options, so `--help` or `-h` anywhere after them prints the
|
||||
// usage. The other subcommands check for it themselves.
|
||||
Some("commands" | "info") if help_requested(&args[1..], &[]) => help(),
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("mcp") => mcp(&args[1..], safe_mode),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
Some("convert") => convert(&args[1..]),
|
||||
@@ -213,7 +230,9 @@ fn with_stores(mut h: Headless) -> Headless {
|
||||
h
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
/// `mcp`. With `--safe-mode` (`safe_mode`) it serves a headless session: a running app loads its own
|
||||
/// plug-ins, so `--connect` is refused and the default does not connect.
|
||||
fn mcp(args: &[String], safe_mode: bool) -> Result<(), String> {
|
||||
if help_requested(args, &["--connect", "--automation-read-root", "--automation-write-root"]) {
|
||||
return help();
|
||||
}
|
||||
@@ -240,6 +259,9 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
if safe_mode && connect.is_some() {
|
||||
return Err("--safe-mode runs a headless session without plug-ins; a running app loads its own: start it with `vectorcraft --safe-mode --control <port>`".into());
|
||||
}
|
||||
let read: Vec<&Path> = read_roots.iter().map(Path::new).collect();
|
||||
let write: Vec<&Path> = write_roots.iter().map(Path::new).collect();
|
||||
let roots = AutomationRoots::new(&read, &write)?;
|
||||
@@ -252,7 +274,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
file_access::confine_process(roots.clone());
|
||||
eprintln!("vectorcraft-cli: files confined: {}", roots.describe());
|
||||
Box::new(Headless::with_document().with_automation_roots(Some(roots)))
|
||||
} else if headless {
|
||||
} else if headless || safe_mode {
|
||||
Box::new(with_stores(Headless::with_document()))
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
|
||||
@@ -64,6 +64,61 @@ fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
v.get(v.len() / 2).copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// Exercise the application's native save/open commands, including previews, PDF output,
|
||||
/// compression and API base64 conversion. Session setup and validation are outside timing.
|
||||
/// Five measured runs follow one warmup; no filesystem or GPU window is involved.
|
||||
fn native_io(doc: &Document) -> Result<(), String> {
|
||||
for (label, compatible, preview) in [
|
||||
("default options", None, false),
|
||||
("PDF disabled", Some(false), false),
|
||||
("preview+gzip", None, true),
|
||||
("preview+gzip, PDF disabled", Some(false), true),
|
||||
] {
|
||||
let mut session = vectorcraft_engine::Session::new();
|
||||
session.add_document(doc.clone(), None);
|
||||
let mut params = json!({"format":"vectorcraft","modified":null,"preview":preview,"compress":preview});
|
||||
if let Some(value) = compatible {
|
||||
params["pdfCompatible"] = json!(value);
|
||||
}
|
||||
let (mut saves, mut opens) = (Vec::new(), Vec::new());
|
||||
let (mut size, mut has_pdf, mut compressed) = (0, false, false);
|
||||
for run in 0..6 {
|
||||
let start = Instant::now();
|
||||
let saved = session.execute("file.saveAs", ¶ms).map_err(|e| e.to_string())?;
|
||||
let save_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let data = saved["dataBase64"].as_str().ok_or("native save returned no dataBase64")?;
|
||||
let bytes = vectorcraft_format::base64_decode(data).ok_or("native save returned invalid base64")?;
|
||||
size = bytes.len();
|
||||
has_pdf = vectorcraft_format::pdf_content(&bytes).is_some();
|
||||
if compatible == Some(false) && has_pdf {
|
||||
return Err("native save ignored the explicit PDF opt-out".into());
|
||||
}
|
||||
compressed = vectorcraft_format::is_compressed(&bytes);
|
||||
if compressed != preview || vectorcraft_format::preview(&bytes).is_some() != preview {
|
||||
return Err("native save did not apply the requested preview/compression options".into());
|
||||
}
|
||||
let args = json!({"name":"perf.vectorcraft","dataBase64":data});
|
||||
let mut opened = vectorcraft_engine::Session::new();
|
||||
let start = Instant::now();
|
||||
opened.execute("document.open", &args).map_err(|e| e.to_string())?;
|
||||
let open_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
if opened.doc().map_err(|e| e.to_string())?.doc.artboards.len() != doc.artboards.len() {
|
||||
return Err("native round trip changed the artboard count".into());
|
||||
}
|
||||
if run > 0 {
|
||||
saves.push(save_ms);
|
||||
opens.push(open_ms);
|
||||
}
|
||||
}
|
||||
saves.sort_by(f64::total_cmp);
|
||||
opens.sort_by(f64::total_cmp);
|
||||
let save = saves.get(2).copied().ok_or("no save measurements")?;
|
||||
let open = opens.get(2).copied().ok_or("no open measurements")?;
|
||||
outln!(" native {label}: save {save:.2} ms, open {open:.2} ms, {size} bytes, PDF {has_pdf}, compressed {compressed}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
#[cfg(not(windows))]
|
||||
fn load_average() -> Option<f64> {
|
||||
@@ -393,19 +448,36 @@ fn deep(n: usize) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if crate::help_requested(args, &["--paths"]) {
|
||||
if crate::help_requested(args, &["--paths", "--artboards"]) {
|
||||
return crate::help();
|
||||
}
|
||||
let mut n = 50_000usize;
|
||||
let mut want_deep = false;
|
||||
let mut want_native = false;
|
||||
let mut artboards = None::<usize>;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--deep" => want_deep = true,
|
||||
"--native-io" => want_native = true,
|
||||
"--artboards" => {
|
||||
artboards =
|
||||
Some(it.next().and_then(|v| v.parse().ok()).filter(|n| (1..=4).contains(n)).ok_or("--artboards needs a number from 1 to 4")?)
|
||||
}
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
if want_native && n > 50_000 {
|
||||
return Err("--native-io allows at most 50000 paths".into());
|
||||
}
|
||||
if want_native && want_deep {
|
||||
return Err("--native-io and --deep are separate measurement modes".into());
|
||||
}
|
||||
if artboards.is_some() && !want_native {
|
||||
return Err("--artboards requires --native-io".into());
|
||||
}
|
||||
let artboards = artboards.unwrap_or(1);
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
// The figure is the 1-minute load average, or on Windows the cores busy over one second.
|
||||
#[cfg(not(windows))]
|
||||
@@ -418,8 +490,20 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
outln!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n)?;
|
||||
let mut doc = synthetic(n)?;
|
||||
outln!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
if want_native {
|
||||
let first = doc.artboards.first().cloned().ok_or("synthetic document has no artboard")?;
|
||||
// Overlapping artboards intentionally repeat the same paths on every PDF page.
|
||||
for index in 1..artboards {
|
||||
let mut page = first.clone();
|
||||
page.id = first.id.checked_add(index as u32).ok_or("artboard id overflow")?;
|
||||
page.name = format!("Perf {}", index + 1);
|
||||
doc.artboards.push(page);
|
||||
}
|
||||
outln!(" application native I/O: {artboards} overlapping artboard(s), one warmup + five samples; medians; no disk I/O");
|
||||
return native_io(&doc);
|
||||
}
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
@@ -513,6 +597,24 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_io_exercises_the_application_save_and_open_commands() {
|
||||
native_io(&synthetic(3).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_io_rejects_conflicting_modes_and_excessive_workloads() {
|
||||
for (args, expected) in [
|
||||
(vec!["--native-io", "--deep"], "separate measurement modes"),
|
||||
(vec!["--native-io", "--paths", "50001"], "at most 50000"),
|
||||
(vec!["--native-io", "--artboards", "5"], "1 to 4"),
|
||||
(vec!["--artboards", "1"], "requires --native-io"),
|
||||
(vec!["--artboards", "2"], "requires --native-io"),
|
||||
] {
|
||||
assert!(run(&args.into_iter().map(str::to_owned).collect::<Vec<_>>()).unwrap_err().contains(expected));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn busy_share_leaves_out_idle_time() {
|
||||
// In one second four processors log 40,000,000 ticks of 100 ns of kernel and user time,
|
||||
|
||||
@@ -283,6 +283,22 @@ fn mcp_headless_over_stdio() {
|
||||
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
|
||||
}
|
||||
|
||||
/// `--safe-mode mcp` serves a headless session without plug-ins: it refuses `--connect`, and by
|
||||
/// default it does not connect to an app listening on the default address, which loads its own
|
||||
/// plug-ins.
|
||||
#[test]
|
||||
fn safe_mode_mcp_does_not_connect_to_a_running_app() {
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp", "--connect", "127.0.0.1:9"]).env("VECTORCRAFT_NO_PREFS", "1").output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(!out.status.success() && err.contains("--safe-mode runs a headless session"), "{err}");
|
||||
// Something listening on the default address, as a running app does; a running app or another
|
||||
// program already holding it serves as well.
|
||||
let _app = std::net::TcpListener::bind(vectorcraft_mcp::DEFAULT_ADDR);
|
||||
let out = Command::new(BIN).args(["--safe-mode", "mcp"]).env("VECTORCRAFT_NO_PREFS", "1").stdin(Stdio::null()).output().unwrap();
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(out.status.success() && err.contains("MCP server on stdio (headless)"), "{err}");
|
||||
}
|
||||
|
||||
/// `mcp --automation-read-root` / `--automation-write-root` (#832): the flags are checked before the
|
||||
/// server starts, and the process then reads and writes only inside its roots.
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,7 @@ use super::windows_stylus;
|
||||
use super::{App, VectorcraftApp, file_access, window};
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::{mac_menu, mac_window, open_documents};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
|
||||
/// The parts of [`App::raw_input_hook`] that every window needs, for the OS windows of floating
|
||||
/// panels: a click or Escape that closes the macOS menu bar's open menu, and the pen barrel
|
||||
@@ -113,16 +114,26 @@ impl App {
|
||||
vectorcraft_ui_egui::background::wait_all(&mut self.app);
|
||||
return;
|
||||
}
|
||||
// An app that stays in Safe Mode leaves later launches to start their own app with plug-ins.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
if safe_mode::staying_on(&self.app) {
|
||||
single_instance::resign();
|
||||
}
|
||||
// Files from the Finder and the Dock (macOS) or from later launches (#4892) wait while a file
|
||||
// dialog is open: what asked for it runs again on its document when it answers.
|
||||
if !self.app.file_dialog_open() {
|
||||
// dialog is open: what asked for it runs again on its document when it answers. They also
|
||||
// wait for the choice about a stopped plug-in, so they open after the plug-ins it loads, as
|
||||
// do the command line's files held back for it. Files later launches handed over before the
|
||||
// choice ended in Safe Mode open in a new app with plug-ins.
|
||||
if !self.app.file_dialog_open() && !safe_mode::choosing(&self.app) {
|
||||
let held = std::mem::take(&mut self.held_files);
|
||||
#[cfg(target_os = "macos")]
|
||||
let files = open_documents::take();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let files = single_instance::take();
|
||||
let files = single_instance::take_for_frame(safe_mode::staying_on(&self.app), single_instance::launch_with_plug_ins);
|
||||
if !files.is_empty() {
|
||||
bring_window_forward(ctx);
|
||||
}
|
||||
open_files(&mut self.app, held);
|
||||
open_files(&mut self.app, files);
|
||||
}
|
||||
let (now, zoom) = (ctx.input(|i| i.time), ctx.zoom_factor());
|
||||
@@ -151,13 +162,15 @@ impl App {
|
||||
/// On macOS with accessibility, end the process once the app has saved what it keeps on quitting,
|
||||
/// before eframe tears the window down (#661). AccessKit then gives the window's content view back
|
||||
/// its own class, which AppKit's Touch Bar support was observing it under, and AppKit aborts the
|
||||
/// app as it quits on Macs with a Touch Bar. Nothing after the window is needed on a normal quit.
|
||||
/// app as it quits on Macs with a Touch Bar. On a normal quit, only the launch marker is needed
|
||||
/// after the window, so it is ended here as `main` ends it after the window on other platforms.
|
||||
/// Not while the window is still starting up (`frames` below [`gpu::STARTUP_FRAMES`]): a graphics
|
||||
/// failure then starts the app again on another adapter (#651).
|
||||
#[allow(unused_variables)]
|
||||
fn end_before_teardown(frames: u64) {
|
||||
#[cfg(all(target_os = "macos", feature = "accessibility", feature = "wgpu"))]
|
||||
if frames >= gpu::STARTUP_FRAMES {
|
||||
safe_mode::end_launch();
|
||||
log::info!("quitting");
|
||||
log::logger().flush();
|
||||
std::process::exit(0);
|
||||
@@ -172,6 +185,16 @@ pub(crate) fn bring_window_forward(ctx: &egui::Context) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
|
||||
/// Open the command line's `files` now, or, while the choice about a stopped plug-in is up
|
||||
/// ([`safe_mode::choosing`]), return them to open once it is made, with the plug-ins it loads.
|
||||
pub(crate) fn open_or_hold(app: &mut VectorcraftApp, files: Vec<String>) -> Vec<String> {
|
||||
if safe_mode::choosing(app) {
|
||||
return files;
|
||||
}
|
||||
open_files(app, files);
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Open files handed to the app (command line, macOS Finder and Dock, later launches) as documents. A file that
|
||||
/// can't be opened is reported to the user and on stderr; the others still open.
|
||||
pub(crate) fn open_files(app: &mut VectorcraftApp, files: Vec<String>) {
|
||||
|
||||
@@ -310,7 +310,7 @@ fn pci_ids(device_id: &str) -> Option<(u32, u32)> {
|
||||
let field = |key: &str| {
|
||||
device_id.split(['\\', '&']).find_map(|part| {
|
||||
let (name, hex) = part.split_at_checked(key.len())?;
|
||||
if name.eq_ignore_ascii_case(key) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
if name.eq_ignore_ascii_case(key) && hex.bytes().all(|b| b.is_ascii_hexdigit()) { u32::from_str_radix(hex, 16).ok() } else { None }
|
||||
})
|
||||
};
|
||||
Some((field("VEN_")?, field("DEV_")?))
|
||||
|
||||
@@ -820,3 +820,10 @@ fn crashes_are_forgotten_in_another_version_or_under_another_preference() {
|
||||
assert_eq!(record.crashed.len(), CRASHED_LIMIT);
|
||||
assert_eq!(record.crashed.last().map(|c| c.key.as_str()), Some("Dx12:10de:0013"));
|
||||
}
|
||||
|
||||
/// `u32::from_str_radix` accepts a leading `+`; a device id with one is not a PCI id.
|
||||
#[test]
|
||||
fn pci_ids_reject_a_sign_in_the_hex_digits() {
|
||||
assert_eq!(pci_ids("PCI\\VEN_+10DE&DEV_2204"), None);
|
||||
assert_eq!(pci_ids("PCI\\VEN_10DE&DEV_+2204"), None);
|
||||
}
|
||||
|
||||
+106
-39
@@ -1,7 +1,10 @@
|
||||
//! VectorCraft desktop app.
|
||||
//!
|
||||
//! Usage: `vectorcraft [--control <port> [--automation-read-root <dir>]... [--automation-write-root <dir>]...]
|
||||
//! [--in-window-menus] [--new-instance] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//! [--in-window-menus] [--new-instance] [--safe-mode] [--] [files…]`, `vectorcraft --version`, `vectorcraft --help` (or `-h`)
|
||||
//!
|
||||
//! Any other argument starting with '-' (not `-` alone) is an error; after `--`, every argument is
|
||||
//! a file.
|
||||
//!
|
||||
//! Files given while VectorCraft already runs open in that window, unless `--new-instance` is given
|
||||
//! (Windows and Linux; `single_instance`).
|
||||
@@ -65,6 +68,7 @@ mod xwayland;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::file_access::{self, AutomationRoots};
|
||||
use vectorcraft_ui_egui::dialogs::safe_mode;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::picks::PickRequest;
|
||||
use vectorcraft_ui_egui::{ClipboardProbeFactory, FilePick, Services, VectorcraftApp};
|
||||
@@ -77,7 +81,7 @@ mod titlebar;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use app::bring_window_forward;
|
||||
use app::native_input_hook;
|
||||
use app::open_files;
|
||||
use app::open_or_hold;
|
||||
use host_services::services;
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use prefs::{KEEP_UI_PREFS, decode_saved_prefs, keep_prefs_copy, prefs_bytes};
|
||||
@@ -92,7 +96,9 @@ use titlebar::custom_titlebar_from;
|
||||
use titlebar::opted_into_system_title_bar;
|
||||
#[cfg(windows)]
|
||||
use titlebar::set_taskbar_icon;
|
||||
use titlebar::{custom_titlebar, native_options};
|
||||
#[cfg(all(test, feature = "wgpu"))]
|
||||
use titlebar::{app_draws_frame, opted_into_app_title_bar, start_frame};
|
||||
use titlebar::{custom_titlebar, native_options, titlebar_from_env};
|
||||
|
||||
struct App {
|
||||
app: VectorcraftApp,
|
||||
@@ -111,6 +117,8 @@ struct App {
|
||||
pending_maximize: bool,
|
||||
/// Frames left to retry [`window::apply_pending_maximize`].
|
||||
maximize_tries: u8,
|
||||
/// The command line's files, held back until the choice about a stopped plug-in is made.
|
||||
held_files: Vec<String>,
|
||||
/// The time and UI zoom of the last monitor list ([`window::monitors_due`]).
|
||||
monitors_listed: Option<(f64, f32)>,
|
||||
}
|
||||
@@ -132,13 +140,89 @@ Options:
|
||||
(or VECTORCRAFT_IN_WINDOW_MENUS=1)
|
||||
--new-instance Open the files in a new window even when VectorCraft
|
||||
already runs (Windows and Linux)
|
||||
--safe-mode Start without plug-ins: none loads or runs until
|
||||
VectorCraft starts normally (Help › Safe Mode); the
|
||||
files open in a new window, as with --new-instance
|
||||
--version Print the version and exit
|
||||
-h, --help Print this help and exit
|
||||
-- Treat every argument after it as a file
|
||||
|
||||
Any other argument starting with '-' is an error.
|
||||
|
||||
Without a window (converting, scripting, the MCP server), use vectorcraft-cli:
|
||||
vectorcraft-cli --help
|
||||
";
|
||||
|
||||
/// The command line's options other than the automation roots and the control port.
|
||||
struct CommandLine {
|
||||
files: Vec<String>,
|
||||
in_window_menus: bool,
|
||||
new_instance: bool,
|
||||
/// `--safe-mode`: no plug-in loads or runs in this launch.
|
||||
safe_mode: bool,
|
||||
}
|
||||
|
||||
/// Reads the arguments after the program name. The automation roots and the control port go into
|
||||
/// `roots_args` and `control`. `Err` is the exit code when the process ends here: `--version` and
|
||||
/// `--help` print and succeed; a bad automation option or an unknown option prints an error and fails.
|
||||
fn read_command_line(
|
||||
mut args: impl Iterator<Item = String>,
|
||||
control: &mut automation_args::ControlArg,
|
||||
roots_args: &mut automation_args::AutomationArgs,
|
||||
) -> Result<CommandLine, std::process::ExitCode> {
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut new_instance = false;
|
||||
let mut safe_mode = false;
|
||||
let mut unknown = None;
|
||||
while let Some(a) = args.next() {
|
||||
// After `--`, every argument is a file, even one whose name starts with '-'.
|
||||
if a == "--" {
|
||||
files.extend(args.by_ref());
|
||||
break;
|
||||
}
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--new-instance" => new_instance = true,
|
||||
"--safe-mode" => safe_mode = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return Err(std::process::ExitCode::SUCCESS);
|
||||
}
|
||||
// The process serial number older macOS versions pass to an app the Finder starts.
|
||||
#[cfg(target_os = "macos")]
|
||||
_ if a.starts_with("-psn_") => {}
|
||||
// An option this version doesn't know (a typo such as `--safe-mod`) is an error, not a
|
||||
// file to open. Reported after the loop, so a later `--help` or `--version` still wins.
|
||||
_ if a.starts_with('-') && a != "-" => {
|
||||
unknown.get_or_insert(a);
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
if let Some(a) = unknown {
|
||||
eprintln!("vectorcraft: unknown option {a}; see vectorcraft --help, or put -- before a file whose name starts with '-'");
|
||||
return Err(std::process::ExitCode::FAILURE);
|
||||
}
|
||||
Ok(CommandLine { files, in_window_menus, new_instance, safe_mode })
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// Windows: the update helper is a copy of this program started with `--finish-update`.
|
||||
#[cfg(windows)]
|
||||
@@ -154,37 +238,11 @@ fn main() -> std::process::ExitCode {
|
||||
let root_env = |name: &str| std::env::var_os(name).filter(|v| !v.is_empty()).map(std::path::PathBuf::from);
|
||||
let mut roots_args =
|
||||
automation_args::AutomationArgs::new(root_env("VECTORCRAFT_AUTOMATION_READ_ROOT"), root_env("VECTORCRAFT_AUTOMATION_WRITE_ROOT"));
|
||||
let mut files = Vec::new();
|
||||
let mut in_window_menus = std::env::var_os("VECTORCRAFT_IN_WINDOW_MENUS").is_some_and(|v| !v.is_empty() && v != "0");
|
||||
let mut new_instance = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match roots_args.consume(&a, &mut args).and_then(|root| if root { Ok(true) } else { control.consume(&a, &mut args) }) {
|
||||
Ok(true) => continue,
|
||||
Ok(false) => {}
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft: {e}");
|
||||
return std::process::ExitCode::FAILURE;
|
||||
}
|
||||
}
|
||||
match a.as_str() {
|
||||
"--in-window-menus" => in_window_menus = true,
|
||||
"--new-instance" => new_instance = true,
|
||||
"--version" => {
|
||||
println!("vectorcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
// Before anything starts, like `--version`: from a terminal or an agent's shell the app
|
||||
// used to open (or hang without a display) instead (#2110).
|
||||
"--help" | "-h" => {
|
||||
// Not `print!`, which panics when standard output is closed (`| head -0`):
|
||||
// there is nothing left to tell then.
|
||||
let _ = std::io::Write::write_all(&mut std::io::stdout(), USAGE.as_bytes());
|
||||
return std::process::ExitCode::SUCCESS;
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let CommandLine { files, in_window_menus, new_instance, safe_mode: safe_mode_flag } =
|
||||
match read_command_line(std::env::args().skip(1), &mut control, &mut roots_args) {
|
||||
Ok(command_line) => command_line,
|
||||
Err(code) => return code,
|
||||
};
|
||||
let automation_args::AutomationArgs { read_roots, write_roots, .. } = roots_args;
|
||||
// After the arguments: `--control` and `--version` win over a bad environment value.
|
||||
let control_port = match control.port {
|
||||
@@ -202,10 +260,10 @@ fn main() -> std::process::ExitCode {
|
||||
// Files opened from the file manager while VectorCraft runs open in its window (#4892). Before
|
||||
// the log file is attached: a launch that hands its files over leaves the running app's log be.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let instance = match single_instance::start(control_port.is_some(), new_instance, &files) {
|
||||
match single_instance::start(control_port.is_some(), new_instance, safe_mode_flag, &files) {
|
||||
single_instance::Start::Forwarded => return std::process::ExitCode::SUCCESS,
|
||||
single_instance::Start::Running(primary) => primary,
|
||||
};
|
||||
single_instance::Start::Running(primary) => single_instance::hold(primary),
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = new_instance;
|
||||
vectorcraft_ui_egui::i18n::detect_system_lang_in_background();
|
||||
@@ -396,10 +454,15 @@ fn main() -> std::process::ExitCode {
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
// Before any plug-in loads: this launch's marker in the Launch folder next to the
|
||||
// preferences (none for runs without preferences), and Safe Mode.
|
||||
let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch")));
|
||||
let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app, saved);
|
||||
safe_mode::after_start(&mut app, stopped);
|
||||
// The saved PDF presets live in their own folder (also read by vectorcraft-cli),
|
||||
// which takes over from the copy the preferences file used to hold.
|
||||
prefs_dir::attach_pdf_preset_store(&mut app.session);
|
||||
@@ -466,6 +529,7 @@ fn main() -> std::process::ExitCode {
|
||||
let (loss, ctx) = (graphics_loss.clone(), cc.egui_ctx.clone());
|
||||
rs.device.set_device_lost_callback(move |reason, msg| loss.report(&ctx, format!("{reason:?}: {msg}")));
|
||||
}
|
||||
app.titlebar_from_env = titlebar_from_env();
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
app.desktop_frame = !custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
@@ -492,7 +556,7 @@ fn main() -> std::process::ExitCode {
|
||||
single_instance::set_ui(&cc.egui_ctx);
|
||||
}
|
||||
app.services.native_input_hook = native_input_hook(&app);
|
||||
file_access::unconfined(|| open_files(&mut app, files));
|
||||
let held_files = file_access::unconfined(|| open_or_hold(&mut app, files));
|
||||
// The first frame is due from now (`gpu::watch_first_frame`).
|
||||
#[cfg(feature = "wgpu")]
|
||||
created.ready();
|
||||
@@ -508,6 +572,7 @@ fn main() -> std::process::ExitCode {
|
||||
winit_window,
|
||||
pending_maximize,
|
||||
maximize_tries: 8,
|
||||
held_files,
|
||||
monitors_listed: None,
|
||||
}))
|
||||
}),
|
||||
@@ -516,7 +581,9 @@ fn main() -> std::process::ExitCode {
|
||||
// The window is gone: later launches start their own app, including the one a graphics
|
||||
// failure starts again below.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
drop(instance);
|
||||
single_instance::resign();
|
||||
// The process ends normally from here: this launch didn't stop while a plug-in was loading.
|
||||
safe_mode::end_launch();
|
||||
#[cfg(feature = "wgpu")]
|
||||
let outcome = gpu::finish(outcome, &startup);
|
||||
#[cfg(not(feature = "wgpu"))]
|
||||
|
||||
@@ -12,8 +12,12 @@
|
||||
//! as its own app, which replaces a stale file. Nothing received is ever run.
|
||||
//!
|
||||
//! Runs that start many apps side by side (`--control`, `VECTORCRAFT_NO_PREFS`) neither forward
|
||||
//! nor listen, and `--new-instance` opens a launch's files in an app of its own. macOS hands files
|
||||
//! opened from Finder to the running app itself (`open_documents`), so this is off there.
|
||||
//! nor listen, and neither does a `--safe-mode` launch, which opens its files in an app of its own
|
||||
//! and leaves later launches to start theirs with plug-ins. `--new-instance` opens a launch's files
|
||||
//! in an app of its own. An app that stays in Safe Mode after the choice about a stopped plug-in
|
||||
//! stops listening ([`resign`]), and files later launches handed it before then open in a new app
|
||||
//! with plug-ins ([`take_for_frame`]). macOS hands files opened from Finder to the running app itself
|
||||
//! (`open_documents`), so this is off there.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::hash::{BuildHasher as _, RandomState};
|
||||
@@ -43,6 +47,8 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// Paths handed over by later launches that the UI hasn't opened yet.
|
||||
static PENDING: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
/// This app while it is the one later launches hand their files to ([`hold`]).
|
||||
static HELD: Mutex<Option<Primary>> = Mutex::new(None);
|
||||
/// The UI to wake when files arrive (unset until the window exists; the first frame opens them).
|
||||
static UI: OnceLock<egui::Context> = OnceLock::new();
|
||||
|
||||
@@ -56,23 +62,46 @@ pub enum Start {
|
||||
|
||||
/// Hand `files` to the app already running, or become the app later launches hand theirs to.
|
||||
/// `automation`: started with `--control`.
|
||||
pub fn start(automation: bool, new_instance: bool, files: &[String]) -> Start {
|
||||
pub fn start(automation: bool, new_instance: bool, safe_mode: bool, files: &[String]) -> Start {
|
||||
if automation || !prefs_enabled() {
|
||||
return Start::Running(None);
|
||||
}
|
||||
let Some(path) = prefs_path().and_then(|p| Some(p.parent()?.join(FILE_NAME))) else { return Start::Running(None) };
|
||||
start_at(&path, new_instance, safe_mode, files)
|
||||
}
|
||||
|
||||
/// [`start`] with the endpoint file `path`. A launch with `--new-instance` or `--safe-mode` keeps
|
||||
/// its files and runs as its own app: a running app would open them with its plug-ins loaded. A
|
||||
/// `--safe-mode` launch never becomes the app later launches hand their files to, which would open
|
||||
/// them without plug-ins.
|
||||
fn start_at(path: &Path, new_instance: bool, safe_mode: bool, files: &[String]) -> Start {
|
||||
if safe_mode {
|
||||
return Start::Running(None);
|
||||
}
|
||||
// With nothing to hand over, the same exchange only asks whether an app is running.
|
||||
let send: &[String] = if new_instance { &[] } else { files };
|
||||
match forward(&path, send) {
|
||||
match forward(path, send) {
|
||||
Ok(()) if send.is_empty() => Start::Running(None),
|
||||
Ok(()) => Start::Forwarded,
|
||||
Err(e) => {
|
||||
log::debug!("no running VectorCraft to hand files to ({e})");
|
||||
Start::Running(listen(&path, queue))
|
||||
Start::Running(listen(path, queue))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep `primary` ([`start`]'s) until [`resign`].
|
||||
pub fn hold(primary: Option<Primary>) {
|
||||
*HELD.lock().unwrap_or_else(PoisonError::into_inner) = primary;
|
||||
}
|
||||
|
||||
/// Later launches start their own app from now on: the endpoint file goes. The app calls it when
|
||||
/// its window is gone, and when it stays in Safe Mode after the choice about a stopped plug-in.
|
||||
pub fn resign() {
|
||||
let primary = HELD.lock().unwrap_or_else(PoisonError::into_inner).take();
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
/// Wake `ctx` when files arrive from later launches.
|
||||
pub fn set_ui(ctx: &egui::Context) {
|
||||
// Set once; a second call would hand over the same context.
|
||||
@@ -84,6 +113,43 @@ pub fn take() -> Vec<String> {
|
||||
std::mem::take(&mut *PENDING.lock().unwrap_or_else(PoisonError::into_inner))
|
||||
}
|
||||
|
||||
/// The paths later launches handed over since the last call, to open in this app. An app that
|
||||
/// stays in Safe Mode (`safe_mode`) resigns and hands them to a new app with plug-ins (`launch`,
|
||||
/// [`launch_with_plug_ins`] in the app) instead: they arrived while it was the primary, before the
|
||||
/// choice about a stopped plug-in ended in Safe Mode, or just before it resigned. When `launch`
|
||||
/// fails (logged), they open here.
|
||||
pub fn take_for_frame(safe_mode: bool, launch: impl FnOnce(&[String]) -> io::Result<()>) -> Vec<String> {
|
||||
let files = take();
|
||||
if !safe_mode {
|
||||
return files;
|
||||
}
|
||||
// First, so the new app becomes the primary instead of handing the files back.
|
||||
resign();
|
||||
if files.is_empty() {
|
||||
return files;
|
||||
}
|
||||
match launch(&files) {
|
||||
Ok(()) => Vec::new(),
|
||||
Err(e) => {
|
||||
log::warn!("couldn't start VectorCraft with plug-ins for the files another launch opened: {e}");
|
||||
files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start this program again, with plug-ins, on `files`.
|
||||
pub fn launch_with_plug_ins(files: &[String]) -> io::Result<()> {
|
||||
app_with_plug_ins(&std::env::current_exe()?, files).spawn().map(drop)
|
||||
}
|
||||
|
||||
/// The command [`launch_with_plug_ins`] runs: `exe -- files…`, so a file whose name starts with '-'
|
||||
/// stays a file.
|
||||
fn app_with_plug_ins(exe: &Path, files: &[String]) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(exe);
|
||||
command.arg("--").args(files);
|
||||
command
|
||||
}
|
||||
|
||||
fn queue(files: Vec<String>) {
|
||||
PENDING.lock().unwrap_or_else(PoisonError::into_inner).extend(files);
|
||||
if let Some(ctx) = UI.get() {
|
||||
@@ -263,6 +329,13 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Held by the tests that use the process-wide [`HELD`] and [`PENDING`], one at a time.
|
||||
static GLOBALS: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn globals() -> std::sync::MutexGuard<'static, ()> {
|
||||
GLOBALS.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
struct TestDir(tempfile::TempDir);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
@@ -313,6 +386,72 @@ mod tests {
|
||||
assert!(!path.exists(), "the endpoint file goes with the app");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_instance_or_safe_mode_launch_keeps_its_files() {
|
||||
let (path, _dir, file) = folder("own");
|
||||
let (primary, rx) = primary(&path);
|
||||
for (new_instance, safe_mode) in [(true, false), (false, true)] {
|
||||
let start = start_at(&path, new_instance, safe_mode, std::slice::from_ref(&file));
|
||||
assert!(matches!(start, Start::Running(None)), "runs as its own app (new instance {new_instance}, Safe Mode {safe_mode})");
|
||||
}
|
||||
assert!(matches!(start_at(&path, false, false, std::slice::from_ref(&file)), Start::Forwarded));
|
||||
assert_eq!(rx.recv_timeout(CLIENT_TIMEOUT).unwrap(), std::slice::from_ref(&file));
|
||||
assert!(rx.try_recv().is_err(), "only the plain launch handed its file over");
|
||||
drop(primary);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_safe_mode_launch_leaves_later_launches_their_own_app() {
|
||||
let _globals = globals();
|
||||
let (path, _dir, file) = folder("safe-mode-alone");
|
||||
// With no app running, a Safe Mode launch runs without listening.
|
||||
assert!(matches!(start_at(&path, false, true, std::slice::from_ref(&file)), Start::Running(None)));
|
||||
assert!(!path.exists(), "a Safe Mode launch records no endpoint");
|
||||
// A plain launch after it starts its own app and takes later launches' files.
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, std::slice::from_ref(&file)) else {
|
||||
panic!("the plain launch runs and listens")
|
||||
};
|
||||
assert!(path.exists());
|
||||
// An app that stays in Safe Mode after the choice gives that up.
|
||||
hold(Some(primary));
|
||||
resign();
|
||||
assert!(!path.exists(), "the endpoint file goes with resign");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_handed_to_an_app_staying_in_safe_mode_go_to_a_new_app_with_plug_ins() {
|
||||
let _globals = globals();
|
||||
// Nothing left over from a test that failed.
|
||||
resign();
|
||||
drop(take());
|
||||
let (path, _dir, file) = folder("safe-mode-later");
|
||||
let Start::Running(Some(primary)) = start_at(&path, false, false, &[]) else { panic!("the first launch listens") };
|
||||
hold(Some(primary));
|
||||
// A later launch handed its file over while the choice about a stopped plug-in was up.
|
||||
queue(vec![file.clone()]);
|
||||
let mut launched = Vec::new();
|
||||
let here = take_for_frame(true, |files| {
|
||||
launched = files.to_vec();
|
||||
Ok(())
|
||||
});
|
||||
assert!(here.is_empty(), "nothing opens in the Safe Mode app");
|
||||
assert_eq!(launched, std::slice::from_ref(&file), "a new app with plug-ins opens it");
|
||||
assert!(!path.exists(), "the app resigned before starting the new one");
|
||||
// When the new app can't start, the file opens here rather than being lost.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(true, |_| Err(io::Error::other("no program"))), std::slice::from_ref(&file));
|
||||
// An app with plug-ins opens what it is handed.
|
||||
queue(vec![file.clone()]);
|
||||
assert_eq!(take_for_frame(false, |_| panic!("no new app")), [file]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_new_app_takes_every_name_as_a_file() {
|
||||
let command = app_with_plug_ins(Path::new("vectorcraft"), &["-a.svg".to_owned(), "b.svg".to_owned()]);
|
||||
let args: Vec<_> = command.get_args().collect();
|
||||
assert_eq!(args, ["--", "-a.svg", "b.svg"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_token_garbage_and_oversized_messages_are_refused() {
|
||||
let (path, _dir, file) = folder("refused");
|
||||
|
||||
@@ -36,6 +36,33 @@ fn shortcut_overrides_from_an_earlier_version_load() {
|
||||
assert!(km.problems().iter().any(|p| format!("{p:?}") == r#"UnknownCommand { key: "test.unknown" }"#), "reported");
|
||||
}
|
||||
|
||||
/// The command line's files wait for the choice about a stopped plug-in, so they open with the
|
||||
/// plug-ins it loads; with no choice up they open at once.
|
||||
#[test]
|
||||
fn command_line_files_wait_for_the_choice_about_a_stopped_plug_in() {
|
||||
let temp = tempfile::Builder::new().prefix("vectorcraft-held-files-").tempdir().unwrap();
|
||||
let file = temp.path().join("a.svg");
|
||||
std::fs::write(&file, r#"<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"/>"#).unwrap();
|
||||
let files = vec![file.to_string_lossy().into_owned()];
|
||||
// The files the app reads to open them.
|
||||
let read = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
|
||||
let reads = read.clone();
|
||||
let services = vectorcraft_ui_egui::Services {
|
||||
read: Some(Box::new(move |p: &str| {
|
||||
reads.borrow_mut().push(p.to_owned());
|
||||
std::fs::read(p).map_err(|e| e.to_string())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), services);
|
||||
app.ui.stopped_plugin = Some(serde_json::json!({"id": "test.held", "name": "Held"}));
|
||||
assert_eq!(open_or_hold(&mut app, files.clone()), files, "held while the choice is up");
|
||||
assert!(read.borrow().is_empty(), "nothing opened before the choice");
|
||||
app.ui.stopped_plugin = None;
|
||||
assert!(open_or_hold(&mut app, files.clone()).is_empty());
|
||||
assert_eq!(*read.borrow(), files, "opened with no choice up");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_files_unminimizes_and_focuses_the_window() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -164,6 +191,60 @@ fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// Preferences › User Interface › VectorCraft Title Bar on Every Desktop gives the app frame where
|
||||
/// the probe chose the desktop's (KDE, X11, a window opened through XWayland); without it the probe
|
||||
/// decides, and `VECTORCRAFT_TITLEBAR` takes precedence over both.
|
||||
#[test]
|
||||
fn the_app_title_bar_preference_gives_the_app_frame_on_desktop_frames() {
|
||||
assert!(!super::opted_into_app_title_bar(None), "no preferences: the default");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"appTitleBar": false}));
|
||||
assert!(!super::opted_into_app_title_bar(Some(&off)));
|
||||
let on = ui_state_with_prefs(serde_json::json!({"appTitleBar": true}));
|
||||
assert!(super::opted_into_app_title_bar(Some(&on)));
|
||||
|
||||
// (probed_app, overridden, forced_to_x11): without the preference, then with it.
|
||||
let cases = [
|
||||
((false, None, false), false, true), // KDE, Sway, X11: the desktop frame
|
||||
((true, None, false), true, true), // GNOME on Wayland: the app frame either way
|
||||
((true, None, true), false, true), // GNOME, the window opened through XWayland
|
||||
((false, Some(true), false), true, true), // VECTORCRAFT_TITLEBAR=app
|
||||
((false, Some(true), true), true, true), // VECTORCRAFT_TITLEBAR=app, the window opened through XWayland
|
||||
((true, Some(false), false), false, false), // VECTORCRAFT_TITLEBAR=desktop takes precedence over the preference
|
||||
((false, Some(false), true), false, false),
|
||||
];
|
||||
for ((probed_app, overridden, forced_to_x11), without, with) in cases {
|
||||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, false), without, "{probed_app} {overridden:?} {forced_to_x11}");
|
||||
assert_eq!(super::app_draws_frame(probed_app, overridden, forced_to_x11, true), with, "{probed_app} {overridden:?} {forced_to_x11}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame a start uses: the preference turns the desktop frame the probe chose into the app frame
|
||||
/// with the default window buttons, a probed app frame keeps its own buttons, and the desktop frame
|
||||
/// stays where the app frame is not drawn. System Title Bar takes precedence over the preference.
|
||||
#[test]
|
||||
fn the_start_frame_applies_the_app_title_bar_preference() {
|
||||
use craft_window::{Button, Frame, Layout};
|
||||
let gnome = Layout { left: vec![Button::Close], right: vec![] };
|
||||
let cases = [
|
||||
// (probed, overridden, forced_to_x11, opted_into_app), expected
|
||||
((Frame::Desktop, None, false, false), Frame::Desktop),
|
||||
((Frame::Desktop, None, false, true), Frame::App(Layout::default())),
|
||||
((Frame::Desktop, None, true, true), Frame::App(Layout::default())),
|
||||
((Frame::App(gnome.clone()), None, false, false), Frame::App(gnome.clone())),
|
||||
((Frame::App(gnome.clone()), None, true, false), Frame::Desktop),
|
||||
((Frame::App(gnome.clone()), None, true, true), Frame::App(gnome.clone())),
|
||||
((Frame::Desktop, Some(false), false, true), Frame::Desktop),
|
||||
((Frame::App(gnome.clone()), Some(true), true, false), Frame::App(gnome.clone())),
|
||||
];
|
||||
for ((probed, overridden, forced_to_x11, opted_into_app), expected) in cases {
|
||||
let label = format!("{probed:?} {overridden:?} {forced_to_x11} {opted_into_app}");
|
||||
assert_eq!(super::start_frame(probed, overridden, forced_to_x11, opted_into_app), expected, "{label}");
|
||||
}
|
||||
// With both preferences on, System Title Bar takes precedence: the desktop draws the frame.
|
||||
let both = super::start_frame(Frame::Desktop, None, false, true);
|
||||
assert!(!super::custom_titlebar_from(&both, true), "System Title Bar takes precedence over the app title bar preference");
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
||||
@@ -46,6 +46,53 @@ pub(crate) fn opted_into_system_title_bar(saved: Option<&vectorcraft_ui_egui::Ui
|
||||
saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the user asked for VectorCraft's own title bar in Preferences › User Interface ›
|
||||
/// VectorCraft Title Bar on Every Desktop (Linux and the BSDs). Read like
|
||||
/// [`opted_into_system_title_bar`]; a missing or unreadable value is `false` (the default).
|
||||
pub(crate) fn opted_into_app_title_bar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
saved.map(|ui| ui.engine_prefs.get("appTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Whether the app draws the frame on this start, before the System Title Bar preference is
|
||||
/// applied ([`custom_titlebar_from`]). `probed_app` is the `craft-window` probe's answer and
|
||||
/// `overridden` its `VECTORCRAFT_TITLEBAR` override. In order: the override decides when it is
|
||||
/// set; VectorCraft Title Bar on Every Desktop (`opted_into_app`) gives the app frame; a window
|
||||
/// that opens through XWayland (`forced_to_x11`) gets the desktop frame; otherwise the probe
|
||||
/// decides.
|
||||
pub(crate) fn app_draws_frame(probed_app: bool, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> bool {
|
||||
match overridden {
|
||||
Some(app) => app,
|
||||
None => opted_into_app || (probed_app && !forced_to_x11),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame this start uses, before the System Title Bar preference is applied
|
||||
/// ([`custom_titlebar_from`]): [`app_draws_frame`] applied to the frame the probe chose. A probed
|
||||
/// app frame keeps its window-button layout; an app frame the probe did not choose gets the default
|
||||
/// layout, since `custom_titlebar_from` reads only which frame it is.
|
||||
pub(crate) fn start_frame(probed: craft_window::Frame, overridden: Option<bool>, forced_to_x11: bool, opted_into_app: bool) -> craft_window::Frame {
|
||||
let probed_app = matches!(probed, craft_window::Frame::App(_));
|
||||
match (app_draws_frame(probed_app, overridden, forced_to_x11, opted_into_app), probed) {
|
||||
(true, frame @ craft_window::Frame::App(_)) => frame,
|
||||
(true, craft_window::Frame::Desktop) => craft_window::Frame::App(craft_window::Layout::default()),
|
||||
(false, _) => craft_window::Frame::Desktop,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `craft-window` probe's answer for this process (its override variable is
|
||||
/// `VECTORCRAFT_TITLEBAR`). The probe runs once; on a Wayland session it opens a connection.
|
||||
fn probed_choice() -> &'static craft_window::Choice {
|
||||
static CHOICE: std::sync::OnceLock<craft_window::Choice> = std::sync::OnceLock::new();
|
||||
CHOICE.get_or_init(|| craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT")))
|
||||
}
|
||||
|
||||
/// Whether `VECTORCRAFT_TITLEBAR` chose this window's frame (Linux and the BSDs), so Preferences ›
|
||||
/// User Interface › VectorCraft Title Bar on Every Desktop does not apply while it is set. Always
|
||||
/// `false` on macOS and Windows.
|
||||
pub(crate) fn titlebar_from_env() -> bool {
|
||||
cfg!(not(any(target_os = "macos", target_os = "windows"))) && probed_choice().overridden().is_some()
|
||||
}
|
||||
|
||||
/// Whether this start draws its own title bar, given the frame the shared `craft-window` crate
|
||||
/// decided and the user's System Title Bar preference.
|
||||
///
|
||||
@@ -82,24 +129,27 @@ pub(crate) fn custom_titlebar_from(_frame: &craft_window::Frame, opted_into_syst
|
||||
/// On Linux, Preferences › Performance › Linux Display Server › X11 opens the window through
|
||||
/// XWayland (below). A GNOME/Wayland probe would say `Frame::App` there, but the window really runs
|
||||
/// on X11, whose window manager draws the frame — so when XWayland is being forced, keep the native
|
||||
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins.
|
||||
/// frame, the same as a real X11 session. An explicit `VECTORCRAFT_TITLEBAR` override still wins,
|
||||
/// and so does VectorCraft Title Bar on Every Desktop ([`app_draws_frame`]).
|
||||
pub(crate) fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("VECTORCRAFT"));
|
||||
let choice = probed_choice();
|
||||
let opted_into_system = opted_into_system_title_bar(saved);
|
||||
// macOS and Windows never read VectorCraft Title Bar on Every Desktop (`custom_titlebar_from`
|
||||
// ignores the frame there).
|
||||
let opted_into_app = cfg!(not(any(target_os = "macos", target_os = "windows"))) && opted_into_app_title_bar(saved);
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// When the window will actually open on X11 (XWayland) — the user asked for it *and* the X11
|
||||
// libraries are present, the same two conditions the launch path checks below — that run is
|
||||
// effectively X11, so keep the native frame. If the libraries are missing, startup stays on
|
||||
// Wayland, so the probe's own decision (app-drawn on GNOME) must stand.
|
||||
let forced_to_x11 = {
|
||||
let pref = saved.and_then(|ui| ui.engine_prefs.get("linuxDisplayServer")).and_then(serde_json::Value::as_str);
|
||||
// `overridden() == None` means the probe decided (no VECTORCRAFT_TITLEBAR). When the window
|
||||
// will actually open on X11 (XWayland) — the user asked for it *and* the X11 libraries are
|
||||
// present, the same two conditions the launch path checks below — that run is effectively
|
||||
// X11, so keep the native frame. If the libraries are missing, startup stays on Wayland, so
|
||||
// the probe's own decision (app-drawn on GNOME) must stand.
|
||||
let forced_to_x11 = xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found();
|
||||
if choice.overridden().is_none() && forced_to_x11 {
|
||||
return custom_titlebar_from(&craft_window::Frame::Desktop, opted_into_system);
|
||||
}
|
||||
}
|
||||
custom_titlebar_from(choice.frame(), opted_into_system)
|
||||
xwayland::wanted(pref, |name| std::env::var_os(name), xwayland::pen_attached) && xwayland::x11_libraries_found()
|
||||
};
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let forced_to_x11 = false;
|
||||
let frame = start_frame(choice.frame().clone(), choice.overridden(), forced_to_x11, opted_into_app);
|
||||
custom_titlebar_from(&frame, opted_into_system)
|
||||
}
|
||||
|
||||
pub(crate) fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
|
||||
@@ -13,6 +13,13 @@
|
||||
//! same undo step as the typing round it. The keypad digits themselves are taken out of the frame,
|
||||
//! so they don't run a shortcut bound to Alt and a digit.
|
||||
//!
|
||||
//! With NumLock off the keypad keys arrive as Insert, End, Down, Page Down, Left, Clear, Right,
|
||||
//! Home, Up and Page Up, and Windows still composes the code from them. They are told from the
|
||||
//! separate navigation keys by the extended-key flag, which only the separate keys set
|
||||
//! (<https://learn.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#extended-key-flag>),
|
||||
//! so with Alt held they count as digits and are taken out of the frame too, rather than moving
|
||||
//! the caret before the character arrives.
|
||||
//!
|
||||
//! egui reports a keypad digit as the same key as the digit above the letters, so the other
|
||||
//! platforms, whose systems have no such convention, can't tell them apart either.
|
||||
|
||||
@@ -28,10 +35,20 @@ const WM_CHAR: u32 = 0x0102;
|
||||
const WM_SYSKEYDOWN: u32 = 0x0104;
|
||||
const WM_SYSKEYUP: u32 = 0x0105;
|
||||
const WM_SYSCHAR: u32 = 0x0106;
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on. With it off the keys
|
||||
/// are Insert, End, … and type no code.
|
||||
/// `VK_NUMPAD0` to `VK_NUMPAD9` (`winuser.h`): keypad digits with NumLock on.
|
||||
const VK_NUMPAD0: usize = 0x60;
|
||||
const VK_NUMPAD9: usize = 0x69;
|
||||
/// The keypad's virtual keys with NumLock off, by digit (`winuser.h`): `VK_INSERT`, `VK_END`,
|
||||
/// `VK_DOWN`, `VK_NEXT`, `VK_LEFT`, `VK_CLEAR`, `VK_RIGHT`, `VK_HOME`, `VK_UP` and `VK_PRIOR`.
|
||||
const NUMLOCK_OFF_KEYS: [usize; 10] = [0x2D, 0x23, 0x28, 0x22, 0x25, 0x0C, 0x27, 0x24, 0x26, 0x21];
|
||||
/// The keys egui reports for the keypad with NumLock off, by digit (Clear has no egui key, so it
|
||||
/// comes as the top-row digit key, `Num5`).
|
||||
const NUMLOCK_OFF_EGUI_KEYS: [Key; 10] =
|
||||
[Key::Insert, Key::End, Key::ArrowDown, Key::PageDown, Key::ArrowLeft, Key::Num5, Key::ArrowRight, Key::Home, Key::ArrowUp, Key::PageUp];
|
||||
const DIGIT_KEYS: [Key; 10] = [Key::Num0, Key::Num1, Key::Num2, Key::Num3, Key::Num4, Key::Num5, Key::Num6, Key::Num7, Key::Num8, Key::Num9];
|
||||
/// Bit 24 of a key message's `lParam`: an extended key, such as the navigation keys beside the
|
||||
/// keypad (the keypad's own keys clear it).
|
||||
const EXTENDED_KEY: isize = 1 << 24;
|
||||
/// `VK_MENU`, `VK_LMENU` and `VK_RMENU`: Alt.
|
||||
const ALT_KEYS: [usize; 3] = [0x12, 0xA4, 0xA5];
|
||||
|
||||
@@ -51,23 +68,42 @@ struct AltCodes {
|
||||
high: Option<u16>,
|
||||
/// Characters the codes typed, for the next frame.
|
||||
typed: String,
|
||||
/// Keypad digit presses and releases with Alt held since the last frame, which egui reports
|
||||
/// as Alt and a digit: taken out of the frame.
|
||||
keypad_events: usize,
|
||||
/// The digits of the keypad presses and releases with Alt held since the last frame, which
|
||||
/// egui reports as Alt and a digit (or, with NumLock off, Alt and a navigation key): taken
|
||||
/// out of the frame.
|
||||
keypad_events: Vec<usize>,
|
||||
}
|
||||
|
||||
/// The digit a keypad key stands for in an Alt code: `wparam` is its virtual-key code and
|
||||
/// `lparam` the message's flags.
|
||||
fn keypad_digit(wparam: usize, lparam: isize) -> Option<usize> {
|
||||
if (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam) {
|
||||
return Some(wparam - VK_NUMPAD0);
|
||||
}
|
||||
if lparam & EXTENDED_KEY != 0 {
|
||||
return None;
|
||||
}
|
||||
NUMLOCK_OFF_KEYS.iter().position(|&k| k == wparam)
|
||||
}
|
||||
|
||||
impl AltCodes {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit).
|
||||
fn message(&mut self, message: u32, wparam: usize) {
|
||||
let keypad = (VK_NUMPAD0..=VK_NUMPAD9).contains(&wparam);
|
||||
match message {
|
||||
/// Note a keyboard message `message` with `wparam` (the virtual-key code, or a UTF-16 unit)
|
||||
/// and `lparam` (its flags).
|
||||
fn message(&mut self, message: u32, wparam: usize, lparam: isize) {
|
||||
let keypad = match message {
|
||||
WM_SYSKEYDOWN | WM_SYSKEYUP => keypad_digit(wparam, lparam),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = keypad {
|
||||
// Windows sends a key pressed with Alt held as a system key.
|
||||
WM_SYSKEYDOWN if keypad => {
|
||||
if message == WM_SYSKEYDOWN {
|
||||
self.digits += 1;
|
||||
self.keypad_events += 1;
|
||||
self.armed = false;
|
||||
}
|
||||
WM_SYSKEYUP if keypad => self.keypad_events += 1,
|
||||
self.keypad_events.push(d);
|
||||
return;
|
||||
}
|
||||
match message {
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN if ALT_KEYS.contains(&wparam) => {}
|
||||
// Any other key ends the sequence and keeps its own meaning.
|
||||
WM_KEYDOWN | WM_SYSKEYDOWN => self.reset(),
|
||||
@@ -99,14 +135,23 @@ impl AltCodes {
|
||||
/// out the keypad digits typed with Alt held.
|
||||
fn drain(&mut self, events: &mut Vec<Event>) {
|
||||
let mut keypad = std::mem::take(&mut self.keypad_events);
|
||||
if keypad > 0 {
|
||||
if !keypad.is_empty() {
|
||||
events.retain(|e| {
|
||||
let digit = matches!(e, Event::Key { key: Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 | Key::Num6 | Key::Num7 | Key::Num8 | Key::Num9, modifiers, .. } if modifiers.alt);
|
||||
if digit && keypad > 0 {
|
||||
keypad -= 1;
|
||||
return false;
|
||||
let Event::Key { key, modifiers, .. } = e else {
|
||||
return true;
|
||||
};
|
||||
if !modifiers.alt {
|
||||
return true;
|
||||
}
|
||||
// The first keypad key still to take out that egui reports as `key`.
|
||||
let stands_for = |d: &usize| DIGIT_KEYS.get(*d) == Some(key) || NUMLOCK_OFF_EGUI_KEYS.get(*d) == Some(key);
|
||||
match keypad.iter().position(stands_for) {
|
||||
Some(i) => {
|
||||
keypad.remove(i);
|
||||
false
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
if !self.typed.is_empty() {
|
||||
@@ -129,7 +174,7 @@ pub fn msg_hook(msg: *const std::ffi::c_void) {
|
||||
if (WM_KEYDOWN..=WM_SYSCHAR).contains(&msg.message) {
|
||||
CODES.with(|c| {
|
||||
if let Ok(mut c) = c.try_borrow_mut() {
|
||||
c.message(msg.message, msg.wParam.0);
|
||||
c.message(msg.message, msg.wParam.0, msg.lParam.0);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -152,14 +197,14 @@ mod tests {
|
||||
|
||||
/// Alt held, keypad digits `code`, Alt released, and the character Windows then posts.
|
||||
fn alt_code(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = VK_NUMPAD0 + usize::from(d - b'0');
|
||||
c.message(WM_SYSKEYDOWN, vk);
|
||||
c.message(WM_SYSKEYUP, vk);
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(posted));
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
fn key(key: Key, alt: bool, pressed: bool) -> Event {
|
||||
@@ -186,11 +231,11 @@ mod tests {
|
||||
fn a_character_without_a_code_before_it_is_left_to_winit() {
|
||||
let mut c = AltCodes::default();
|
||||
// A plain key press's character (winit types it), and Alt pressed and released alone.
|
||||
c.message(WM_KEYDOWN, 0x41);
|
||||
c.message(WM_CHAR, usize::from(b'a'));
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, usize::from(b'b'));
|
||||
c.message(WM_KEYDOWN, 0x41, 0);
|
||||
c.message(WM_CHAR, usize::from(b'a'), 0);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(b'b'), 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(events.is_empty(), "{events:?}");
|
||||
@@ -199,12 +244,12 @@ mod tests {
|
||||
#[test]
|
||||
fn another_key_while_alt_is_held_cancels_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
// Alt+F: a shortcut, not a code.
|
||||
c.message(WM_SYSKEYDOWN, 0x46);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0x41);
|
||||
c.message(WM_SYSKEYDOWN, 0x46, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert!(!events.iter().any(|e| matches!(e, Event::Text(_))), "{events:?}");
|
||||
@@ -213,13 +258,81 @@ mod tests {
|
||||
#[test]
|
||||
fn a_code_above_the_basic_plane_arrives_as_two_halves() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1);
|
||||
c.message(WM_KEYUP, ALT);
|
||||
c.message(WM_CHAR, 0xD83D);
|
||||
c.message(WM_CHAR, 0xDE00);
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, VK_NUMPAD0 + 1, 0);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0xD83D, 0);
|
||||
c.message(WM_CHAR, 0xDE00, 0);
|
||||
let mut events = vec![];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("😀".into())]);
|
||||
}
|
||||
|
||||
/// Alt held, keypad digits `code` with NumLock off (keypad Insert, End, …, flags without the
|
||||
/// extended-key bit), Alt released, and the character Windows then posts.
|
||||
fn alt_code_numlock_off(c: &mut AltCodes, code: &str, posted: u16) {
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
for d in code.bytes() {
|
||||
let vk = NUMLOCK_OFF_KEYS[usize::from(d - b'0')];
|
||||
c.message(WM_SYSKEYDOWN, vk, 0);
|
||||
c.message(WM_SYSKEYUP, vk, 0);
|
||||
}
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, usize::from(posted), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_the_keypad_keys_type_the_code_and_leave_the_caret_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
alt_code_numlock_off(&mut c, "0176", 0xB0);
|
||||
// What egui reports for keypad 0, 1, 7 and 6 with NumLock off and Alt held, a press and a
|
||||
// release each, then a plain Home typed after Alt came up.
|
||||
let mut events = vec![];
|
||||
for k in [Key::Insert, Key::End, Key::Home, Key::ArrowRight] {
|
||||
events.push(key(k, true, true));
|
||||
events.push(key(k, true, false));
|
||||
}
|
||||
events.push(key(Key::Home, false, true));
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, false, true), Event::Text("°".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_numlock_off_keypad_five_counts_too() {
|
||||
let mut c = AltCodes::default();
|
||||
// Keypad 5 is Clear, which egui reports as the top-row 5. 0151 uses Insert, End and Clear;
|
||||
// 0234 uses Insert, Down, Page Down and Left.
|
||||
alt_code_numlock_off(&mut c, "0151", 0x2014);
|
||||
alt_code_numlock_off(&mut c, "0234", 0xEA);
|
||||
let mut events =
|
||||
vec![key(Key::Num5, true, true), key(Key::ArrowDown, true, true), key(Key::PageDown, true, true), key(Key::ArrowLeft, true, true)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![Event::Text("—ê".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_separate_navigation_keys_still_move_the_caret_and_cancel_the_code() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_SYSKEYDOWN, ALT, 0);
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[1], 0);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[1], 0);
|
||||
// Alt+Home on the separate Home key (extended): a shortcut, not a digit.
|
||||
c.message(WM_SYSKEYDOWN, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_SYSKEYUP, NUMLOCK_OFF_KEYS[7], EXTENDED_KEY);
|
||||
c.message(WM_KEYUP, ALT, 0);
|
||||
c.message(WM_CHAR, 0x41, 0);
|
||||
let mut events = vec![key(Key::End, true, true), key(Key::End, true, false), key(Key::Home, true, true), key(Key::Home, true, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events, vec![key(Key::Home, true, true), key(Key::Home, true, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numlock_off_keys_without_alt_are_left_alone() {
|
||||
let mut c = AltCodes::default();
|
||||
c.message(WM_KEYDOWN, NUMLOCK_OFF_KEYS[7], 0);
|
||||
c.message(WM_KEYUP, NUMLOCK_OFF_KEYS[7], 0);
|
||||
let mut events = vec![key(Key::Home, false, true), key(Key::Home, false, false)];
|
||||
c.drain(&mut events);
|
||||
assert_eq!(events.len(), 2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ fn prints_usage(args: &[&str], env: &[(&str, &str)]) {
|
||||
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(out.stderr.is_empty(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(stdout.starts_with("Usage: vectorcraft "), "{args:?}: {stdout}");
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--version", "--help"] {
|
||||
for flag in ["--control", "--automation-read-root", "--automation-write-root", "--in-window-menus", "--safe-mode", "--version", "--help"] {
|
||||
assert!(stdout.contains(flag), "{args:?}: usage doesn't mention {flag}: {stdout}");
|
||||
}
|
||||
// Headless work is the CLI's: the usage says where to go.
|
||||
@@ -74,3 +74,27 @@ fn help_prints_usage_and_exits_before_startup() {
|
||||
fn help_wins_over_a_bad_control_port_variable() {
|
||||
prints_usage(&["--help"], &[("VECTORCRAFT_CONTROL_PORT", "abc")]);
|
||||
}
|
||||
|
||||
/// An option the app doesn't know (a typo of `--safe-mode`) is an error, not a file to open with
|
||||
/// plug-ins; a later `--help` still wins.
|
||||
#[test]
|
||||
fn an_unknown_option_is_an_error_and_not_a_file() {
|
||||
for args in [&["--safe-mod"][..], &["drawing.svg", "--safe-mod"], &["-x"]] {
|
||||
let out = run(args, &[]);
|
||||
assert!(!out.status.success(), "{args:?}");
|
||||
assert!(out.stdout.is_empty(), "{args:?}");
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
let unknown = args.iter().find(|a| a.starts_with('-')).unwrap();
|
||||
assert_eq!(
|
||||
stderr,
|
||||
format!("vectorcraft: unknown option {unknown}; see vectorcraft --help, or put -- before a file whose name starts with '-'\n"),
|
||||
"{args:?}"
|
||||
);
|
||||
}
|
||||
prints_usage(&["--safe-mod", "--help"], &[]);
|
||||
// After `--`, a name starting with '-' is a file: the run goes on to the control port, which
|
||||
// `run` makes fail.
|
||||
let out = run(&["--", "-x.svg"], &[]);
|
||||
let stderr = String::from_utf8(out.stderr).unwrap();
|
||||
assert!(stderr.contains("VECTORCRAFT_CONTROL_PORT") && !stderr.contains("unknown option"), "{stderr}");
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ const MAX_DOCUMENT_CELLS: usize = 1 << 18;
|
||||
/// Points of all strokes' pressure curves together (a stroke style may be shared by many layers).
|
||||
const MAX_DOCUMENT_PRESSURE_POINTS: usize = 1 << 20;
|
||||
|
||||
/// Child references inspected, page rectangles produced, and outline segments retained, in all
|
||||
/// (shared objects are visited once per link, so each visit is charged). Real documents stay
|
||||
/// within a few times [`MAX_NODES`]; this leaves ~30x headroom over that.
|
||||
const MAX_DOCUMENT_WORK: usize = 1 << 24;
|
||||
|
||||
/// Owned layer-name characters kept in all (each visit of a shared object keeps its own copy).
|
||||
const MAX_DOCUMENT_NAME_CHARS: usize = 16 << 20;
|
||||
|
||||
/// What a whole read may still use. Shared objects can be linked from many layers, and embedded
|
||||
/// documents are read with what their embedding document left, so linking or embedding the
|
||||
/// same content many times can't multiply the work.
|
||||
@@ -44,6 +52,10 @@ pub(crate) struct Budget {
|
||||
pub(crate) cells: usize,
|
||||
/// Points of pressure curves ([`MAX_DOCUMENT_PRESSURE_POINTS`] in all).
|
||||
pub(crate) pressure: usize,
|
||||
/// Child references, page rectangles and outline segments ([`MAX_DOCUMENT_WORK`] in all).
|
||||
pub(crate) work: usize,
|
||||
/// Layer-name characters ([`MAX_DOCUMENT_NAME_CHARS`] in all).
|
||||
pub(crate) name_chars: usize,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
@@ -53,7 +65,15 @@ impl Budget {
|
||||
chars: MAX_DOCUMENT_CHARS,
|
||||
cells: MAX_DOCUMENT_CELLS,
|
||||
pressure: MAX_DOCUMENT_PRESSURE_POINTS,
|
||||
work: MAX_DOCUMENT_WORK,
|
||||
name_chars: MAX_DOCUMENT_NAME_CHARS,
|
||||
};
|
||||
|
||||
/// Take `n` units of work, or fail the read.
|
||||
pub(crate) fn spend(&mut self, n: usize) -> Result<(), Error> {
|
||||
self.work = self.work.checked_sub(n).ok_or(Error::Limit("too much shared geometry or page data"))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a document: archive, `doc.dat` and the model.
|
||||
@@ -192,6 +212,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
return Ok(None);
|
||||
}
|
||||
let pages = pages(s, spread);
|
||||
self.budget.spend(pages.len() + 1)?;
|
||||
let default_size = s.floats::<2>(doc, b"DfSz").filter(|[w, h]| *w > 0.0 && *h > 0.0).map(|[w, h]| Rect { x0: 0.0, y0: 0.0, x1: w, y1: h });
|
||||
let bounds = s.floats::<4>(spread, b"SprB").map(rect).or_else(|| pages.iter().copied().reduce(union)).or(default_size).unwrap_or(Rect {
|
||||
x0: 0.0,
|
||||
@@ -214,7 +235,10 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let s = self.s;
|
||||
let mut out = Vec::new();
|
||||
let mut mask = None;
|
||||
for c in s.objs(parent, b"Chld") {
|
||||
let kids = s.objs(parent, b"Chld");
|
||||
// Invisible masks and skipped children cost no node, so the visit is charged here.
|
||||
self.budget.spend(kids.len())?;
|
||||
for c in kids {
|
||||
if s.is(c, b"MRst") {
|
||||
// A mask layer inside a group or layer masks that container.
|
||||
if s.bool(c, b"Visi") == Some(false) {
|
||||
@@ -254,7 +278,8 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
let world = local.then(parent);
|
||||
let class = s.class(id).unwrap_or(Tag(0));
|
||||
// A layer name is short; a long one is cut, as every visit of a shared object keeps a copy.
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS).collect();
|
||||
let name: String = s.str(id, b"Desc").unwrap_or_default().chars().take(MAX_NAME_CHARS.min(self.budget.name_chars)).collect();
|
||||
self.budget.name_chars -= name.chars().count();
|
||||
let visible = s.bool(id, b"Visi").unwrap_or(true);
|
||||
let locked = s.bool(id, b"Edtb").is_some_and(|e| !e);
|
||||
let opacity = s.f64(id, b"Opac").map_or(1.0, |o| o.clamp(0.0, 1.0));
|
||||
@@ -312,6 +337,7 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
self.warn(format!("a {} that could not be read", describe(class)));
|
||||
return Ok(Kind::Unsupported);
|
||||
};
|
||||
self.budget.spend(local.subpaths.iter().map(|sp| sp.segments.len() + 1).sum())?;
|
||||
let (fills, strokes) = paint::node_paint(self, id, world);
|
||||
Kind::Shape { path: local.transformed(world), fills, strokes, even_odd }
|
||||
}
|
||||
@@ -645,3 +671,15 @@ impl<'s, 'a, 'b> Reader<'s, 'a, 'b> {
|
||||
Kind::Artboard { rect, path: path.transformed(world), even_odd, background, strokes }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn shared_work_is_bounded() {
|
||||
let mut b = Budget::FULL;
|
||||
assert!(b.spend(MAX_DOCUMENT_WORK).is_ok());
|
||||
assert_eq!(b.spend(1), Err(Error::Limit("too much shared geometry or page data")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -238,12 +238,13 @@ pub(crate) fn descriptor(r: &mut Reader, id: ObjId, local: Affine) -> Option<Pai
|
||||
};
|
||||
let grad = s.obj(fill, b"Grad")?;
|
||||
let cols = s.objs(grad, b"Cols");
|
||||
let posn: Vec<Vec<f64>> = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(floats).collect(),
|
||||
_ => Vec::new(),
|
||||
// Convert only the positions that pair with a colour, as shared attributes repeat this per run.
|
||||
let posn: &[Value] = match s.field(grad, b"Posn") {
|
||||
Some(Value::Array(a)) => a,
|
||||
_ => &[],
|
||||
};
|
||||
let mut stops = Vec::new();
|
||||
for (c, p) in cols.iter().zip(&posn).take(MAX_STOPS) {
|
||||
for (c, p) in cols.iter().zip(posn.iter().filter_map(floats)).take(MAX_STOPS) {
|
||||
let color = color(r, *c)?;
|
||||
let offset = p.first().copied().filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
|
||||
let bias = p.get(1).copied().filter(|v| v.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0);
|
||||
|
||||
@@ -195,6 +195,11 @@ fn decode_within(r: &mut Reader, bitmap: ObjId, crop: (u32, u32, u32, u32), worl
|
||||
if let Some(name) = s.entry(bitmap, b"Bckg") {
|
||||
let width = s.int(bitmap, b"BmpW").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| u32::try_from(v).ok()).unwrap_or(0);
|
||||
// The whole source is decoded for every layer that reads it, so charge it all, not only the crop.
|
||||
let Some(left) = r.budget.pixels.checked_sub(u64::from(width) * u64::from(height)) else {
|
||||
return Err(Error::Limit("embedded image pixels"));
|
||||
};
|
||||
r.budget.pixels = left;
|
||||
match original(r, name).and_then(|bytes| source_pixels(&bytes, width, height)) {
|
||||
Ok(pixels) => Some((width as usize, pixels)),
|
||||
Err(Error::Limit(e)) => return Err(Error::Limit(e)),
|
||||
@@ -318,8 +323,10 @@ fn grid(s: &stream::Stream, bitmap: ObjId, c: usize, bps: usize) -> (usize, usiz
|
||||
let height = s.int(bitmap, b"BmpH").and_then(|v| usize::try_from(v).ok()).unwrap_or(0);
|
||||
let tw = s.int(bitmap, &tag(b"TWi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| width.saturating_mul(bps).div_ceil(TILE));
|
||||
let th = s.int(bitmap, &tag(b"THi", c)).and_then(|v| usize::try_from(v).ok()).unwrap_or_else(|| height.div_ceil(TILE));
|
||||
// A grid larger than the bitmap has tiles no crop can reach: don't convert or scan them on every reference.
|
||||
let tiles = width.saturating_mul(bps).div_ceil(TILE).saturating_mul(height.div_ceil(TILE));
|
||||
let states: Vec<u8> = match s.field(bitmap, &tag(b"Sta", c)) {
|
||||
Some(Value::Array(a)) => a.iter().map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
Some(Value::Array(a)) => a.iter().take(tiles).map(|v| if let Value::UInt(u) = v { u8::try_from(*u).unwrap_or(255) } else { 255 }).collect(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
(tw, th, states)
|
||||
|
||||
@@ -51,6 +51,9 @@ const MAX_TABLE_LINES: usize = 4096;
|
||||
/// Cells a table may have (rows × columns): each becomes a text object with its edge lines.
|
||||
const MAX_TABLE_CELLS: usize = 1 << 16;
|
||||
|
||||
/// Work charged for each copy of a run's formatting a table cell keeps.
|
||||
const TABLE_RUN_COST: usize = 16;
|
||||
|
||||
/// A table node (`TxtT`): the grid, the cells' text and the lines on the cell edges.
|
||||
pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let s = r.s;
|
||||
@@ -108,9 +111,10 @@ pub(crate) fn table(r: &mut Reader, id: ObjId, world: Affine) -> Option<Table> {
|
||||
let mut parts = run.text.split(CELL_BREAK).peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if !part.is_empty() {
|
||||
// Each cell keeps its own copy of the run's formatting, so each copy is charged.
|
||||
match target.and_then(|i| cells.get_mut(i)) {
|
||||
Some(cell) => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
None => extra = true,
|
||||
Some(cell) if r.budget.spend(TABLE_RUN_COST).is_ok() => cell.runs.push(TextRun { text: part.to_string(), ..run.clone() }),
|
||||
_ => extra = true,
|
||||
}
|
||||
}
|
||||
if parts.peek().is_some() {
|
||||
@@ -157,6 +161,13 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
let mut placeholders = false;
|
||||
let mut attribute_budget = Some(MAX_ATTRIBUTE_BYTES);
|
||||
for block in s.objs(story, b"Blok") {
|
||||
// Shared blocks and run lists are visited once per link, so every visit is charged, empty or not.
|
||||
let paragraph_runs = s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default();
|
||||
let attribute_runs = s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default();
|
||||
if r.budget.spend(1 + paragraph_runs.len() + attribute_runs.len()).is_err() {
|
||||
r.warn("text beyond the work one document may hold (truncated)");
|
||||
break;
|
||||
}
|
||||
let Some(glyphs) = s.obj(block, b"Glyp") else {
|
||||
r.warn("a text block without readable characters (left out)");
|
||||
continue;
|
||||
@@ -209,7 +220,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
// Paragraph formats by end index; a run without an index covers the rest of the block.
|
||||
let mut paragraphs: Vec<(usize, Paragraph)> = Vec::new();
|
||||
let mut open: Option<Paragraph> = None;
|
||||
for run in s.obj(block, b"PAtt").map(|p| s.objs(p, b"Runs")).unwrap_or_default() {
|
||||
for run in paragraph_runs {
|
||||
let Some(attrs) = s.obj(run, b"Item") else { continue };
|
||||
let a = paragraph(r, attrs);
|
||||
first_align.get_or_insert(a.align);
|
||||
@@ -238,7 +249,7 @@ pub(crate) fn story_runs(r: &mut Reader, story: ObjId, world: Affine) -> (Vec<Te
|
||||
}
|
||||
};
|
||||
let mut start = 0usize;
|
||||
for run in s.obj(block, b"GAtt").map(|g| s.objs(g, b"Runs")).unwrap_or_default() {
|
||||
for run in attribute_runs {
|
||||
let Some(index) = s.int(run, b"Indx").and_then(|v| usize::try_from(v).ok()) else {
|
||||
r.warn("invalid text attribute range (bounded; unassigned characters use defaults)");
|
||||
continue;
|
||||
|
||||
@@ -308,6 +308,78 @@ impl Default for DxfOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// A named set of DXF options (the `dxfPreset` param): the options it sets, and the ones a set of
|
||||
/// options matches.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum DxfPreset {
|
||||
/// The options as chosen (the export's defaults for those left out).
|
||||
#[default]
|
||||
Custom,
|
||||
/// For CNC and CAM cutting software: version 2000 (the oldest version that has both lightweight
|
||||
/// polylines and lineweights), 1 mm = 1 unit, 256 indexed colors (all version 2000 holds), Maximize
|
||||
/// Editability with Alter Paths off (every stroke a center line), Outline Text (type as paths),
|
||||
/// and placed images as PNG.
|
||||
Cnc,
|
||||
}
|
||||
|
||||
impl DxfPreset {
|
||||
pub const ALL: [Self; 2] = [Self::Custom, Self::Cnc];
|
||||
|
||||
/// The `dxfPreset` param value.
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Custom => "custom",
|
||||
Self::Cnc => "cnc",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Custom => "Custom",
|
||||
Self::Cnc => "CNC / CAM",
|
||||
}
|
||||
}
|
||||
|
||||
/// A preset by id (`custom`, `cnc`), any case.
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|p| s.trim().eq_ignore_ascii_case(p.id()))
|
||||
}
|
||||
|
||||
/// The options the preset sets (Custom: the defaults); the region and crop are the defaults.
|
||||
pub fn options(self) -> DxfOptions {
|
||||
match self {
|
||||
Self::Custom => DxfOptions::default(),
|
||||
Self::Cnc => DxfOptions {
|
||||
version: DxfVersion::R2000,
|
||||
unit: Unit::Millimeters,
|
||||
scale: 1.0,
|
||||
scale_lineweights: false,
|
||||
colors: ColorDepth::Aci256,
|
||||
raster: RasterFormat::Png,
|
||||
preserve: Preserve::Editability,
|
||||
alter_paths: false,
|
||||
outline_text: true,
|
||||
..DxfOptions::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// The preset `o` matches: CNC / CAM when every option it sets is as it sets it, else Custom.
|
||||
pub fn of(o: &DxfOptions) -> Self {
|
||||
let c = Self::Cnc.options();
|
||||
let same = o.version == c.version
|
||||
&& o.unit == c.unit
|
||||
&& o.scale == c.scale
|
||||
&& o.scale_lineweights == c.scale_lineweights
|
||||
&& o.colors == c.colors
|
||||
&& o.raster == c.raster
|
||||
&& o.preserve == c.preserve
|
||||
&& o.alter_paths == c.alter_paths
|
||||
&& o.outline_text == c.outline_text;
|
||||
if same { Self::Cnc } else { Self::Custom }
|
||||
}
|
||||
}
|
||||
|
||||
/// An image file a drawing links to, to be written next to it under `name`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DxfImage {
|
||||
|
||||
@@ -823,6 +823,30 @@ fn units_scale_coordinates_and_set_insunits() {
|
||||
assert!(export(&d, &DxfOptions { scale: f64::NAN, ..opts(&d) }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cnc_preset_writes_version_2000_in_millimeters_with_type_as_paths_and_strokes_as_lines() {
|
||||
let mut d = text_doc("Cut", 20.0);
|
||||
let layer = d.layers[0].id;
|
||||
let frame = path(&mut d, shapes::rectangle(Rect::new(5.0, 5.0, 195.0, 95.0)), Paint::None, Paint::solid(Color::BLACK), 10.0);
|
||||
d.insert(Some(layer), usize::MAX, frame).unwrap();
|
||||
let o = DxfOptions { region: d.artboards[0].rect, ..DxfPreset::Cnc.options() };
|
||||
let out = export(&d, &o).unwrap();
|
||||
let p = pairs(&out.bytes);
|
||||
assert_eq!(header(&p, "$ACADVER").as_deref(), Some("AC1015"));
|
||||
assert_eq!(header(&p, "$INSUNITS").as_deref(), Some("4"));
|
||||
let k = kinds(&out);
|
||||
assert!(!k.iter().any(|k| matches!(k.as_str(), "TEXT" | "MTEXT" | "IMAGE")), "type is paths: {k:?}");
|
||||
assert!(k.len() > 2, "the glyphs' outlines: {k:?}");
|
||||
// The 10 pt stroke (wider than the widest lineweight) is one center line, not a filled outline.
|
||||
assert_eq!(k.last().map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
|
||||
assert_ne!(k.get(k.len() - 2).map(String::as_str), Some("LWPOLYLINE"), "{k:?}");
|
||||
assert!(out.images.is_empty());
|
||||
assert_eq!(DxfPreset::of(&o), DxfPreset::Cnc);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions { colors: ColorDepth::True, ..o.clone() }), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::of(&DxfOptions::default()), DxfPreset::Custom);
|
||||
assert_eq!(DxfPreset::from_id(" CNC "), Some(DxfPreset::Cnc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_become_dxf_layers() {
|
||||
let mut d = shapes_doc();
|
||||
|
||||
@@ -296,6 +296,28 @@ impl Appearance {
|
||||
scale(e, s);
|
||||
}
|
||||
}
|
||||
/// The id of Effect › SVG Filters' effect, whose `origin` parameter is a point of the document.
|
||||
pub const SVG_FILTER: &str = "svg.filter";
|
||||
|
||||
/// Does any effect (the object's or a fill's or stroke's) hold an SVG filter?
|
||||
pub fn has_svg_filter(&self) -> bool {
|
||||
self.effects.iter().chain(self.items.iter().flat_map(|i| i.effects().iter())).any(|e| e.id == Self::SVG_FILTER)
|
||||
}
|
||||
|
||||
/// Move the `origin` of every SVG filter effect (where its user space starts in the document)
|
||||
/// by `a`, as the object it is on moves.
|
||||
pub fn transform_svg_filters(&mut self, a: kurbo::Affine) {
|
||||
let items = self.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in self.effects.iter_mut().chain(items).filter(|e| e.id == Self::SVG_FILTER) {
|
||||
let at =
|
||||
|i: usize| e.params.get("origin").and_then(|o| o.get(i)).and_then(serde_json::Value::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0);
|
||||
let p = a * kurbo::Point::new(at(0), at(1));
|
||||
if p.x.is_finite() && p.y.is_finite() && e.params.is_object() {
|
||||
e.params["origin"] = serde_json::json!([p.x, p.y]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gradient paints of the fills and strokes.
|
||||
fn gradients_mut(&mut self) -> impl Iterator<Item = &mut vectorcraft_color::GradientPaint> {
|
||||
self.items.iter_mut().filter_map(|i| match i {
|
||||
|
||||
@@ -106,6 +106,60 @@ impl super::Document {
|
||||
d.layers.insert(0, Arc::new(layer));
|
||||
Some(d)
|
||||
}
|
||||
|
||||
/// Where an artboard the size of artboard `i` goes on `side` of it, [`ARTBOARD_GAP`] away,
|
||||
/// when there is room for it there: every side within [`crate::MAX_COORD`] of the origin and
|
||||
/// no other artboard overlapping it (touching edges don't overlap). `None` when there is no
|
||||
/// such artboard or no room.
|
||||
pub fn room_beside_artboard(&self, i: usize, side: ArtboardSide) -> Option<Rect> {
|
||||
let src = self.artboards.get(i)?.rect;
|
||||
let r = src + side.offset(src);
|
||||
let in_canvas = [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= crate::MAX_COORD);
|
||||
let clear = self.artboards.iter().all(|a| !(r.x0 < a.rect.x1 && a.rect.x0 < r.x1 && r.y0 < a.rect.y1 && a.rect.y0 < r.y1));
|
||||
(in_canvas && clear).then_some(r)
|
||||
}
|
||||
}
|
||||
|
||||
/// The space between an artboard and one added or duplicated beside it (VectorCraft's own value,
|
||||
/// the space New Artboard and Duplicate Artboards leave right of the last artboard).
|
||||
pub const ARTBOARD_GAP: f64 = 20.0;
|
||||
|
||||
/// A side of an artboard, where an artboard added or duplicated beside it goes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArtboardSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl ArtboardSide {
|
||||
pub const ALL: [Self; 4] = [Self::Top, Self::Right, Self::Bottom, Self::Left];
|
||||
|
||||
/// The side a command's `side` param names: "left", "right", "top" or "bottom".
|
||||
pub fn parse(name: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|s| s.name() == name)
|
||||
}
|
||||
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Left => "left",
|
||||
Self::Right => "right",
|
||||
Self::Top => "top",
|
||||
Self::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a copy of `r` moves to sit on this side of it, [`ARTBOARD_GAP`] away (y down).
|
||||
pub fn offset(self, r: Rect) -> vectorcraft_geom::Vec2 {
|
||||
let (w, h) = (r.width() + ARTBOARD_GAP, r.height() + ARTBOARD_GAP);
|
||||
match self {
|
||||
Self::Left => vectorcraft_geom::Vec2::new(-w, 0.0),
|
||||
Self::Right => vectorcraft_geom::Vec2::new(w, 0.0),
|
||||
Self::Top => vectorcraft_geom::Vec2::new(0.0, -h),
|
||||
Self::Bottom => vectorcraft_geom::Vec2::new(0.0, h),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document's locked artboards ([`Document::locked_artboards`]).
|
||||
|
||||
@@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::{Document, NodeId};
|
||||
|
||||
/// One asset: what it is called (its files' names) and the objects it exports, cropped to their
|
||||
/// visual bounds.
|
||||
/// visual bounds, or for a 3D asset its object's solids as a mesh file.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ExportAsset {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub nodes: Vec<NodeId>,
|
||||
/// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D
|
||||
/// file. `None` for art exported as pictures.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub format_3d: Option<String>,
|
||||
}
|
||||
|
||||
/// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`…
|
||||
@@ -30,7 +34,16 @@ impl Document {
|
||||
|
||||
/// The name of the next unnamed asset: one more than the highest `Asset N` so far.
|
||||
pub fn next_asset_name(&self) -> String {
|
||||
let n = self.assets.iter().filter_map(|a| a.name.strip_prefix(ASSET)?.parse::<u64>().ok()).max().unwrap_or(0);
|
||||
let n = self
|
||||
.assets
|
||||
.iter()
|
||||
.filter_map(|a| {
|
||||
let digits = a.name.strip_prefix(ASSET)?;
|
||||
// `parse` alone would accept a leading `+`.
|
||||
digits.bytes().all(|b| b.is_ascii_digit()).then(|| digits.parse::<u64>().ok()).flatten()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
format!("{ASSET}{}", n.saturating_add(1))
|
||||
}
|
||||
|
||||
@@ -57,3 +70,23 @@ impl Document {
|
||||
v.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn name_after(names: &[&str]) -> String {
|
||||
let mut doc = Document::new(100.0, 100.0);
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
doc.assets.push(ExportAsset { id: i as u64 + 1, name: (*n).to_string(), nodes: vec![], format_3d: None });
|
||||
}
|
||||
doc.next_asset_name()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn next_asset_name_ignores_non_digit_suffixes() {
|
||||
assert_eq!(name_after(&["Asset +5"]), "Asset 1");
|
||||
assert_eq!(name_after(&["Asset ", "Asset 5x"]), "Asset 1");
|
||||
assert_eq!(name_after(&["Asset 5"]), "Asset 6");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,8 +313,8 @@ mod tests {
|
||||
d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }];
|
||||
d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into();
|
||||
d.assets = vec![
|
||||
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] },
|
||||
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] },
|
||||
crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None },
|
||||
crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None },
|
||||
];
|
||||
d.variables.bindings.insert(NodeId(11), ["v".to_string()].into());
|
||||
d.variables.bindings.insert(NodeId(10), ["w".to_string()].into());
|
||||
|
||||
@@ -68,6 +68,7 @@ impl Document {
|
||||
print_setup,
|
||||
assets,
|
||||
svg_scripts,
|
||||
svg_filters,
|
||||
puppet,
|
||||
new_art,
|
||||
dimensions,
|
||||
@@ -135,6 +136,7 @@ impl Document {
|
||||
&& *print_setup == other.print_setup
|
||||
&& *assets == other.assets
|
||||
&& *svg_scripts == other.svg_scripts
|
||||
&& *svg_filters == other.svg_filters
|
||||
&& match (puppet, &other.puppet) {
|
||||
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||
(a, b) => a.is_none() && b.is_none(),
|
||||
@@ -245,6 +247,7 @@ impl Document {
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
svg_filters: vec![],
|
||||
puppet: None,
|
||||
new_art: Default::default(),
|
||||
dimensions: false,
|
||||
|
||||
@@ -123,6 +123,39 @@ impl Document {
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions,
|
||||
/// graph designs, pattern swatches (the one being edited included) and their opacity masks?
|
||||
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
|
||||
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
|
||||
let mut any = false;
|
||||
for root in roots {
|
||||
crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n));
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
/// Change with `f` every node for which `wants` holds, anywhere in the document's art (as
|
||||
/// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art
|
||||
/// they share.
|
||||
pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
edit_branches_where(root, &wants, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round every path's anchors and handles to `decimals` decimal places: path objects (with
|
||||
/// Live Corners' uncut outlines), blend spines, envelopes' top-object outlines and Intertwine
|
||||
/// areas, in the layers, symbol definitions, graph designs, pattern swatches (the one being
|
||||
@@ -164,7 +197,7 @@ impl Document {
|
||||
}
|
||||
}
|
||||
NodeKind::Envelope { kind, .. } => {
|
||||
if let EnvelopeKind::TopObject { path } = kind.as_mut() {
|
||||
if let EnvelopeKind::TopObject { path, .. } = kind.as_mut() {
|
||||
round_path(path);
|
||||
}
|
||||
}
|
||||
@@ -194,6 +227,26 @@ impl Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// Change with `f` the nodes of `n`'s tree (opacity-mask art included) for which `wants` holds,
|
||||
/// copying out of the shared art only the branches that hold one.
|
||||
fn edit_branches_where(n: &mut Arc<Node>, wants: &impl Fn(&Node) -> bool, f: &mut impl FnMut(&mut Node)) {
|
||||
let mut any = false;
|
||||
crate::swatches::walk_with_masks(n, &mut |c| any |= wants(c));
|
||||
if !any {
|
||||
return;
|
||||
}
|
||||
let node = Arc::make_mut(n);
|
||||
if wants(node) {
|
||||
f(node);
|
||||
}
|
||||
if let Some(m) = node.extras_if_any().and_then(|e| e.mask.as_mut()) {
|
||||
edit_branches_where(&mut m.art, wants, f);
|
||||
}
|
||||
for c in node.children_mut().into_iter().flatten() {
|
||||
edit_branches_where(c, wants, f);
|
||||
}
|
||||
}
|
||||
|
||||
/// Round with `round` the anchors of the paths in `v`, nodes in their file form kept as JSON: every
|
||||
/// `{"subpaths": [{"anchors": [{"p", "in", "out"}]}]}` outside type (`{"type": "text"}`), as
|
||||
/// [`Document::round_geometry`] rounds the nodes it holds.
|
||||
|
||||
@@ -936,6 +936,257 @@ fn hit_any(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Option
|
||||
}
|
||||
}
|
||||
|
||||
/// The art of a symbol instance with its own appearance changes, in the symbol's coordinates, and
|
||||
/// how the canvas places each object of it: through the symbol's 9-slice guides when the instance
|
||||
/// is scaled by them ([`Document::place_symbol_art`]), otherwise by the instance's transform.
|
||||
struct InstanceArt {
|
||||
art: Node,
|
||||
map: Option<crate::NineSliceMap>,
|
||||
xf: vectorcraft_geom::Affine,
|
||||
}
|
||||
|
||||
impl InstanceArt {
|
||||
/// The art of symbol instance `inst`. `None` for anything else, a missing symbol, or art that
|
||||
/// placing through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of,
|
||||
/// which [`Document::place_symbol_art`] does not place and the canvas does not draw.
|
||||
fn of(doc: &Document, inst: &Node) -> Option<Self> {
|
||||
let NodeKind::SymbolInstance { symbol, xf } = &inst.kind else { return None };
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol)?;
|
||||
let art = crate::symbol_overrides::dynamic_art(&sym.art, inst);
|
||||
let map = doc.symbol_slice_map(symbol, *xf);
|
||||
if map.as_ref().is_some_and(|m| m.most_placed(&art, doc) > crate::MAX_SYMBOL_NODES) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { art, map, xf: *xf })
|
||||
}
|
||||
|
||||
/// The map that the objects inside group or layer `n` are moved by as one piece: `whole`, the
|
||||
/// map of an object around `n` that is moved as one piece, or else the map of the 9-slice cell
|
||||
/// `n` lies in ([`crate::NineSliceMap::one_cell`]). `None` when `n` is placed through the guides
|
||||
/// object by object.
|
||||
fn whole(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<vectorcraft_geom::Affine> {
|
||||
whole.or_else(|| self.map.as_ref()?.one_cell(n, doc))
|
||||
}
|
||||
|
||||
/// Object `n` of the art placed on the canvas as it is drawn, clip groups included: moved by
|
||||
/// `whole` when an object around it is moved as one piece ([`Self::whole`]). `None` when
|
||||
/// placing it through the guides could make more than [`crate::MAX_SYMBOL_NODES`] objects, as
|
||||
/// [`Document::place_symbol_art`] refuses it.
|
||||
fn place(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place(n, doc)),
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Object `n` of the art placed on the canvas without clip groups
|
||||
/// ([`crate::NineSliceMap::place_unclipped`]), as [`Self::place`] places it and within the
|
||||
/// same limit ([`Document::place_symbol_art_unclipped`]).
|
||||
fn place_unclipped(&self, n: &Node, doc: &Document, whole: Option<vectorcraft_geom::Affine>) -> Option<Node> {
|
||||
match (&self.map, whole) {
|
||||
(_, Some(a)) => Some(moved(n, a)),
|
||||
(Some(m), None) => (m.most_placed_unclipped(n, doc) <= crate::MAX_SYMBOL_NODES).then(|| m.place_unclipped(n, doc)),
|
||||
(None, None) => Some(moved(n, self.xf)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` transformed by `xf`, its strokes at their own weight.
|
||||
fn moved(n: &Node, xf: vectorcraft_geom::Affine) -> Node {
|
||||
let mut out = n.clone();
|
||||
out.transform(xf, false);
|
||||
out
|
||||
}
|
||||
|
||||
/// The object of symbol instance `inst`'s art under `p`, for Direct Selection on a dynamic symbol's
|
||||
/// instance: its path of child indices from the art's root ([`crate::symbol_overrides::part`]), the
|
||||
/// topmost object hit inside the art's groups. With [`HitOptions::clip_outlines`], a press on the
|
||||
/// outline of the clipping path of a clip group or a clipping layer picks that path before the art
|
||||
/// it clips, as [`hit_test`] does outside symbols. Hidden, locked and template objects are passed
|
||||
/// over, as [`hit_test`] passes over them when it looks for editable objects, and so are a Shaper
|
||||
/// group's original shapes, which the canvas does not draw, and guide paths with
|
||||
/// [`HitOptions::skip_guides`]. Nothing is picked in art that placing through the symbol's 9-slice
|
||||
/// guides could make more than [`crate::MAX_SYMBOL_NODES`] objects of, which the canvas does not
|
||||
/// draw. `None` when `inst` is not a symbol instance or no editable object of its art is under `p`.
|
||||
pub fn symbol_part_at(doc: &Document, inst: &Node, p: Point, opt: HitOptions) -> Option<Vec<u32>> {
|
||||
struct Find<'a> {
|
||||
doc: &'a Document,
|
||||
art: &'a InstanceArt,
|
||||
p: Point,
|
||||
opt: HitOptions,
|
||||
symbols: &'a SymbolBoxes<'a>,
|
||||
}
|
||||
impl Find<'_> {
|
||||
fn hit(&self, n: &Node, whole: Option<vectorcraft_geom::Affine>) -> bool {
|
||||
self.art.place(n, self.doc, whole).is_some_and(|n| hit_any(&n, self.p, self.opt, self.symbols).is_some())
|
||||
}
|
||||
fn skips_guide(&self, n: &Node) -> bool {
|
||||
self.opt.skip_guides && matches!(n.kind, NodeKind::Path { guide: true, .. })
|
||||
}
|
||||
fn find(&self, n: &Node, path: &mut Vec<u32>, whole: Option<vectorcraft_geom::Affine>) -> bool {
|
||||
if !n.visible || n.locked || n.is_template() || self.skips_guide(n) {
|
||||
return false;
|
||||
}
|
||||
let (NodeKind::Group { children, .. } | NodeKind::Layer { children, .. }) = &n.kind else { return self.hit(n, whole) };
|
||||
if path.len() >= crate::symbol_overrides::MAX_PATH_DEPTH {
|
||||
return self.hit(n, whole);
|
||||
}
|
||||
let whole = self.art.whole(n, self.doc, whole);
|
||||
if n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
{
|
||||
let Some(clip) = self.art.place_unclipped(first, self.doc, whole) else { return false };
|
||||
if self.opt.clip_outlines
|
||||
&& first.visible
|
||||
&& !first.locked
|
||||
&& !self.skips_guide(first)
|
||||
&& clip_outline(&clip, self.p, self.opt).is_some()
|
||||
{
|
||||
path.push(0);
|
||||
return true;
|
||||
}
|
||||
if !clip_contains(&clip, self.p) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (i, c) in children.iter().enumerate().rev() {
|
||||
// A Shaper group's original shapes are not drawn; symbol art has no isolation scope.
|
||||
if shaper_hides(n, i, None) {
|
||||
continue;
|
||||
}
|
||||
let Ok(i) = u32::try_from(i) else { continue };
|
||||
path.push(i);
|
||||
if self.find(c, path, whole) {
|
||||
return true;
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
let art = InstanceArt::of(doc, inst)?;
|
||||
let symbols = doc.symbol_boxes();
|
||||
let mut path = vec![];
|
||||
Find { doc, art: &art, p, opt, symbols: &symbols }.find(&art.art, &mut path, None).then_some(path)
|
||||
}
|
||||
|
||||
/// The outlines of the object at `path` in symbol instance `inst`'s art (see [`symbol_part_at`]),
|
||||
/// placed on the canvas as it is drawn: its paths' outlines, and the bounds of objects without a
|
||||
/// path. Inside a clip group (an object drawn once in each 9-slice cell it reaches is one clip
|
||||
/// group per cell) they are cut to the bounds of the group's clip path, which is not outlined. An
|
||||
/// object inside a clip group or a clipping layer of the art, other than its clipping path, is cut
|
||||
/// to the bounds of that clipping path as well, which contain the region where [`symbol_part_at`]
|
||||
/// picks it.
|
||||
/// `None` for art past the 9-slice object limit that [`symbol_part_at`] applies.
|
||||
pub fn symbol_part_outline(doc: &Document, inst: &Node, path: &[u32]) -> Option<BezPath> {
|
||||
let art = InstanceArt::of(doc, inst)?;
|
||||
let part = crate::symbol_overrides::part(&art.art, path)?;
|
||||
let mut out = BezPath::new();
|
||||
let mut limit = None;
|
||||
let mut whole = None;
|
||||
let mut n = &art.art;
|
||||
for &i in path {
|
||||
let children = n.children()?;
|
||||
whole = art.whole(n, doc, whole);
|
||||
if i != 0
|
||||
&& n.clips()
|
||||
&& let Some(first) = children.first()
|
||||
&& let Some(clip) = art.place_unclipped(first, doc, whole)?.geometric_bounds()
|
||||
{
|
||||
match meet(Some(clip), limit) {
|
||||
Some(r) => limit = Some(r),
|
||||
None => return Some(out),
|
||||
}
|
||||
}
|
||||
n = children.get(usize::try_from(i).ok()?)?;
|
||||
}
|
||||
outline_within(&art.place(part, doc, whole)?, limit, &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// [`symbol_part_outline`] for placed object `n`, cut to `limit` when there is one.
|
||||
fn outline_within(n: &Node, limit: Option<Rect>, out: &mut BezPath) {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
if let Some(pd) = n.path_data() {
|
||||
match limit {
|
||||
Some(r) => push_cut(out, &pd.to_bezpath(), r),
|
||||
None => out.extend(pd.to_bezpath()),
|
||||
}
|
||||
} else if n.children().is_none()
|
||||
&& let Some(b) = n.geometric_bounds()
|
||||
&& let Some(b) = meet(Some(b), limit)
|
||||
{
|
||||
out.extend(b.to_path(0.1));
|
||||
}
|
||||
let Some(children) = n.children() else { return };
|
||||
if n.clips()
|
||||
&& let Some((first, rest)) = children.split_first()
|
||||
{
|
||||
let inner = match first.geometric_bounds() {
|
||||
Some(c) => match meet(Some(c), limit) {
|
||||
Some(r) => Some(r),
|
||||
None => return,
|
||||
},
|
||||
None => limit,
|
||||
};
|
||||
rest.iter().for_each(|c| outline_within(c, inner, out));
|
||||
return;
|
||||
}
|
||||
children.iter().for_each(|c| outline_within(c, limit, out));
|
||||
}
|
||||
|
||||
/// The overlap of `a` and `b` (`a` alone without `b`), `None` when they do not overlap.
|
||||
fn meet(a: Option<Rect>, b: Option<Rect>) -> Option<Rect> {
|
||||
let a = a?;
|
||||
let Some(b) = b else { return Some(a) };
|
||||
let r = Rect::new(a.x0.max(b.x0), a.y0.max(b.y0), a.x1.min(b.x1), a.y1.min(b.y1));
|
||||
(r.x0 <= r.x1 && r.y0 <= r.y1).then_some(r)
|
||||
}
|
||||
|
||||
/// The parts of path `p`, flattened to lines, that lie inside `r`, appended to `out`.
|
||||
fn push_cut(out: &mut BezPath, p: &BezPath, r: Rect) {
|
||||
use vectorcraft_geom::kurbo::PathEl;
|
||||
let (mut at, mut start, mut last) = (Point::ZERO, Point::ZERO, None::<Point>);
|
||||
vectorcraft_geom::kurbo::flatten(p.iter(), 0.1, |el| {
|
||||
let to = match el {
|
||||
PathEl::MoveTo(q) => {
|
||||
(at, start, last) = (q, q, None);
|
||||
return;
|
||||
}
|
||||
PathEl::LineTo(q) => q,
|
||||
PathEl::ClosePath => start,
|
||||
_ => return,
|
||||
};
|
||||
if let Some((a, b)) = cut_segment(at, to, r) {
|
||||
if last != Some(a) {
|
||||
out.move_to(a);
|
||||
}
|
||||
out.line_to(b);
|
||||
last = Some(b);
|
||||
}
|
||||
at = to;
|
||||
});
|
||||
}
|
||||
|
||||
/// The part of segment `a` to `b` inside `r`, `None` when none of it is.
|
||||
fn cut_segment(a: Point, b: Point, r: Rect) -> Option<(Point, Point)> {
|
||||
let d = b - a;
|
||||
let (mut t0, mut t1) = (0.0_f64, 1.0_f64);
|
||||
for (p, q) in [(-d.x, a.x - r.x0), (d.x, r.x1 - a.x), (-d.y, a.y - r.y0), (d.y, r.y1 - a.y)] {
|
||||
if p.abs() < 1e-12 {
|
||||
if q < 0.0 {
|
||||
return None;
|
||||
}
|
||||
} else if p < 0.0 {
|
||||
t0 = t0.max(q / p);
|
||||
} else {
|
||||
t1 = t1.min(q / p);
|
||||
}
|
||||
}
|
||||
(t0 <= t1).then(|| (a + d * t0, a + d * t1))
|
||||
}
|
||||
|
||||
/// The path of compound path `n` under `p`, for the Group Selection tool: the one whose outline is
|
||||
/// within `tol`, else the innermost one whose area holds `p` (a hole inside an outer shape, an
|
||||
/// island inside a hole). `None` when `n` isn't a compound path or no part is under `p`.
|
||||
|
||||
@@ -1373,3 +1373,128 @@ fn cull_box_holds_the_reach_of_paths_and_compound_paths() {
|
||||
assert!(holds(cull, reach), "{cull:?} does not hold {reach:?}");
|
||||
assert_eq!(checked, 201);
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art, a layer with a clipping mask clips its objects as a clip group does:
|
||||
/// Direct Selection picks an object only where the mask shows it, and outlines it there.
|
||||
#[test]
|
||||
fn an_object_a_clipping_layer_in_symbol_art_hides_is_not_picked_there() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (mask, art) = half_clipped(&mut d);
|
||||
let mut layer = Node::layer(d.alloc_id(), "Masked", crate::LayerColor::Preset(2));
|
||||
if let NodeKind::Layer { children, .. } = &mut layer.kind {
|
||||
*children = vec![Arc::new(mask), Arc::new(art)];
|
||||
}
|
||||
layer.set_clips(true);
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(layer)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Clipped".into(), art: Arc::new(root) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Clipped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
let n = d.node(inst).unwrap();
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "where the mask shows the art");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(150.0, 50.0), opt), None, "where the mask hides it");
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0, 1]).unwrap());
|
||||
assert!((b.x1 - 100.0).abs() < 1e-6, "the outline is cut to the mask: {b:?}");
|
||||
// Its outline inside the group around the layer is cut too.
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[0]).unwrap());
|
||||
assert!((b.x1 - 100.0).abs() < 1e-6, "the layer's outline is cut to the mask: {b:?}");
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art that placing through its 9-slice guides could make more than
|
||||
/// [`crate::MAX_SYMBOL_NODES`] objects of, Direct Selection picks and outlines nothing, as the
|
||||
/// canvas draws nothing of it. At the art's natural size the same art is picked.
|
||||
#[test]
|
||||
fn symbol_art_past_the_object_limit_picks_nothing() {
|
||||
use vectorcraft_color::{Gradient, GradientPaint};
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||||
mask.linked = false;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
// Drawn in six cells, each with its own copy of the mask's 3,240,000 squares.
|
||||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 50.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
big.extras_mut().mask = Some(Box::new(mask));
|
||||
let plain = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 50.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(plain), Arc::new(big)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Big".into(), art: Arc::new(root) });
|
||||
d.set_symbol_nine_slice("Big", Some(crate::SymbolNineSlice::default()));
|
||||
let scaled = d.alloc_id();
|
||||
let natural = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||||
d.insert(Some(l), 0, Node::new(scaled, NodeKind::SymbolInstance { symbol: "Big".into(), xf })).unwrap();
|
||||
d.insert(Some(l), 0, Node::new(natural, NodeKind::SymbolInstance { symbol: "Big".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
assert!(d.place_symbol_art("Big", &d.symbols[0].art, xf).is_none(), "the art is past the limit");
|
||||
let opt = HitOptions::default();
|
||||
let n = d.node(scaled).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 110.0), opt), None, "the plain square");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(100.0, 40.0), opt), None, "the masked path");
|
||||
assert!(symbol_part_outline(&d, n, &[0]).is_none());
|
||||
assert!(symbol_part_outline(&d, n, &[1]).is_none());
|
||||
let n = d.node(natural).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 75.0), opt), Some(vec![0]));
|
||||
assert!(symbol_part_outline(&d, n, &[0]).is_some());
|
||||
}
|
||||
|
||||
/// A clip group that lies in one 9-slice cell is moved by that cell as one piece, everything in it
|
||||
/// included, so an object in it is picked and outlined there even when placing that object through
|
||||
/// the guides on its own would pass [`crate::MAX_SYMBOL_NODES`] objects.
|
||||
#[test]
|
||||
fn objects_of_a_clip_group_in_one_cell_are_placed_with_it() {
|
||||
use vectorcraft_color::{Gradient, GradientPaint};
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Arc::new(Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 1.0, 1.0)), Appearance::default_art()));
|
||||
let inner = Arc::new(Node::group(d.alloc_id(), vec![leaf; 1_800]));
|
||||
let mut mask = crate::OpacityMask::new(Node::group(d.alloc_id(), vec![inner; 1_800]), true);
|
||||
mask.linked = false;
|
||||
let gradient = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut big = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(gradient, Paint::None, 0.0));
|
||||
big.extras_mut().mask = Some(Box::new(mask));
|
||||
let clip = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Appearance::default_art());
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(clip), Arc::new(big.clone())]);
|
||||
group.set_clips(true);
|
||||
let frame = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(frame), Arc::new(group)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Corner".into(), art: Arc::new(root) });
|
||||
d.set_symbol_nine_slice("Corner", Some(crate::SymbolNineSlice::default()));
|
||||
let inst = d.alloc_id();
|
||||
let xf = vectorcraft_geom::Affine::scale_non_uniform(2.0, 1.5);
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Corner".into(), xf })).unwrap();
|
||||
let m = d.symbol_slice_map("Corner", xf).unwrap();
|
||||
assert!(m.most_placed(&big, &d) > crate::MAX_SYMBOL_NODES, "the object alone is past the limit");
|
||||
assert!(d.place_symbol_art("Corner", &d.symbols[0].art, xf).is_some(), "the art is drawn");
|
||||
let n = d.node(inst).unwrap();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(10.0, 10.0), HitOptions::default()), Some(vec![1, 1]));
|
||||
let b = vectorcraft_geom::kurbo::Shape::bounding_box(&symbol_part_outline(&d, n, &[1, 1]).unwrap());
|
||||
assert!((b.x1 - 20.0).abs() < 1e-6 && (b.y1 - 20.0).abs() < 1e-6, "cut to the clip in its corner: {b:?}");
|
||||
}
|
||||
|
||||
/// In a dynamic symbol's art, Direct Selection does not pick a Shaper group's original shapes,
|
||||
/// which the canvas does not draw: a click over an original shape whose face the group erased
|
||||
/// picks nothing, and a click over the face it shows picks the face.
|
||||
#[test]
|
||||
fn a_shaper_groups_original_shapes_in_symbol_art_are_not_picked() {
|
||||
let mut d = Document::new(300.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let square = |x: f64| shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0));
|
||||
let a = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
let b = Node::path(d.alloc_id(), square(50.0), Appearance::default_art());
|
||||
let mut sources = Node::group(d.alloc_id(), vec![Arc::new(a), Arc::new(b)]);
|
||||
sources.name = Some(crate::shaper::SOURCES.into());
|
||||
sources.opacity = 0.0;
|
||||
let mut face = Node::path(d.alloc_id(), square(0.0), Appearance::default_art());
|
||||
face.name = Some(format!("{}0", crate::shaper::FILL_PREFIX));
|
||||
let mut group = Node::group(d.alloc_id(), vec![Arc::new(sources), Arc::new(face)]);
|
||||
group.extras_mut().shaper = Some(Box::default());
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(group)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Shaped".into(), art: Arc::new(root) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Shaped".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
let n = d.node(inst).unwrap();
|
||||
let opt = HitOptions::default();
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(130.0, 50.0), opt), None, "over the erased original shape");
|
||||
assert_eq!(symbol_part_at(&d, n, Point::new(50.0, 50.0), opt), Some(vec![0, 1]), "over the shown face");
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ pub use placed_document::{PageBox, PlacedDocument};
|
||||
pub use profiles::ColorProfiles;
|
||||
pub use puppet::{PuppetPin, PuppetPins};
|
||||
pub use rastersettings::{RasterColorModel, RasterEffectsSettings};
|
||||
pub use selection::{AnchorRef, SegmentRef, Selection};
|
||||
pub use selection::{AnchorRef, SegmentRef, Selection, SymbolPart};
|
||||
pub use setup::{Background, DocSetup, ExportText, GridSize, Quotes};
|
||||
pub use slices::{CellAlign, CellVAlign, Slice, SliceArea, SliceKind, SliceOptions, SliceSource};
|
||||
pub use style_libs::StyleLibrary;
|
||||
@@ -141,7 +141,7 @@ mod limits;
|
||||
mod records;
|
||||
mod units;
|
||||
|
||||
pub use artboard::{Artboard, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
|
||||
pub use artboard::{ARTBOARD_GAP, Artboard, ArtboardSide, LockedArtboards, RetiredArtboardId, VIDEO_PIXEL_ASPECT_RANGE, VIDEO_SAFE_INSETS};
|
||||
pub use canvas_range::BeyondCanvas;
|
||||
#[cfg(test)]
|
||||
use document_ext::free_id;
|
||||
@@ -150,7 +150,7 @@ pub use guides::{GridPrefs, Guide, RulerMode};
|
||||
#[cfg(test)]
|
||||
use limits::MAX_NEXT_ID;
|
||||
pub use limits::{DocError, FORMAT_VERSION, MAX_COORD};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, Symbol, SymbolEdit};
|
||||
pub use records::{ImageBlob, MaskEdit, NewArtIds, SavedSelection, SavedView, SvgFilter, Symbol, SymbolEdit};
|
||||
pub use units::{ColorMode, Unit, parse_number};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -310,6 +310,10 @@ pub struct Document {
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Effect › SVG Filters: the SVG filters the document holds (at most [`SvgFilter::MAX`]), in
|
||||
/// the order the submenu lists them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_filters: Vec<SvgFilter>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -174,6 +174,303 @@ mod tests {
|
||||
assert!((c.x - 105.0).abs() < 1e-6 && (c.y + 10.0).abs() < 1e-6, "{c:?}");
|
||||
}
|
||||
|
||||
/// A 100 × 50 outline whose top side runs (0,0) → (30,0) → (100,0) as two straight segments.
|
||||
fn top_split_at_30() -> PathData {
|
||||
let a = |x: f64, y: f64| Anchor::corner(Point::new(x, y));
|
||||
PathData::single(SubPath::new(vec![a(0.0, 0.0), a(30.0, 0.0), a(100.0, 0.0), a(100.0, 50.0), a(0.0, 50.0)], true))
|
||||
}
|
||||
|
||||
/// The length of `c`'s control polygon.
|
||||
fn polygon(c: CubicBez) -> f64 {
|
||||
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
|
||||
}
|
||||
|
||||
/// An anchor and its incoming and outgoing handles.
|
||||
type Knot = ((f64, f64), (f64, f64), (f64, f64));
|
||||
|
||||
/// A closed path through `pts`.
|
||||
fn outline(pts: &[Knot]) -> PathData {
|
||||
let a = |&(p, i, o): &Knot| Anchor { p: p.into(), h_in: i.into(), h_out: o.into(), ..Anchor::corner(p.into()) };
|
||||
PathData::single(SubPath::new(pts.iter().map(a).collect(), true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_straight_segments_spreads_by_length() {
|
||||
// The top side runs (0,0) → (30,0) → (100,0): each straight piece takes its length's share.
|
||||
let kind = EnvelopeKind::TopObject { path: top_split_at_30(), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 100.0, 50.0));
|
||||
for (q, want) in [((50.0, 0.0), (50.0, 0.0)), ((15.0, 0.0), (15.0, 0.0)), ((80.0, 0.0), (80.0, 0.0)), ((50.0, 25.0), (50.0, 25.0))] {
|
||||
let p = map(Point::new(q.0, q.1));
|
||||
assert!(p.distance(Point::new(want.0, want.1)) < 1e-6, "{q:?} → {p:?}, want {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_shares_a_side_by_control_polygon_length() {
|
||||
// Two curves of unequal polygons make the top side; the anchor between them lands at the
|
||||
// first one's share, and inside each curve the content follows its parameter.
|
||||
let first = CubicBez::new((0.0, 0.0), (10.0, -20.0), (30.0, -20.0), (40.0, 0.0));
|
||||
let second = CubicBez::new((40.0, 0.0), (60.0, 10.0), (90.0, 10.0), (100.0, 0.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (10.0, -20.0)),
|
||||
((40.0, 0.0), (30.0, -20.0), (60.0, 10.0)),
|
||||
((100.0, 0.0), (90.0, 10.0), (100.0, 0.0)),
|
||||
((100.0, 80.0), (100.0, 80.0), (100.0, 80.0)),
|
||||
((0.0, 80.0), (0.0, 80.0), (0.0, 80.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(first) / (polygon(first) + polygon(second));
|
||||
assert!(c.eval(share, 0.0).distance(Point::new(40.0, 0.0)) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
for f in [0.25, 0.5, 0.75] {
|
||||
let p = c.eval(share * f, 0.0);
|
||||
assert!(p.distance(first.eval(f)) < 1e-9, "first curve at {f}: {p:?}");
|
||||
let p = c.eval(share + (1.0 - share) * f, 0.0);
|
||||
assert!(p.distance(second.eval(f)) < 1e-9, "second curve at {f}: {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rounded_rectangle_top_object_stays_close_to_the_identity() {
|
||||
// The corners fall at the middle of each corner arc. Each half arc takes the share of its
|
||||
// control polygon, so the straight edges stay close to where the content was.
|
||||
let r = Rect::new(0.0, 0.0, 200.0, 100.0);
|
||||
let path = shapes::rounded_rectangle(r, 10.0);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let [tl, tr, br, bl] = c.corners();
|
||||
let k = 10.0 * (1.0 - std::f64::consts::FRAC_1_SQRT_2);
|
||||
for (p, want) in [(tl, (k, k)), (tr, (200.0 - k, k)), (br, (200.0 - k, 100.0 - k)), (bl, (k, 100.0 - k))] {
|
||||
assert!(p.distance(Point::from(want)) < 0.01, "corner {p:?}, want {want:?}");
|
||||
}
|
||||
for (u, v) in [(0.25, 0.0), (0.5, 0.0), (0.8, 1.0), (0.0, 0.5), (0.5, 0.5), (1.0, 0.3)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(200.0 * u, 100.0 * v)) < 3.0, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
// The top edge starts after the first half arc's share.
|
||||
let sp = &path.subpaths[0];
|
||||
let edge = Point::new(10.0, 0.0);
|
||||
let arc = (0..sp.segment_count())
|
||||
.filter(|i| !sp.segment_is_line(*i))
|
||||
.map(|i| sp.segment(i))
|
||||
.find(|s| s.p0.distance(edge) < 1e-9 || s.p3.distance(edge) < 1e-9)
|
||||
.unwrap();
|
||||
let half = polygon(if arc.p3.distance(edge) < 1e-9 { arc.subsegment(0.5..1.0) } else { arc.subsegment(0.0..0.5) });
|
||||
let p = c.eval(half / (2.0 * half + 180.0), 0.0);
|
||||
assert!(p.distance(Point::new(10.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_inside_a_bulging_curve_is_the_nearest_outline_point() {
|
||||
// The top left anchor's handle bulges out past the corner, so the anchor turns inward
|
||||
// there and the corner falls inside the curve.
|
||||
// The outline's bounding box starts at (-7.957, -22.222); the top curve's point nearest
|
||||
// that corner, found by sampling the curve 2,000,000 times, is (-5.072093, -18.127254).
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), (-50.0, -50.0)),
|
||||
((200.0, 0.0), (150.0, 0.0), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
let want = Point::new(-5.072093, -18.127254);
|
||||
let tl = Coons::from_top_object(&path, None).unwrap().corners()[0];
|
||||
assert!(tl.distance(want) < 1e-5, "{tl:?}, want {want:?}");
|
||||
assert!(tl.distance(Point::ZERO) > 1.0, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_takes_an_anchor_only_near_its_box_corner() {
|
||||
// A chamfered top left corner of a 200 × 100 outline. With both chamfer anchors within
|
||||
// 47.5% of the box from its corner, the nearer anchor is the corner.
|
||||
let chamfer = |a: (f64, f64), b: (f64, f64)| {
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
outline(&[c(a), c(b), c((200.0, 0.0)), c((200.0, 100.0)), c((0.0, 100.0))])
|
||||
};
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 46.0), (94.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(0.0, 46.0)) < 1e-9, "{tl:?}");
|
||||
// Farther out, neither anchor counts: the corner is the chamfer's point nearest the box corner.
|
||||
let tl = Coons::from_top_object(&chamfer((0.0, 50.0), (100.0, 0.0)), None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(20.0, 40.0)) < 1e-6, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_skips_smooth_and_inward_anchors() {
|
||||
// A top left anchor whose outgoing handle turns from its incoming one by `deg` degrees
|
||||
// (positive turns out of the outline). The top side bulges up past it.
|
||||
let shape = |deg: f64| {
|
||||
let a = deg.to_radians();
|
||||
let (hx, hy) = (50.0 * a.cos() + 40.0 * a.sin(), 40.0 * a.cos() - 50.0 * a.sin());
|
||||
outline(&[
|
||||
((50.0, -300.0), (25.0, -280.0), (50.0 + hx, -300.0 - hy)),
|
||||
((250.0, -300.0), (200.0, -340.0), (250.0, -300.0)),
|
||||
((250.0, -200.0), (250.0, -200.0), (250.0, -200.0)),
|
||||
((50.0, -200.0), (50.0, -200.0), (50.0, -230.0)),
|
||||
])
|
||||
};
|
||||
let corner = |deg: f64| Coons::from_top_object(&shape(deg), None).unwrap().corners()[0];
|
||||
let anchor = Point::new(50.0, -300.0);
|
||||
for deg in [0.0, 0.4, 0.6, -1.5] {
|
||||
assert!(corner(deg).distance(anchor) > 1.0, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
for deg in [0.9, 1.5, 10.0] {
|
||||
assert!(corner(deg).distance(anchor) < 1e-9, "{deg}°: {:?}", corner(deg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_skips_an_inward_anchor_between_straight_segments() {
|
||||
// A 200 × 100 outline of straight segments with a notch at its top left: (40,40) is the
|
||||
// anchor nearest the box corner, but it turns into the outline, so (60,0) is the corner.
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
let path = outline(&[c((60.0, 0.0)), c((200.0, 0.0)), c((200.0, 100.0)), c((0.0, 100.0)), c((0.0, 60.0)), c((40.0, 40.0))]);
|
||||
let tl = Coons::from_top_object(&path, None).unwrap().corners()[0];
|
||||
assert!(tl.distance(Point::new(60.0, 0.0)) < 1e-9, "{tl:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_corner_tie_goes_to_the_first_anchor() {
|
||||
// Both chamfer anchors lie 30 from the box corner: the first one along the outline, in
|
||||
// the order it was drawn, wins.
|
||||
let c = |p: (f64, f64)| (p, p, p);
|
||||
let tl = |pts: &[Knot]| Coons::from_top_object(&outline(pts), None).unwrap().corners()[0];
|
||||
let from_left = tl(&[c((0.0, 30.0)), c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0))]);
|
||||
assert!(from_left.distance(Point::new(0.0, 30.0)) < 1e-9, "{from_left:?}");
|
||||
let from_top = tl(&[c((30.0, 0.0)), c((100.0, 0.0)), c((100.0, 100.0)), c((0.0, 100.0)), c((0.0, 30.0))]);
|
||||
assert!(from_top.distance(Point::new(30.0, 0.0)) < 1e-9, "{from_top:?}");
|
||||
// Drawn counter-clockwise, the anchor met first in that order wins.
|
||||
let back = tl(&[c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0)), c((0.0, 30.0))]);
|
||||
assert!(back.distance(Point::new(30.0, 0.0)) < 1e-9, "{back:?}");
|
||||
let back = tl(&[c((0.0, 30.0)), c((0.0, 100.0)), c((100.0, 100.0)), c((100.0, 0.0)), c((30.0, 0.0))]);
|
||||
assert!(back.distance(Point::new(0.0, 30.0)) < 1e-9, "{back:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflected_top_object_keeps_the_corners_a_file_gives() {
|
||||
// Reflecting the outline across x = 0 keeps its anchors in the same order, now running
|
||||
// counter-clockwise: the corners stay on the same anchors, so the content is reflected too.
|
||||
let mut path = top_split_at_30();
|
||||
path.transform(Affine::FLIP_X);
|
||||
let c = Coons::from_top_object(&path, Some([1, 2, 3, 4])).unwrap();
|
||||
let want = [(-30.0, 0.0), (-100.0, 0.0), (-100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
assert!(c.corners().iter().zip(want).all(|(p, q)| p.distance(q) < 1e-9), "{:?}", c.corners());
|
||||
let p = c.eval(0.5, 0.5);
|
||||
assert!(p.distance(Point::new(-50.0, 17.5)) < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_keeps_the_corners_a_file_gives() {
|
||||
let same = |a: [Point; 4], b: [Point; 4]| a.iter().zip(b).all(|(p, q)| p.distance(q) < 1e-9);
|
||||
let want = [(30.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some([1, 2, 3, 4])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Drawn the other way round, with the corners counted along that order.
|
||||
let mut back = top_split_at_30();
|
||||
back.subpaths[0].reverse();
|
||||
let c = Coons::from_top_object(&back, Some([3, 2, 1, 0])).unwrap();
|
||||
assert!(same(c.corners(), want), "{:?}", c.corners());
|
||||
// Corners that repeat, run out of order or go past the outline are found from it instead.
|
||||
let found = [(0.0, 0.0), (100.0, 0.0), (100.0, 50.0), (0.0, 50.0)].map(Point::from);
|
||||
for bad in [[0, 0, 3, 4], [2, 1, 3, 4], [1, 2, 3, 9]] {
|
||||
let c = Coons::from_top_object(&top_split_at_30(), Some(bad)).unwrap();
|
||||
assert!(same(c.corners(), found), "{bad:?}: {:?}", c.corners());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_split_curve_moves_the_top_object_share_to_its_new_anchor() {
|
||||
// Splitting the top curve at t = 0.3 keeps its shape but changes the polygon shares: the
|
||||
// new anchor lands at the first piece's share, not at u = 0.3.
|
||||
let top = CubicBez::new((0.0, 0.0), (40.0, -40.0), (160.0, -40.0), (200.0, 0.0));
|
||||
let (a, b) = (top.subsegment(0.0..0.3), top.subsegment(0.3..1.0));
|
||||
let path = outline(&[
|
||||
((0.0, 0.0), (0.0, 0.0), a.p1.into()),
|
||||
(a.p3.into(), a.p2.into(), b.p1.into()),
|
||||
((200.0, 0.0), b.p2.into(), (200.0, 0.0)),
|
||||
((200.0, 100.0), (200.0, 100.0), (200.0, 100.0)),
|
||||
((0.0, 100.0), (0.0, 100.0), (0.0, 100.0)),
|
||||
]);
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
let share = polygon(a) / (polygon(a) + polygon(b));
|
||||
assert!(c.eval(share, 0.0).distance(a.p3) < 1e-9, "{:?}", c.eval(share, 0.0));
|
||||
assert!((share - 0.3).abs() > 0.01, "{share}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rectangle_top_object_is_the_identity() {
|
||||
let r = Rect::new(10.0, 20.0, 110.0, 70.0);
|
||||
let c = Coons::from_top_object(&shapes::rectangle(r), None).unwrap();
|
||||
for (u, v) in [(0.125, 0.3), (0.9, 0.05), (0.37, 0.81)] {
|
||||
let p = c.eval(u, v);
|
||||
assert!(p.distance(Point::new(10.0 + 100.0 * u, 20.0 + 50.0 * v)) < 1e-9, "({u}, {v}) → {p:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_straight_line_top_object_maps_onto_its_bounding_box() {
|
||||
let line = PathData::single(SubPath::new(vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 40.0))], false));
|
||||
let p = Coons::from_top_object(&line, None).unwrap().eval(0.25, 0.75);
|
||||
assert!(p.distance(Point::new(25.0, 30.0)) < 1e-9, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_spreads_a_segment_by_its_parameter() {
|
||||
// The top side is one straight segment whose handles sit at 60 and 80: its parameter
|
||||
// reaches the middle at x = 63.75, not at 45.
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(90.0, 0.0))];
|
||||
top[0].h_out = Point::new(60.0, 0.0);
|
||||
top[1].h_in = Point::new(80.0, 0.0);
|
||||
top.extend([Anchor::corner(Point::new(90.0, 40.0)), Anchor::corner(Point::new(0.0, 40.0))]);
|
||||
let kind = EnvelopeKind::TopObject { path: PathData::single(SubPath::new(top, true)), corners: None };
|
||||
let map = envelope_mapper(&kind, Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let p = map(Point::new(5.0, 0.0));
|
||||
assert!(p.distance(Point::new(63.75, 0.0)) < 1e-6, "{p:?}");
|
||||
let p = map(Point::new(5.0, 10.0));
|
||||
assert!(p.distance(Point::new(45.0, 40.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_follows_a_curved_side_by_its_parameter() {
|
||||
// The top side bends down from (0,0) to (100,0) through handles at (20,30) and (90,30):
|
||||
// halfway along its parameter it passes (53.75, 22.5).
|
||||
let mut top = vec![Anchor::corner(Point::new(0.0, 0.0)), Anchor::corner(Point::new(100.0, 0.0))];
|
||||
top[0].h_out = Point::new(20.0, 30.0);
|
||||
top[1].h_in = Point::new(90.0, 30.0);
|
||||
top.extend([Anchor::corner(Point::new(100.0, 80.0)), Anchor::corner(Point::new(0.0, 80.0))]);
|
||||
let path = PathData::single(SubPath::new(top, true));
|
||||
let c = Coons::from_top_object(&path, None).unwrap();
|
||||
for (u, t) in [(0.5, 0.5), (0.25, 0.25), (0.8125, 0.8125)] {
|
||||
let want = CubicBez::new((0.0, 0.0), (20.0, 30.0), (90.0, 30.0), (100.0, 0.0)).eval(t);
|
||||
let p = c.eval(u, 0.0);
|
||||
assert!(p.distance(want) < 1e-6, "u {u}: {p:?} vs {want:?}");
|
||||
}
|
||||
// Drawn the other way round, the outline gives the same patch.
|
||||
let mut back = path.clone();
|
||||
back.subpaths[0].reverse();
|
||||
let p = Coons::from_top_object(&back, None).unwrap().eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(53.75, 22.5)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_top_object_side_of_many_segments_keeps_each_corner() {
|
||||
// The top side zigzags through `n` straight segments, between y = 0 and y = 2.
|
||||
for n in [3, 100] {
|
||||
let w = f64::from(n);
|
||||
let zig = |i: i32| Point::new(f64::from(i) * 100.0 / w, if i % 2 == 1 { 2.0 } else { 0.0 });
|
||||
let mut pts: Vec<Anchor> = (0..=n).map(|i| Anchor::corner(zig(i))).collect();
|
||||
pts.extend([Anchor::corner(Point::new(100.0, 50.0)), Anchor::corner(Point::new(0.0, 50.0))]);
|
||||
let c = Coons::from_top_object(&PathData::single(SubPath::new(pts, true)), None).unwrap();
|
||||
for i in (1..n).filter(|i| n < 10 || [1, 37, 38, 99].contains(i)) {
|
||||
let p = c.eval(f64::from(i) / w, 0.0);
|
||||
assert!(p.distance(zig(i)) < 1e-9, "{n} segments, anchor {i}: {p:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_mesh_outline_keeps_its_sides_measured_by_length() {
|
||||
let c = Coons::from_path(&top_split_at_30()).unwrap();
|
||||
let p = c.eval(0.5, 0.0);
|
||||
assert!(p.distance(Point::new(50.0, 0.0)) < 1e-6, "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_identity() {
|
||||
let r = Rect::new(0.0, 0.0, 100.0, 100.0);
|
||||
|
||||
@@ -32,7 +32,14 @@ pub enum EnvelopeKind {
|
||||
handles: Vec<[Vec2; 4]>,
|
||||
},
|
||||
/// The content's bounding box maps onto the outline of this path.
|
||||
TopObject { path: PathData },
|
||||
TopObject {
|
||||
path: PathData,
|
||||
/// The anchors (indices into the outline, the path's largest subpath, in its own order)
|
||||
/// at the top left, top right, bottom right and bottom left corners, as a saved file gives
|
||||
/// them. `None`: the corners are found from the outline.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
corners: Option<[u32; 4]>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Envelope Options → Preserve Shape Using: how a distorted raster keeps the envelope's shape.
|
||||
@@ -518,7 +525,7 @@ pub fn envelope_overlay(n: &Node) -> Option<(PathData, Option<GradientMesh>)> {
|
||||
Some((grid.lines(), Some(grid)))
|
||||
}
|
||||
EnvelopeKind::Warp { .. } => Some((EnvelopeMap::of(n)?.surface_mesh(4, 4, Color::BLACK).lines(), None)),
|
||||
EnvelopeKind::TopObject { path } => Some((path.clone(), None)),
|
||||
EnvelopeKind::TopObject { path, .. } => Some((path.clone(), None)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -642,7 +649,7 @@ pub fn envelope_bounds(content: &[Arc<Node>], kind: &EnvelopeKind, frame: Affine
|
||||
let first = *points.first()?;
|
||||
Some(points.iter().fold(Rect::from_points(first, first), |r, p| r.union_pt(*p)))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.bounds(),
|
||||
EnvelopeKind::TopObject { path, .. } => path.bounds(),
|
||||
EnvelopeKind::Warp { .. } => EnvelopeMap::new(content, kind, frame, options)?.bounds(),
|
||||
}
|
||||
}
|
||||
|
||||
+262
-7
@@ -73,13 +73,16 @@ const SIDE: usize = 65;
|
||||
|
||||
/// Four boundary curves (top TL→TR, right TR→BR, bottom BL→BR, left TL→BL), each resampled to
|
||||
/// [`SIDE`] points, from the outline of a closed path split at the points nearest its bounding
|
||||
/// box corners.
|
||||
/// box corners. [`Coons::from_top_object`] samples its sides at the places `knots` gives instead.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Coons {
|
||||
top: Vec<Point>,
|
||||
right: Vec<Point>,
|
||||
bottom: Vec<Point>,
|
||||
left: Vec<Point>,
|
||||
/// Where along its side (0 to 1, rising) each point of the top, right, bottom and left side
|
||||
/// lies; `None` when the points are evenly spaced.
|
||||
knots: Option<Box<[Vec<f64>; 4]>>,
|
||||
}
|
||||
|
||||
impl Coons {
|
||||
@@ -87,9 +90,11 @@ impl Coons {
|
||||
pub fn rect(r: Rect) -> Self {
|
||||
let line = |a: Point, b: Point| (0..SIDE).map(|i| a.lerp(b, i as f64 / (SIDE - 1) as f64)).collect::<Vec<_>>();
|
||||
let (tl, tr, br, bl) = (Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1));
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl) }
|
||||
Self { top: line(tl, tr), right: line(tr, br), bottom: line(bl, br), left: line(tl, bl), knots: None }
|
||||
}
|
||||
|
||||
/// The patch of `path`'s outline, each side measured by its length: equal steps in `u` or `v`
|
||||
/// cover equal lengths of the side.
|
||||
pub fn from_path(path: &PathData) -> Option<Self> {
|
||||
// The largest subpath is the outline.
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
@@ -136,7 +141,145 @@ impl Coons {
|
||||
bottom.reverse();
|
||||
let mut left = resample(&arc(bl, tl), SIDE);
|
||||
left.reverse();
|
||||
Some(Self { top, right, bottom, left })
|
||||
Some(Self { top, right, bottom, left, knots: None })
|
||||
}
|
||||
|
||||
/// The patch of a top object's outline (the largest subpath of `path`), split into four sides
|
||||
/// at its corners. Each piece of a side between two anchors or corners takes a share of `u` or
|
||||
/// `v` in proportion to the length of its control polygon, spread along the piece by its own
|
||||
/// curve parameter. A segment without handles counts as a cubic with its handles a third of
|
||||
/// the way along, so the content spreads evenly along it. `corners` are the anchors (indices
|
||||
/// into the outline, in its own order) at the top left, top right, bottom right and bottom
|
||||
/// left, as a saved file gives them. They stay on those anchors when they run the way the
|
||||
/// outline does or the opposite way, so a reflected outline keeps them; without them, or when
|
||||
/// they don't fit the outline, each corner is found from the outline ([`top_object_corner`]).
|
||||
/// A straight segment closes an open subpath, and segments of no length are left out. A top
|
||||
/// object's envelope maps onto this patch.
|
||||
pub fn from_top_object(path: &PathData, corners: Option<[u32; 4]>) -> Option<Self> {
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
let mut sp = sp.clone();
|
||||
let count = sp.anchors.len();
|
||||
// Stored corners count only when they are distinct anchors met in order around the outline.
|
||||
let fits = |c: &[usize; 4]| {
|
||||
if !c.iter().all(|i| *i < count) {
|
||||
return false;
|
||||
}
|
||||
let gaps: Vec<usize> = (0..4).map(|k| (c[(k + 1) % 4] + count - c[k]) % count).collect();
|
||||
gaps.iter().all(|g| *g > 0) && gaps.iter().sum::<usize>() == count
|
||||
};
|
||||
let given = corners.map(|c| c.map(|i| i as usize));
|
||||
// Stored corners met in the outline's own order keep it as drawn, even counter-clockwise
|
||||
// (a reflected outline). Otherwise orient it clockwise in y-down (TL → TR → BR → BL).
|
||||
let mut corners = given.filter(fits);
|
||||
let reversed = corners.is_none() && sp.signed_area() < 0.0;
|
||||
if reversed {
|
||||
sp.reverse();
|
||||
corners = given.map(|c| c.map(|i| count.saturating_sub(1).saturating_sub(i))).filter(fits);
|
||||
}
|
||||
// Each segment, and whether it is straight: a straight one's parameter runs evenly along it.
|
||||
let line = |a: Point, b: Point| (CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b), true);
|
||||
let mut segs: Vec<(usize, (CubicBez, bool))> = (0..sp.segment_count())
|
||||
.map(|i| {
|
||||
let c = sp.segment(i);
|
||||
(i, if sp.segment_is_line(i) { line(c.p0, c.p3) } else { (c, false) })
|
||||
})
|
||||
.collect();
|
||||
if !sp.closed
|
||||
&& let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first())
|
||||
{
|
||||
segs.push((count.saturating_sub(1), line(a.p, b.p)));
|
||||
}
|
||||
segs.retain(|(_, (c, _))| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9));
|
||||
// The kept segment that anchor `k` starts (a segment of no length starts where the next begins).
|
||||
let starts: Vec<usize> = segs.iter().map(|(i, _)| *i).collect();
|
||||
// Where each kept segment and the anchor it starts at come in the outline as drawn: a tie
|
||||
// between corners goes to the one met first there.
|
||||
let order: Vec<(usize, usize)> = starts
|
||||
.iter()
|
||||
.map(|&i| if reversed { (count.saturating_sub(1).saturating_sub(i), (2 * count).saturating_sub(2 + i) % count.max(1)) } else { (i, i) })
|
||||
.collect();
|
||||
let segs: Vec<(CubicBez, bool)> = segs.into_iter().map(|(_, s)| s).collect();
|
||||
let r = segs.iter().map(|(c, _)| c.bounding_box()).reduce(|a, b| a.union(b)).or_else(|| path.bounds())?;
|
||||
// An outline of fewer than three points (a straight line there and back) maps onto its
|
||||
// bounding box, as in `from_path`.
|
||||
if segs.len() < 2 || (segs.len() == 2 && segs.iter().all(|(_, straight)| *straight)) {
|
||||
return Some(Self::rect(r));
|
||||
}
|
||||
let n = segs.len() as f64;
|
||||
let at = |s: f64| {
|
||||
let s = s.rem_euclid(n);
|
||||
let i = (s.floor() as usize).min(segs.len() - 1);
|
||||
segs.get(i).map_or(Point::ZERO, |(c, _)| c.eval((s - i as f64).clamp(0.0, 1.0)))
|
||||
};
|
||||
// The side from `a` to `b`: its points, and where along it (0 to 1) each lies. It runs
|
||||
// through pieces of segments, split at `a` and `b`. Each piece takes its share of the side
|
||||
// by the length of its control polygon and holds points at even steps of its parameter
|
||||
// (at least 16, and at least `SIDE` points on the side), its ends included. A side of
|
||||
// many pieces takes fewer points in each, so it holds at most `MAX_SIDE_POINTS` points,
|
||||
// or one for each piece when it has more pieces than that.
|
||||
let side = |a: f64, b: f64| {
|
||||
let span = (b - a).rem_euclid(n);
|
||||
if !(span.is_finite() && span > 0.0) {
|
||||
return (vec![at(a), at(a)], vec![0.0, 1.0]);
|
||||
}
|
||||
let mut pieces: Vec<CubicBez> = vec![];
|
||||
let (mut s, end) = (a, a + span);
|
||||
while s < end - 1e-12 && pieces.len() <= segs.len() {
|
||||
let i = s.floor();
|
||||
let next = (i + 1.0).min(end);
|
||||
if let Some((c, _)) = segs.get((i as usize) % segs.len()) {
|
||||
pieces.push(c.subsegment((s - i).clamp(0.0, 1.0)..(next - i).clamp(0.0, 1.0)));
|
||||
}
|
||||
s = next;
|
||||
}
|
||||
let polygon = |c: &CubicBez| c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3);
|
||||
let mut weights: Vec<f64> = pieces.iter().map(polygon).collect();
|
||||
let mut total: f64 = weights.iter().sum();
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
weights.iter_mut().for_each(|w| *w = 1.0);
|
||||
total = weights.len().max(1) as f64;
|
||||
}
|
||||
let count = pieces.len().max(1);
|
||||
let per = 16usize.max((SIDE - 1).div_ceil(count));
|
||||
let per = if count * per < MAX_SIDE_POINTS { per } else { (MAX_SIDE_POINTS / count).max(1) };
|
||||
let mut pts = vec![pieces.first().map_or_else(|| at(a), |c| c.p0)];
|
||||
let mut knots = vec![0.0];
|
||||
let mut before = 0.0;
|
||||
for (c, w) in pieces.iter().zip(&weights) {
|
||||
for k in 1..=per {
|
||||
let f = k as f64 / per as f64;
|
||||
pts.push(c.eval(f));
|
||||
knots.push((before + w * f) / total);
|
||||
}
|
||||
before += w;
|
||||
}
|
||||
if let Some(last) = knots.last_mut() {
|
||||
*last = 1.0;
|
||||
}
|
||||
(pts, knots)
|
||||
};
|
||||
let (tl, tr, br, bl) = match corners {
|
||||
// Anchor `k` sits at the start of the first kept segment from it on.
|
||||
Some(c) => {
|
||||
let place = |k: usize| starts.iter().position(|i| *i >= k).unwrap_or(0) as f64;
|
||||
(place(c[0]), place(c[1]), place(c[2]), place(c[3]))
|
||||
}
|
||||
None => {
|
||||
let corner = |q: Point| top_object_corner(&segs, &order, r, q);
|
||||
(corner(Point::new(r.x0, r.y0)), corner(Point::new(r.x1, r.y0)), corner(Point::new(r.x1, r.y1)), corner(Point::new(r.x0, r.y1)))
|
||||
}
|
||||
};
|
||||
let flip = |(mut p, mut u): (Vec<Point>, Vec<f64>)| {
|
||||
p.reverse();
|
||||
u.reverse();
|
||||
u.iter_mut().for_each(|x| *x = 1.0 - *x);
|
||||
(p, u)
|
||||
};
|
||||
let (top, top_u) = side(tl, tr);
|
||||
let (right, right_u) = side(tr, br);
|
||||
let (bottom, bottom_u) = flip(side(br, bl));
|
||||
let (left, left_u) = flip(side(bl, tl));
|
||||
Some(Self { top, right, bottom, left, knots: Some(Box::new([top_u, right_u, bottom_u, left_u])) })
|
||||
}
|
||||
|
||||
/// The point at `u` along a side; past its ends the end segments go on straight (art an
|
||||
@@ -147,10 +290,36 @@ impl Coons {
|
||||
side[i].lerp(side[i + 1], s - i as f64)
|
||||
}
|
||||
|
||||
/// The point at `u` along a side whose points lie at `knots` (rising, 0 to 1); past its ends
|
||||
/// the end segments go on straight, as in [`Self::sample`].
|
||||
fn sample_at(side: &[Point], knots: &[f64], u: f64) -> Point {
|
||||
if side.len() < 2 || side.len() != knots.len() {
|
||||
return side.first().copied().unwrap_or_default();
|
||||
}
|
||||
let i = knots.partition_point(|k| *k <= u).clamp(1, side.len() - 1) - 1;
|
||||
let (k0, k1) = (knots[i], knots[i + 1]);
|
||||
let f = if k1 > k0 { (u - k0) / (k1 - k0) } else { 0.0 };
|
||||
side[i].lerp(side[i + 1], f)
|
||||
}
|
||||
|
||||
/// The point at `u` along side `which` (0 top, 1 right, 2 bottom, 3 left).
|
||||
fn side_at(&self, which: usize, u: f64) -> Point {
|
||||
let side = match which {
|
||||
0 => &self.top,
|
||||
1 => &self.right,
|
||||
2 => &self.bottom,
|
||||
_ => &self.left,
|
||||
};
|
||||
match self.knots.as_deref().and_then(|k| k.get(which)) {
|
||||
Some(knots) => Self::sample_at(side, knots, u),
|
||||
None => Self::sample(side, u),
|
||||
}
|
||||
}
|
||||
|
||||
/// The surface point at (u, v) ∈ [0,1]².
|
||||
pub fn eval(&self, u: f64, v: f64) -> Point {
|
||||
let (t, b, l, r) = (Self::sample(&self.top, u), Self::sample(&self.bottom, u), Self::sample(&self.left, v), Self::sample(&self.right, v));
|
||||
let (p00, p10, p01, p11) = (self.top[0], self.top[SIDE - 1], self.bottom[0], self.bottom[SIDE - 1]);
|
||||
let (t, b, l, r) = (self.side_at(0, u), self.side_at(2, u), self.side_at(3, v), self.side_at(1, v));
|
||||
let [p00, p10, p11, p01] = self.corners();
|
||||
let c = (1.0 - v) * t.to_vec2() + v * b.to_vec2() + (1.0 - u) * l.to_vec2() + u * r.to_vec2()
|
||||
- ((1.0 - u) * (1.0 - v) * p00.to_vec2() + u * (1.0 - v) * p10.to_vec2() + (1.0 - u) * v * p01.to_vec2() + u * v * p11.to_vec2());
|
||||
c.to_point()
|
||||
@@ -158,7 +327,93 @@ impl Coons {
|
||||
|
||||
/// The four corners TL, TR, BR, BL.
|
||||
pub fn corners(&self) -> [Point; 4] {
|
||||
[self.top[0], self.top[SIDE - 1], self.bottom[SIDE - 1], self.bottom[0]]
|
||||
let end = |side: &[Point]| side.last().copied().unwrap_or_default();
|
||||
[self.top[0], end(&self.top), end(&self.bottom), self.bottom[0]]
|
||||
}
|
||||
}
|
||||
|
||||
/// The most points one side of [`Coons::from_top_object`] holds while it adds points between its
|
||||
/// anchors.
|
||||
const MAX_SIDE_POINTS: usize = 1 << 16;
|
||||
|
||||
/// How far into the outline's bounding box, as a share of its width and of its height, a corner
|
||||
/// of a top object looks for an anchor.
|
||||
const CORNER_REACH: f64 = 0.475;
|
||||
|
||||
/// A handle this long (in points) or shorter makes its anchor a corner of a top object.
|
||||
const SHORT_HANDLE: f64 = 0.01;
|
||||
|
||||
/// An anchor whose tangent directions, as unit vectors, differ by less than this in x and in y is
|
||||
/// smooth, and is not a corner of a top object.
|
||||
const SMOOTH_TURN: f64 = 0.01;
|
||||
|
||||
/// A top object's corner this close (in curve parameter) to either end of its segment moves onto
|
||||
/// the anchor there.
|
||||
const CORNER_END_SNAP: f64 = 0.005;
|
||||
|
||||
/// Is the anchor at `a`, reached by handle `h_in` and left by handle `h_out` on an outline that
|
||||
/// runs clockwise on screen, a corner of a top object? A handle of [`SHORT_HANDLE`] or shorter
|
||||
/// makes it one. Otherwise its tangent directions must differ by [`SMOOTH_TURN`] or more and turn
|
||||
/// clockwise, so it points out of the outline. A straight segment's handles here are the ones
|
||||
/// [`Coons::from_top_object`] gives it, a third of the way along, so an anchor between two
|
||||
/// straight segments longer than three times [`SHORT_HANDLE`] is a corner only when it turns out
|
||||
/// of the outline.
|
||||
fn top_object_corner_anchor(h_in: Point, a: Point, h_out: Point) -> bool {
|
||||
let (vin, vout) = (a - h_in, h_out - a);
|
||||
let (len_in, len_out) = (vin.hypot(), vout.hypot());
|
||||
if len_in <= SHORT_HANDLE || len_out <= SHORT_HANDLE {
|
||||
return true;
|
||||
}
|
||||
let (ui, uo) = (vin / len_in, vout / len_out);
|
||||
if (ui.x - uo.x).abs() < SMOOTH_TURN && (ui.y - uo.y).abs() < SMOOTH_TURN {
|
||||
return false;
|
||||
}
|
||||
ui.cross(uo) >= 0.0
|
||||
}
|
||||
|
||||
/// Where along `segs` (segment index + parameter, in [0, n)) the corner of a top object nearest
|
||||
/// corner `q` of its bounding box `r` lies, for an outline that runs clockwise on screen. It is
|
||||
/// the nearest anchor to `q` that is a corner ([`top_object_corner_anchor`]) and lies within
|
||||
/// [`CORNER_REACH`] of the box's width and height from `q`. Without one, it is the nearest point
|
||||
/// of the outline to `q`, moved onto its segment's anchor when within [`CORNER_END_SNAP`] of
|
||||
/// either end. `order` gives where each segment's first anchor and the segment itself come in the
|
||||
/// outline as drawn, which may run the other way; on a tie the one met first there wins.
|
||||
fn top_object_corner(segs: &[(CubicBez, bool)], order: &[(usize, usize)], r: Rect, q: Point) -> f64 {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let n = segs.len();
|
||||
if n == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let rank = |i: usize| order.get(i).copied().unwrap_or((i, i));
|
||||
let (reach_x, reach_y) = (r.width() * CORNER_REACH, r.height() * CORNER_REACH);
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for i in 0..n {
|
||||
let (Some((prev, _)), Some((cur, _))) = (segs.get((i + n - 1) % n), segs.get(i)) else { continue };
|
||||
let a = cur.p0;
|
||||
if (a.x - q.x).abs() > reach_x || (a.y - q.y).abs() > reach_y || !top_object_corner_anchor(prev.p2, a, cur.p1) {
|
||||
continue;
|
||||
}
|
||||
let (d, k) = (a.distance_squared(q), rank(i).0);
|
||||
if best.is_none_or(|(b, bk, _)| d < b || (d == b && k < bk)) {
|
||||
best = Some((d, k, i));
|
||||
}
|
||||
}
|
||||
if let Some((_, _, i)) = best {
|
||||
return i as f64;
|
||||
}
|
||||
let tol = (r.width() + r.height()).max(1e-6) * 1e-9;
|
||||
let nearest = segs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (c, _))| (c.nearest(q, tol), i))
|
||||
.min_by(|a, b| a.0.distance_sq.total_cmp(&b.0.distance_sq).then(rank(a.1).1.cmp(&rank(b.1).1)));
|
||||
let Some((near, i)) = nearest else { return 0.0 };
|
||||
if near.t > CORNER_END_SNAP && near.t < 1.0 - CORNER_END_SNAP {
|
||||
i as f64 + near.t
|
||||
} else if near.t < 0.5 {
|
||||
i as f64
|
||||
} else {
|
||||
((i + 1) % n) as f64
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1033,7 +1288,7 @@ pub(super) fn surface_of<'a>(kind: &'a EnvelopeKind, src: Rect, frame: Affine) -
|
||||
let (rows, cols) = (*rows as usize, *cols as usize);
|
||||
Box::new(move |u: f64, v: f64| grid_eval(rows, cols, points, u, v))
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => match Coons::from_path(path) {
|
||||
EnvelopeKind::TopObject { path, corners } => match Coons::from_top_object(path, *corners) {
|
||||
Some(co) => Box::new(move |u: f64, v: f64| co.eval(u, v)),
|
||||
None => Box::new(flat),
|
||||
},
|
||||
|
||||
@@ -69,6 +69,9 @@ pub struct NodeExtras {
|
||||
/// As an envelope with margins in Edit Contents, the margins its strokes gave when Edit Contents
|
||||
/// began ([`crate::live::EnvelopeOptions::bounds_margin`]).
|
||||
pub envelope_edit_margin: Option<[f32; 4]>,
|
||||
/// Transform panel › Align to Pixel Grid: the path's or compound path's horizontal and vertical
|
||||
/// segments stay on the pixel grid when it is transformed.
|
||||
pub pixel_aligned: bool,
|
||||
}
|
||||
|
||||
/// No extras: what [`Node::extras`] gives for a node without any.
|
||||
@@ -95,6 +98,7 @@ pub(super) static NO_EXTRAS: NodeExtras = NodeExtras {
|
||||
shape_mode: crate::ShapeMode::Add,
|
||||
symbol_overrides: Vec::new(),
|
||||
envelope_edit_margin: None,
|
||||
pixel_aligned: false,
|
||||
};
|
||||
|
||||
/// A node's [`NodeExtras`], if it has any ([`Node::extras`]). Extras that are all unset equal
|
||||
|
||||
@@ -448,6 +448,9 @@ impl Node {
|
||||
if self.appearance.gradients().next().is_some() {
|
||||
self.appearance.transform_gradients(a);
|
||||
}
|
||||
if self.appearance.has_svg_filter() {
|
||||
self.appearance.transform_svg_filters(a);
|
||||
}
|
||||
if sc.patterns {
|
||||
crate::pattern::transform_pattern_paints(self, a);
|
||||
}
|
||||
@@ -508,7 +511,7 @@ impl Node {
|
||||
*h = (lin * h.to_point()).to_vec2();
|
||||
}
|
||||
}
|
||||
EnvelopeKind::TopObject { path } => path.transform(a),
|
||||
EnvelopeKind::TopObject { path, .. } => path.transform(a),
|
||||
EnvelopeKind::Warp { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ fn extras_are_written_flat_in_order() {
|
||||
e.compound_shape = true;
|
||||
e.shape_mode = crate::ShapeMode::Exclude;
|
||||
e.envelope_edit_margin = Some([0.5, 0.5, 0.25, 0.25]);
|
||||
e.pixel_aligned = true;
|
||||
}
|
||||
/// The top-level keys of a JSON object, in the order written.
|
||||
struct Keys(Vec<String>);
|
||||
@@ -67,9 +68,11 @@ fn extras_are_written_flat_in_order() {
|
||||
"compound_shape",
|
||||
"shape_mode",
|
||||
"envelope_edit_margin",
|
||||
"pixel_aligned",
|
||||
];
|
||||
assert_eq!(keys, order);
|
||||
assert_eq!(v["shape_mode"], "exclude");
|
||||
assert_eq!(v["pixel_aligned"], true);
|
||||
assert_eq!(serde_json::from_value::<Node>(v).unwrap(), n);
|
||||
let plain = Node::path(NodeId(9), shapes::rectangle(Rect::new(0.0, 0.0, 4.0, 2.0)), Appearance::default_art());
|
||||
let back: Node = serde_json::from_value(serde_json::to_value(&plain).unwrap()).unwrap();
|
||||
|
||||
@@ -54,6 +54,7 @@ impl Serialize for Node {
|
||||
is_default(&e.shape_mode),
|
||||
e.symbol_overrides.is_empty(),
|
||||
e.envelope_edit_margin.is_none(),
|
||||
is_default(&e.pixel_aligned),
|
||||
];
|
||||
let mut st = s.serialize_struct("Node", skips.iter().filter(|s| !**s).count())?;
|
||||
let mut skip = skips.into_iter();
|
||||
@@ -96,6 +97,7 @@ impl Serialize for Node {
|
||||
field!("shape_mode", &e.shape_mode);
|
||||
field!("symbol_overrides", &e.symbol_overrides);
|
||||
field!("envelope_edit_margin", &e.envelope_edit_margin);
|
||||
field!("pixel_aligned", &e.pixel_aligned);
|
||||
st.end()
|
||||
}
|
||||
}
|
||||
@@ -236,6 +238,8 @@ pub(super) struct NodeWire {
|
||||
symbol_overrides: Vec<crate::SymbolOverride>,
|
||||
#[serde(default)]
|
||||
envelope_edit_margin: Option<[f32; 4]>,
|
||||
#[serde(default)]
|
||||
pixel_aligned: bool,
|
||||
}
|
||||
|
||||
impl From<NodeWire> for Node {
|
||||
@@ -263,6 +267,7 @@ impl From<NodeWire> for Node {
|
||||
shape_mode: w.shape_mode,
|
||||
symbol_overrides: w.symbol_overrides,
|
||||
envelope_edit_margin: w.envelope_edit_margin,
|
||||
pixel_aligned: w.pixel_aligned,
|
||||
};
|
||||
Node {
|
||||
id: w.id,
|
||||
|
||||
@@ -126,6 +126,23 @@ impl SavedSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SVG filter the document holds (Effect › SVG Filters): a `<filter>` element named by its
|
||||
/// `id`, listed in the SVG Filters submenu and in Apply SVG Filter.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SvgFilter {
|
||||
/// The filter element's `id`.
|
||||
pub name: String,
|
||||
/// The `<filter>` element's XML, as it was written.
|
||||
pub xml: String,
|
||||
}
|
||||
|
||||
impl SvgFilter {
|
||||
/// Most SVG filters a document holds.
|
||||
pub const MAX: usize = 64;
|
||||
/// Longest filter element, in bytes.
|
||||
pub const MAX_XML: usize = 64 * 1024;
|
||||
}
|
||||
|
||||
/// A symbol definition.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Symbol {
|
||||
|
||||
@@ -54,6 +54,20 @@ pub struct Selection {
|
||||
/// the engine then sets the document's from it. Editing state, never saved.
|
||||
#[serde(skip)]
|
||||
pub new_art: Arc<HashSet<NodeId>>,
|
||||
/// An object of a dynamic symbol instance's art, selected with Direct Selection: the instance
|
||||
/// is the one selected object, and solid fill and stroke changes go to its own appearance for
|
||||
/// that object ([`crate::symbol_overrides`]). Any other selection change clears it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub symbol_part: Option<SymbolPart>,
|
||||
}
|
||||
|
||||
/// See [`Selection::symbol_part`].
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SymbolPart {
|
||||
/// The symbol instance.
|
||||
pub instance: NodeId,
|
||||
/// The object in the symbol's art, as [`crate::symbol_overrides::part`] names it.
|
||||
pub path: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
@@ -79,6 +93,13 @@ impl Selection {
|
||||
self.slices.clear();
|
||||
self.guides.clear();
|
||||
self.key_guide = None;
|
||||
self.symbol_part = None;
|
||||
}
|
||||
/// Select only instance `instance`, with the object at `path` of its symbol's art (see
|
||||
/// [`Selection::symbol_part`]).
|
||||
pub fn set_symbol_part(&mut self, instance: NodeId, path: Vec<u32>) {
|
||||
self.set([instance]);
|
||||
self.symbol_part = Some(SymbolPart { instance, path });
|
||||
}
|
||||
/// Is there a key for Align: an object or a guide?
|
||||
pub fn has_key(&self) -> bool {
|
||||
@@ -93,6 +114,7 @@ impl Selection {
|
||||
pub fn add(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
if !self.objects.contains(&id) {
|
||||
self.objects.push(id);
|
||||
}
|
||||
@@ -100,6 +122,7 @@ impl Selection {
|
||||
pub fn remove(&mut self, id: NodeId) {
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
self.symbol_part = None;
|
||||
self.objects.retain(|x| *x != id);
|
||||
self.anchors.remove(&id);
|
||||
self.segments.remove(&id);
|
||||
@@ -128,6 +151,7 @@ impl Selection {
|
||||
/// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a
|
||||
/// Shift-click on a guide).
|
||||
pub fn toggle_guide(&mut self, i: usize) {
|
||||
self.symbol_part = None;
|
||||
if let Some(k) = self.guides.iter().position(|g| *g == i) {
|
||||
self.guides.remove(k);
|
||||
if self.key_guide == Some(i) {
|
||||
@@ -207,6 +231,27 @@ impl Selection {
|
||||
if self.key.is_some() || self.objects.is_empty() || self.key_guide.is_some_and(|k| !self.guides.contains(&k)) {
|
||||
self.key_guide = None;
|
||||
}
|
||||
// A selected part needs its instance selected alone, with no guides, its symbol dynamic
|
||||
// and the object still in the symbol's art as a path, compound path or type, where neither
|
||||
// it nor a group or layer around it is hidden, locked or a template.
|
||||
let editable_part = |art: &crate::Node, path: &[u32]| {
|
||||
crate::symbol_overrides::part(art, path).is_some_and(crate::symbol_overrides::takes_part_paint)
|
||||
&& (0..=path.len())
|
||||
.all(|k| crate::symbol_overrides::part(art, &path[..k]).is_some_and(|n| n.visible && !n.locked && !n.is_template()))
|
||||
};
|
||||
let part_kept = self.symbol_part.as_ref().is_some_and(|sp| {
|
||||
self.objects == [sp.instance]
|
||||
&& self.guides.is_empty()
|
||||
&& found.get(&sp.instance).and_then(|p| doc.node_at(p)).is_some_and(|n| match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } => {
|
||||
doc.symbols.iter().find(|s| s.name == *symbol).is_some_and(|s| s.dynamic && editable_part(&s.art, &sp.path))
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
});
|
||||
if !part_kept {
|
||||
self.symbol_part = None;
|
||||
}
|
||||
}
|
||||
/// Deselect the objects that are hidden or locked, themselves or through a group or layer
|
||||
/// around them (and so the key object when it is one): hidden or locked art can't stay
|
||||
@@ -238,6 +283,9 @@ impl Selection {
|
||||
}
|
||||
self.target = None;
|
||||
self.slices.clear();
|
||||
if self.symbol_part.as_ref().is_some_and(|sp| gone.contains(&sp.instance)) {
|
||||
self.symbol_part = None;
|
||||
}
|
||||
self.objects.retain(|o| !gone.contains(o));
|
||||
self.anchors.retain(|id, _| !gone.contains(id));
|
||||
self.segments.retain(|id, _| !gone.contains(id));
|
||||
|
||||
@@ -41,6 +41,82 @@ fn guides_are_selected_on_their_own() {
|
||||
assert_eq!(s.guides, vec![0]);
|
||||
}
|
||||
|
||||
/// A picked object of a dynamic symbol's instance stays picked while it and the groups and layers
|
||||
/// around it in the symbol's art are visible, unlocked and not a template. An art edit that hides
|
||||
/// or locks it, or one around it, or makes a layer around it a template, drops the pick.
|
||||
#[test]
|
||||
fn a_picked_symbol_part_is_dropped_once_it_cannot_be_edited() {
|
||||
let rect = || vectorcraft_geom::shapes::rectangle(vectorcraft_geom::Rect::new(0.0, 0.0, 10.0, 10.0));
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
let l = d.layers[0].id;
|
||||
let leaf = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
|
||||
let group = Node::group(d.alloc_id(), vec![Arc::new(leaf)]);
|
||||
let layer = Node::layer(d.alloc_id(), "Inner", crate::LayerColor::Preset(2));
|
||||
let root = Node::group(d.alloc_id(), vec![Arc::new(group), Arc::new(layer)]);
|
||||
d.symbols.push(crate::Symbol { dynamic: true, name: "Parts".into(), art: Arc::new(root.clone()) });
|
||||
let inst = d.alloc_id();
|
||||
d.insert(Some(l), 0, Node::new(inst, NodeKind::SymbolInstance { symbol: "Parts".into(), xf: vectorcraft_geom::Affine::IDENTITY })).unwrap();
|
||||
// The layer in the art holds a path too.
|
||||
let in_layer = Node::path(d.alloc_id(), rect(), crate::Appearance::default_art());
|
||||
let mut art = root;
|
||||
if let Some(NodeKind::Layer { children, .. }) = art.children_mut().and_then(|c| c.get_mut(1)).map(|c| &mut Arc::make_mut(c).kind) {
|
||||
children.push(Arc::new(in_layer));
|
||||
}
|
||||
d.symbols[0].art = Arc::new(art.clone());
|
||||
let picked = |d: &Document, path: &[u32]| {
|
||||
let mut s = Selection::default();
|
||||
s.set_symbol_part(inst, path.to_vec());
|
||||
s.prune(d);
|
||||
s.symbol_part.is_some()
|
||||
};
|
||||
assert!(picked(&d, &[0, 0]) && picked(&d, &[1, 0]), "editable parts stay picked");
|
||||
assert!(!picked(&d, &[0]) && !picked(&d, &[1]) && !picked(&d, &[]), "a group or layer does not stay picked");
|
||||
let edited = |change: &dyn Fn(&mut Node)| {
|
||||
let mut d = d.clone();
|
||||
let mut art = art.clone();
|
||||
change(&mut art);
|
||||
d.symbols[0].art = Arc::new(art);
|
||||
d
|
||||
};
|
||||
let child = |art: &mut Node, path: &[usize]| -> Node {
|
||||
let mut n = &*art;
|
||||
for &i in path {
|
||||
n = &n.children().unwrap()[i];
|
||||
}
|
||||
n.clone()
|
||||
};
|
||||
let set = |art: &mut Node, path: &[usize], n: Node| {
|
||||
let mut at = art;
|
||||
for &i in path {
|
||||
at = Arc::make_mut(&mut at.children_mut().unwrap()[i]);
|
||||
}
|
||||
*at = n;
|
||||
};
|
||||
let hidden = edited(&|art| {
|
||||
let mut n = child(art, &[0, 0]);
|
||||
n.visible = false;
|
||||
set(art, &[0, 0], n);
|
||||
});
|
||||
assert!(!picked(&hidden, &[0, 0]), "the part is hidden");
|
||||
let locked_group = edited(&|art| {
|
||||
let mut n = child(art, &[0]);
|
||||
n.locked = true;
|
||||
set(art, &[0], n);
|
||||
});
|
||||
assert!(!picked(&locked_group, &[0, 0]), "the group around it is locked");
|
||||
assert!(picked(&locked_group, &[1, 0]), "a part outside the locked group stays");
|
||||
let template = edited(&|art| {
|
||||
let mut n = child(art, &[1]);
|
||||
if let NodeKind::Layer { template, .. } = &mut n.kind {
|
||||
*template = true;
|
||||
}
|
||||
set(art, &[1], n);
|
||||
});
|
||||
assert!(!picked(&template, &[1, 0]), "the layer around it is a template");
|
||||
let hidden_root = edited(&|art| art.visible = false);
|
||||
assert!(!picked(&hidden_root, &[0, 0]), "the art is hidden");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guides_show_and_lock_with_their_layer() {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
||||
@@ -120,6 +120,21 @@ impl Quotes {
|
||||
prev.is_none_or(|p| p.is_whitespace() || matches!(p, '(' | '[' | '{' | '—' | '–') || quote_opened.unwrap_or_else(|| self.is_open(p)))
|
||||
}
|
||||
|
||||
/// The straight quote a typed curly quote stands for: `"` for `”` (U+201D) and nothing else.
|
||||
/// The macOS Serbian Cyrillic layout has no straight `"` and types `”` on Shift+2, so with
|
||||
/// Typographer's Quotes on, a `”` typed alone where a quote opens takes the document's opening
|
||||
/// quote ([`Quotes::opens_after`]); where a quote closes it stays `”`. `“ „` and the single
|
||||
/// quotes `‘ ’ ‚` stay as typed: that layout's apostrophe key already types `’`, as Serbian
|
||||
/// wants, and `’90s` keeps its apostrophe.
|
||||
pub fn typed_as_straight(c: char) -> Option<char> {
|
||||
(c == '”').then_some('"')
|
||||
}
|
||||
|
||||
/// Does a quote typed after `prev` (the character before it, `None` at the start) open?
|
||||
pub fn opens_after(&self, prev: Option<char>) -> bool {
|
||||
self.opens(prev, None)
|
||||
}
|
||||
|
||||
/// `text` with straight quotes (`"` and `'`) replaced by these quotes: opening or closing by
|
||||
/// context (an apostrophe is a closing single quote). `prev` is the character before `text`,
|
||||
/// and is updated to its last character, so text can be converted in pieces.
|
||||
@@ -420,6 +435,12 @@ mod tests {
|
||||
}
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("croatian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a ‚b‘ c“");
|
||||
// Only a typed `”` stands for a straight quote; the other curly quotes stay as typed.
|
||||
assert_eq!(Quotes::typed_as_straight('”'), Some('"'));
|
||||
assert!(['“', '„', '‘', '’', '‚', '"', '\'', '«', '»', '「', 'a'].iter().all(|&c| Quotes::typed_as_straight(c).is_none()));
|
||||
// A quote opens at the start and after a space, and closes after a letter.
|
||||
assert!(Quotes::ENGLISH.opens_after(None) && Quotes::ENGLISH.opens_after(Some(' ')));
|
||||
assert!(!Quotes::ENGLISH.opens_after(Some('a')));
|
||||
// Bulgarian: „…“ at either level, as CLDR has it.
|
||||
let mut prev = None;
|
||||
assert_eq!(language_quotes("bulgarian").unwrap().apply("\"a 'b' c\"", &mut prev), "„a „b“ c“");
|
||||
|
||||
@@ -176,7 +176,7 @@ impl Document {
|
||||
pub fn visit_paints(&self, f: &mut dyn FnMut(&Paint)) {
|
||||
let mut tree = |n: &Node| {
|
||||
walk_with_masks(n, &mut |m| {
|
||||
node_paints(m).for_each(&mut *f);
|
||||
node_paints(m).chain(override_paints(m)).for_each(&mut *f);
|
||||
if let NodeKind::Mesh(mesh) = &m.kind {
|
||||
mesh.points.iter().for_each(|p| f(&Paint::solid(p.color)));
|
||||
}
|
||||
@@ -275,10 +275,11 @@ fn map_style_paint(attrs: &mut serde_json::Map<String, serde_json::Value>, key:
|
||||
}
|
||||
|
||||
/// Every colour in the subtree of `n` with the swatch it links to: solid fills, strokes, text runs
|
||||
/// and gradient stops (with their link), then mesh points (unlinked), in paint order.
|
||||
/// and gradient stops (with their link), a dynamic symbol instance's own paints
|
||||
/// ([`override_paints`]), then mesh points (unlinked), in paint order.
|
||||
pub fn node_colors(n: &Node, f: &mut dyn FnMut(&Color, Option<&str>)) {
|
||||
n.walk(&mut |m| {
|
||||
for p in node_paints(m) {
|
||||
for p in node_paints(m).chain(override_paints(m)) {
|
||||
match p {
|
||||
Paint::Solid { color, swatch, .. } => f(color, swatch.as_deref()),
|
||||
Paint::Gradient(g) => g.gradient.stops.iter().for_each(|s| f(&s.color, s.swatch.as_deref())),
|
||||
@@ -315,6 +316,12 @@ pub fn node_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
n.appearance.items.iter().map(item_paint).chain(runs.iter().flat_map(|r| [&r.style.fill, &r.style.stroke])).chain(n.extras().colorize.as_deref())
|
||||
}
|
||||
|
||||
/// The paints a dynamic symbol instance keeps for objects of its art
|
||||
/// ([`crate::symbol_overrides`]), which [`node_paints`] leaves out.
|
||||
pub fn override_paints(n: &Node) -> impl Iterator<Item = &Paint> {
|
||||
n.extras().symbol_overrides.iter().flat_map(|o| [o.fill.as_ref(), o.stroke.as_ref()]).flatten()
|
||||
}
|
||||
|
||||
/// Mutable [`node_paints`], in the same order.
|
||||
pub fn node_paints_mut(n: &mut Node) -> impl Iterator<Item = &mut Paint> {
|
||||
let runs = match &mut n.kind {
|
||||
@@ -353,6 +360,20 @@ fn map_node(n: &Node, f: &mut LinkFn) -> Option<(Node, usize)> {
|
||||
}
|
||||
out = Some(m);
|
||||
}
|
||||
// A dynamic symbol instance's own paints for objects of its art (`symbol_overrides`).
|
||||
if !n.extras().symbol_overrides.is_empty() {
|
||||
let mut overrides = n.extras().symbol_overrides.clone();
|
||||
let mut k = 0;
|
||||
for p in overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
|
||||
if matches!(p, Paint::Solid { .. } | Paint::Gradient(_)) && p.map_links(f) {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
if k > 0 {
|
||||
out.get_or_insert_with(|| n.clone()).extras_mut().symbol_overrides = overrides;
|
||||
count += k;
|
||||
}
|
||||
}
|
||||
if let Some(ch) = n.children() {
|
||||
for (i, c) in ch.iter().enumerate() {
|
||||
if let Some((new, k)) = map_node(c, f) {
|
||||
|
||||
@@ -451,6 +451,18 @@ impl NineSliceMap {
|
||||
self.most_at(art, &boxes, 0)
|
||||
}
|
||||
|
||||
/// The affine map that [`Self::place`] and [`Self::place_unclipped`] move object `n` by as one
|
||||
/// piece, with everything in it: the map of the cell its box lies in, or of the cell at the
|
||||
/// origin when it has no box. `None` when its box crosses a guide.
|
||||
pub fn one_cell(&self, n: &Node, doc: &Document) -> Option<Affine> {
|
||||
let boxes = doc.symbol_boxes().quick();
|
||||
let Some(b) = n.geometric(Some(&boxes)).map(|b| self.natural.transform_rect_bbox(b)).filter(|b| finite(*b)) else {
|
||||
return Some(self.cell_at(Point::ZERO));
|
||||
};
|
||||
let (cols, rows) = (self.x.cells(b.x0, b.x1), self.y.cells(b.y0, b.y1));
|
||||
(cols[0] == cols[1] && rows[0] == rows[1]).then(|| self.cell_xf(cols[0], rows[0]))
|
||||
}
|
||||
|
||||
/// [`Self::most_placed`] for [`Self::place_unclipped`]: the most objects it makes of `art`.
|
||||
pub fn most_placed_unclipped(&self, art: &Node, doc: &Document) -> u64 {
|
||||
Self { clips: false, ..*self }.most_placed(art, doc)
|
||||
|
||||
@@ -49,6 +49,10 @@ impl SymbolOverride {
|
||||
r.style.fill = p.clone();
|
||||
}
|
||||
if let Some(p) = &self.stroke {
|
||||
// A painted stroke on characters without a weight gets 1 pt, as a path's does.
|
||||
if !p.is_none() && r.style.stroke_width == 0.0 {
|
||||
r.style.stroke_width = 1.0;
|
||||
}
|
||||
r.style.stroke = p.clone();
|
||||
}
|
||||
}
|
||||
@@ -57,6 +61,14 @@ impl SymbolOverride {
|
||||
n.appearance.set_fill(p.clone());
|
||||
}
|
||||
if let Some(p) = &self.stroke {
|
||||
// A stroke without paint has no weight; painting it gives it 1 pt.
|
||||
if !p.is_none()
|
||||
&& let Some(s) = n.appearance.stroke_mut()
|
||||
&& s.paint.is_none()
|
||||
&& s.width == 0.0
|
||||
{
|
||||
s.width = 1.0;
|
||||
}
|
||||
n.appearance.set_stroke(p.clone());
|
||||
}
|
||||
}
|
||||
@@ -69,6 +81,13 @@ impl SymbolOverride {
|
||||
}
|
||||
}
|
||||
|
||||
/// Can Direct Selection select `n`, an object of a dynamic symbol's art, and change its fill and
|
||||
/// stroke for one instance? Paths, compound paths and type can; groups, images and every other
|
||||
/// object cannot.
|
||||
pub fn takes_part_paint(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Path { .. } | NodeKind::Compound { .. } | NodeKind::Text(_))
|
||||
}
|
||||
|
||||
/// An override's path read from a file: at most [`MAX_PATH_DEPTH`] indices, so a compressed file
|
||||
/// of one endless path stays small once read.
|
||||
fn short_path<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<u32>, D::Error> {
|
||||
@@ -180,6 +199,27 @@ mod tests {
|
||||
assert_eq!(kept.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stroke_on_type_without_a_weight_gets_one_point() {
|
||||
let red = Paint::solid(Color::rgb(1.0, 0.0, 0.0));
|
||||
let style = crate::CharStyle { stroke: Paint::None, stroke_width: 0.0, ..crate::CharStyle::default() };
|
||||
let text = Node::new(NodeId(2), NodeKind::Text(Box::new(crate::TextObject::point(vectorcraft_geom::Point::ZERO, "Hi", style))));
|
||||
let mut a = Node::group(NodeId(1), vec![Arc::new(text)]);
|
||||
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
|
||||
let Some(NodeKind::Text(t)) = part(&a, &[0]).map(|n| &n.kind) else { panic!("type") };
|
||||
assert!(t.runs.iter().all(|r| r.style.stroke == red && r.style.stroke_width == 1.0), "the stroke draws at 1 pt");
|
||||
|
||||
// A path whose stroke has neither paint nor weight gets 1 pt too.
|
||||
let mut ap = Appearance::default_art();
|
||||
if let Some(s) = ap.stroke_mut() {
|
||||
(s.paint, s.width) = (Paint::None, 0.0);
|
||||
}
|
||||
let path = Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), ap);
|
||||
let mut a = Node::group(NodeId(1), vec![Arc::new(path)]);
|
||||
apply(&mut a, &[SymbolOverride { path: vec![0], stroke: Some(red.clone()), ..Default::default() }]);
|
||||
assert_eq!(part(&a, &[0]).unwrap().appearance.stroke_width(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_deeper_than_the_cap_does_not_read() {
|
||||
let deep = format!("{{\"path\":{:?}}}", vec![0; MAX_PATH_DEPTH + 1]);
|
||||
|
||||
@@ -17,6 +17,11 @@ vectorcraft-text = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
image = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
# Effect › SVG Filters draws its filters with resvg (MIT OR Apache-2.0), at the usvg version the
|
||||
# workspace reads SVG with; raster-images decodes the object's pixels it hands the filter as a PNG.
|
||||
resvg = { version = "0.48", default-features = false, features = ["raster-images"] }
|
||||
# The number lists of filter attributes, read as usvg reads them (MIT OR Apache-2.0; already used by usvg).
|
||||
svgtypes = "0.16"
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
+31
-15
@@ -28,7 +28,7 @@ use crate::distort::{transform_move, transform_offsets};
|
||||
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_with, crop_marks_art, evaluate_container, has_adjustment, has_crop_marks, has_geometry,
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -71,12 +71,27 @@ fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData {
|
||||
/// `path` with the geometry effects of `effects` applied, and how gradients follow them
|
||||
/// ([`crate::Carry`]).
|
||||
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> (PathData, crate::Carry) {
|
||||
if !has_geometry(effects) || path.is_empty() {
|
||||
return path.clone();
|
||||
return (path.clone(), crate::Carry::default());
|
||||
}
|
||||
let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
|
||||
apply_geometry_with(effects, path, b, ctx)
|
||||
apply_geometry_carrying(effects, path, b, ctx)
|
||||
}
|
||||
|
||||
/// The geometry `g` with the own effects of `item` (one of the fills and strokes `ctx` is the
|
||||
/// context of) applied, and `carried` (`item` as the object's effects left it) with its gradient
|
||||
/// moved along with them.
|
||||
fn apply_item(item: &AppearanceItem, carried: &AppearanceItem, g: &PathData, ctx: &GeomContext) -> (PathData, AppearanceItem) {
|
||||
let (ig, carry) = apply(item_effects(item), g, &ctx.item(item));
|
||||
let mut ap = Appearance { items: vec![carried.clone()], ..Default::default() };
|
||||
if !carry.moves(&ap) {
|
||||
return (ig, carried.clone());
|
||||
}
|
||||
carry.apply(&mut ap);
|
||||
(ig, ap.items.pop().unwrap_or_else(|| carried.clone()))
|
||||
}
|
||||
|
||||
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
|
||||
@@ -414,12 +429,15 @@ fn piece(d: &mut Document, n: &Node, path: PathData, rule: FillRule, item: Appea
|
||||
}
|
||||
|
||||
/// `n`'s geometry with its object-level geometry effects applied, its fill rule, the context of
|
||||
/// its effects, and `n` without those effects.
|
||||
/// its effects, and `n` without those effects, its gradients moved along with them.
|
||||
fn leaf_base(n: &Node) -> Option<(PathData, FillRule, GeomContext<'_>, Node)> {
|
||||
let (base, rule) = geometry(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = apply(&n.appearance.effects, &base, &ctx);
|
||||
let mut m = n.clone();
|
||||
let (g, carry) = apply(&n.appearance.effects, &base, &ctx);
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some((g, rule, ctx, m))
|
||||
}
|
||||
@@ -436,9 +454,9 @@ fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
.zip(&m.appearance.items)
|
||||
.map(|(item, carried)| {
|
||||
let (ig, mut it) = apply_item(item, carried, &g, &ctx);
|
||||
clear_item_effects(&mut it);
|
||||
Arc::new(piece(d, n, ig, rule, it))
|
||||
})
|
||||
@@ -470,9 +488,8 @@ pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
m.appearance.effects.retain(|e| e.visible);
|
||||
let mut pieces = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| paints(i)) {
|
||||
let ig = apply(item.effects(), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) {
|
||||
let (ig, mut it) = apply_item(item, carried, &g, &ctx);
|
||||
clear_item_effects(&mut it);
|
||||
it.effects_mut().retain(|e| e.visible);
|
||||
let p = match &it {
|
||||
@@ -562,9 +579,8 @@ pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
Arc::new(p)
|
||||
};
|
||||
let mut pieces = vec![];
|
||||
for item in n.appearance.items.iter().filter(|i| paints(i)) {
|
||||
let ig = apply(item_effects(item), &g, &ctx.item(item));
|
||||
let mut it = item.clone();
|
||||
for (item, carried) in n.appearance.items.iter().zip(&m.appearance.items).filter(|(i, _)| paints(i)) {
|
||||
let (ig, mut it) = apply_item(item, carried, &g, &ctx);
|
||||
clear_item_effects(&mut it);
|
||||
pieces.push(match &it {
|
||||
AppearanceItem::Stroke(st) if st.brush.is_none() => {
|
||||
|
||||
@@ -17,6 +17,20 @@ fn fx(id: &str, params: serde_json::Value) -> Effect {
|
||||
Effect { id: id.into(), params, visible: true }
|
||||
}
|
||||
|
||||
/// Asserts that placed gradient `got` has `want`'s start, end, aspect and focal point, within 1e-6.
|
||||
fn assert_geom_near(got: vectorcraft_color::GradientGeom, want: vectorcraft_color::GradientGeom, what: &str) {
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
let focal = match (got.focal, want.focal) {
|
||||
(None, None) => true,
|
||||
(Some(a), Some(b)) => near(a, b),
|
||||
_ => false,
|
||||
};
|
||||
assert!(
|
||||
near(got.start, want.start) && near(got.end, want.end) && (got.aspect - want.aspect).abs() < 1e-6 && focal,
|
||||
"{what}: got {got:?}, want {want:?}"
|
||||
);
|
||||
}
|
||||
|
||||
fn doc_with(n: Node) -> Document {
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let l = d.layers[0].id;
|
||||
@@ -45,6 +59,157 @@ fn object_effects_become_geometry_and_raster_effects_stay() {
|
||||
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baked_transform_moves_a_placed_gradient_with_the_path() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let placed_geom = |n: &Node| match n.appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.unwrap(),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
};
|
||||
// A move 30 pt right, and a turn of 90° with the width halved about the center (50, 50): the
|
||||
// square becomes 100 pt wide and 50 pt tall, and the gradient runs from the bottom up.
|
||||
let moves = [
|
||||
(json!({"moveH": 30.0}), Point::new(30.0, 0.0), GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom }),
|
||||
(
|
||||
json!({"rotate": 90.0, "scaleH": 50.0}),
|
||||
Point::new(0.0, 25.0),
|
||||
GradientGeom { start: Point::new(50.0, 75.0), end: Point::new(50.0, 25.0), ..geom },
|
||||
),
|
||||
];
|
||||
// Alone, with a stroke whose own effect makes one path for each fill and stroke, and as the
|
||||
// fill's own effect.
|
||||
for (params, corner, want) in moves {
|
||||
for (split, own) in [(false, false), (true, false), (true, true)] {
|
||||
let mut ap = Appearance::basic(placed.clone(), Paint::solid(Color::BLACK), if split { 1.0 } else { 0.0 });
|
||||
if split && let AppearanceItem::Stroke(s) = &mut ap.items[1] {
|
||||
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
|
||||
}
|
||||
let transform = fx("distort.transform", params.clone());
|
||||
if own {
|
||||
ap.items[0].effects_mut().push(transform.clone());
|
||||
}
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
|
||||
if !own {
|
||||
n.appearance.effects.push(transform);
|
||||
}
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let m = d.node(NodeId(50)).unwrap();
|
||||
let fill: &Node = if split { &m.children().unwrap()[0] } else { m };
|
||||
let b = fill.geometric_bounds().unwrap();
|
||||
assert!(b.origin().distance(corner) < 1e-6, "{params}, split {split}, own {own}: {b:?}");
|
||||
assert_geom_near(placed_geom(fill), want, &format!("{params}, split {split}, own {own}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A stroke's own Transform effect or Free Distort moves the gradient placed on that stroke.
|
||||
#[test]
|
||||
fn a_strokes_own_transform_and_free_distort_move_its_placed_gradient() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
// Moved 30 pt right by Transform, and one width (100 pt) right by Free Distort.
|
||||
let moves = [
|
||||
(fx("distort.transform", json!({"moveH": 30.0})), 30.0),
|
||||
(fx("distort.freeDistort", json!({"corners": [[1.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0]]})), 100.0),
|
||||
];
|
||||
for (e, moved) in moves {
|
||||
let mut ap = Appearance::basic(Paint::None, placed.clone(), 4.0);
|
||||
let Some(stroke) = ap.items.iter_mut().find(|i| matches!(i, AppearanceItem::Stroke(_))) else { panic!("a stroke") };
|
||||
stroke.effects_mut().push(e.clone());
|
||||
let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut geoms = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
for item in &c.appearance.items {
|
||||
if let AppearanceItem::Stroke(s) = item
|
||||
&& let Paint::Gradient(g) = &s.paint
|
||||
{
|
||||
geoms.push(g.geom.unwrap());
|
||||
}
|
||||
}
|
||||
});
|
||||
assert!(!geoms.is_empty(), "{}: the stroke keeps its gradient", e.id);
|
||||
let want = GradientGeom { start: Point::new(moved, 50.0), end: Point::new(100.0 + moved, 50.0), ..geom };
|
||||
for g in geoms {
|
||||
assert_geom_near(g, want, &e.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fills_gradient_follows_the_effects_around_its_transform_with_options() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: Point::new(0.0, 50.0), end: Point::new(100.0, 50.0), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let moved = fx("distort.transform", json!({"moveH": 30.0}));
|
||||
let scales_strokes = fx("distort.transform", json!({"strokes": true}));
|
||||
// A move before and a move after the fill's Transform effect with Scale Strokes & Effects on.
|
||||
for fill_fx in [vec![moved.clone(), scales_strokes.clone()], vec![scales_strokes, moved]] {
|
||||
let mut ap = Appearance::basic(placed.clone(), Paint::None, 0.0);
|
||||
*ap.items[0].effects_mut() = fill_fx.clone();
|
||||
let n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), ap);
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let mut found = vec![];
|
||||
d.node(NodeId(50)).unwrap().walk(&mut |c: &Node| {
|
||||
if let Paint::Gradient(g) = c.appearance.fill_paint() {
|
||||
found.push((c.geometric_bounds().unwrap(), g.geom.unwrap()));
|
||||
}
|
||||
});
|
||||
assert!(!found.is_empty(), "{fill_fx:?}: the fill keeps its gradient");
|
||||
let want = GradientGeom { start: Point::new(30.0, 50.0), end: Point::new(130.0, 50.0), ..geom };
|
||||
for (b, g) in found {
|
||||
assert!(b.origin().distance(Point::new(30.0, 0.0)) < 1e-6, "{fill_fx:?}: {b:?}");
|
||||
assert_geom_near(g, want, &format!("{fill_fx:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baked_free_distort_moves_placed_gradients_from_the_bounds_to_the_distorted_bounds() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = |kind| GradientPaint::new(Gradient::new(kind, stops.clone()));
|
||||
let linear = GradientGeom { start: Point::new(20.0, 50.0), end: Point::new(80.0, 50.0), aspect: 1.0, focal: None };
|
||||
let radial = GradientGeom { start: Point::new(40.0, 30.0), end: Point::new(60.0, 30.0), aspect: 0.5, focal: Some(Point::new(45.0, 32.0)) };
|
||||
let placed_linear = Paint::Gradient(Box::new(GradientPaint { geom: Some(linear), ..ramp(GradientKind::Linear) }));
|
||||
let placed_radial = Paint::Gradient(Box::new(GradientPaint { geom: Some(radial), ..ramp(GradientKind::Radial) }));
|
||||
let fitted = Paint::Gradient(Box::new(ramp(GradientKind::Linear)));
|
||||
let baked = |fill: &Paint, e: Effect| {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill.clone(), Paint::None, 0.0));
|
||||
n.appearance.effects.push(e);
|
||||
let d = bake_document(&doc_with(n)).unwrap();
|
||||
let m = d.node(NodeId(50)).unwrap();
|
||||
match m.appearance.fill_paint() {
|
||||
Paint::Gradient(g) => (m.geometric_bounds().unwrap(), (*g).clone()),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
};
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
// The bottom right corner moves 50 pt right, so the bounds grow from 0..100 to 0..150 across.
|
||||
let corners = json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.5, 1.0], [0.0, 1.0]]});
|
||||
let distort = || fx("distort.freeDistort", corners.clone());
|
||||
let (b, g) = baked(&placed_linear, distort());
|
||||
assert!((b.x1 - 150.0).abs() < 1e-6, "{b:?}");
|
||||
let geom = g.geom.unwrap();
|
||||
assert!(near(geom.start, Point::new(30.0, 50.0)) && near(geom.end, Point::new(120.0, 50.0)), "{geom:?}");
|
||||
// A radial gradient's center, focal point and ellipse take the same stretch: the radius along
|
||||
// the vector grows from 20 to 30 pt and the 10 pt radius across it stays.
|
||||
let (_, g) = baked(&placed_radial, distort());
|
||||
let want = GradientGeom { start: Point::new(60.0, 30.0), end: Point::new(90.0, 30.0), aspect: 1.0 / 3.0, focal: Some(Point::new(67.5, 32.0)) };
|
||||
assert_geom_near(g.geom.unwrap(), want, "radial");
|
||||
// A fitted gradient stays fitted, and fits the distorted outline.
|
||||
assert_eq!(baked(&fitted, distort()).1.geom, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_effects_split_into_one_path_per_item() {
|
||||
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
|
||||
|
||||
@@ -105,6 +105,8 @@ fn lengths_of(id: &str) -> Lengths {
|
||||
"sketch.waterPaper" => always(&["fiberLength"]),
|
||||
TRAP => always(&["thickness", "precision"]),
|
||||
RASTERIZE => always(&["addAround"]),
|
||||
// The points one unit of the filter measures.
|
||||
crate::SVG_FILTER => always(&["scale"]),
|
||||
_ => Lengths::default(),
|
||||
}
|
||||
}
|
||||
@@ -223,7 +225,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.freeDistort",
|
||||
"Free Distort…",
|
||||
DT,
|
||||
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]])}",
|
||||
"{corners: [[x,y]×4] (new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates; identity [[0,0],[1,0],[1,1],[0,1]]; placed gradients move and scale from the object's bounds to its distorted bounds)}",
|
||||
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
|
||||
),
|
||||
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
|
||||
@@ -238,7 +240,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.transform",
|
||||
"Transform…",
|
||||
DT,
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise), copies: 0..1000 (0; each copy transforms the last again), 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), copies: 0..1000 (0; each copy transforms the last again; 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)}",
|
||||
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(
|
||||
@@ -826,6 +828,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"{colorModel: \"document\" (the document's mode, RGB or CMYK)|\"grayscale\"|\"bitmap\" (\"document\"), useDocumentResolution: bool (true: Document Raster Effects Settings › Resolution), resolution: ppi 1..2400 (72; with useDocumentResolution false), background: \"white\"|\"transparent\" (\"transparent\"), antiAlias: \"none\"|\"art\"|\"type\" (\"art\"; none: hard edges), clippingMask: bool (false; the image clipped to the art's outline), addAround: pt 0..1000 (0; room around the art)} draws the object as an image of square pixels over its bounds and the room around them, leaving its paths, type and other art as they are; when it is the object's only raster effect, Expand Appearance makes the image Object › Rasterize makes",
|
||||
json!({"colorModel": "document", "useDocumentResolution": true, "resolution": 72.0, "background": "transparent", "antiAlias": "art", "clippingMask": false, "addAround": 0.0}),
|
||||
));
|
||||
v.push(r(
|
||||
crate::SVG_FILTER,
|
||||
"SVG Filter",
|
||||
&["Effect", "SVG Filters"],
|
||||
"{name: string (the filter's id), xml: string (one SVG <filter> element with an id and 1..64 primitives, at most 64 KB), scale: number 0.001..1000 (1; points per unit of the filter's user space), origin: [x, y] pt ([0, 0]; where the filter's user space starts in the document)} runs the SVG filter on the object's pixels, its lengths in points times scale, at the Document Raster Effects resolution or finer; svgFilter.apply applies one of the document's filters (svgFilter.list)",
|
||||
json!({"scale": 1.0, "origin": [0.0, 0.0]}),
|
||||
));
|
||||
v
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,21 @@ fn diag(b: Rect) -> f64 {
|
||||
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates),
|
||||
/// making at most `max` anchors ([`map_nonlinear_within`]).
|
||||
pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData {
|
||||
let c = free_distort_corners(b, p);
|
||||
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
|
||||
let f = |q: Point| {
|
||||
let u = (q.x - b.x0) / w;
|
||||
let v = (q.y - b.y0) / h;
|
||||
let top = c[0].lerp(c[1], u);
|
||||
let bot = c[3].lerp(c[2], u);
|
||||
top.lerp(bot, v)
|
||||
};
|
||||
map_nonlinear_within(path, diag(b) / 16.0, max, f)
|
||||
}
|
||||
|
||||
/// The four corners (top left, top right, bottom right, bottom left) Free Distort with params `p`
|
||||
/// moves bounding box `b`'s corners to, in the document.
|
||||
pub(crate) fn free_distort_corners(b: Rect, p: &Value) -> [Point; 4] {
|
||||
let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
|
||||
if let Some(arr) = p.get("corners").and_then(Value::as_array) {
|
||||
for (i, v) in arr.iter().take(4).enumerate() {
|
||||
@@ -26,16 +41,7 @@ pub fn free_distort(path: &PathData, b: Rect, p: &Value, max: usize) -> PathData
|
||||
}
|
||||
}
|
||||
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
|
||||
let to_doc = |q: Point| Point::new(b.x0 + q.x * w, b.y0 + q.y * h);
|
||||
let c = c.map(to_doc);
|
||||
let f = |q: Point| {
|
||||
let u = (q.x - b.x0) / w;
|
||||
let v = (q.y - b.y0) / h;
|
||||
let top = c[0].lerp(c[1], u);
|
||||
let bot = c[3].lerp(c[2], u);
|
||||
top.lerp(bot, v)
|
||||
};
|
||||
map_nonlinear_within(path, diag(b) / 16.0, max, f)
|
||||
c.map(|q| Point::new(b.x0 + q.x * w, b.y0 + q.y * h))
|
||||
}
|
||||
|
||||
/// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the
|
||||
|
||||
@@ -157,6 +157,118 @@ pub(crate) fn optimize_geometry_effects<'a>(effects: &[&'a Effect], style_safe:
|
||||
/// [`apply_geometry_with`] whose effects keep to `budget`, raised to what `path` costs where that
|
||||
/// is more.
|
||||
pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext, budget: Budget) -> PathData {
|
||||
apply_geometry_carrying_within(effects, path, bounds, ctx, budget, None)
|
||||
}
|
||||
|
||||
/// [`apply_geometry_with`], and the [`Carry`] that moves the object's gradients along with the
|
||||
/// effects that move the object as a whole.
|
||||
pub fn apply_geometry_carrying(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> (PathData, Carry) {
|
||||
let mut carry = Carry::default();
|
||||
let out = apply_geometry_carrying_within(effects, path, bounds, ctx, Budget::EFFECTS, Some(&mut carry));
|
||||
(out, carry)
|
||||
}
|
||||
|
||||
/// [`apply_geometry_carrying`] on kurbo paths.
|
||||
pub fn apply_geometry_bez_carrying(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> (BezPath, Carry) {
|
||||
let (out, carry) = apply_geometry_carrying(effects, &PathData::from_bezpath(path), bounds, ctx);
|
||||
(out.to_bezpath(), carry)
|
||||
}
|
||||
|
||||
/// How placed gradients follow geometry effects. The Transform effect without copies maps them as
|
||||
/// it maps the object (with copies, `crate::reshape` gives each copy its own). Free Distort moves
|
||||
/// and scales them from the object's bounds before the effect to its bounds after it. For the
|
||||
/// other geometry effects it is empty. It does not change gradients fitted to the object.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Carry(Vec<CarryStep>);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum CarryStep {
|
||||
Affine(vectorcraft_geom::Affine),
|
||||
/// From box `from` to box `to` (strokes: their stroke-inflated boxes).
|
||||
Boxes {
|
||||
from: Rect,
|
||||
to: Rect,
|
||||
},
|
||||
}
|
||||
|
||||
impl Carry {
|
||||
/// Does effect `id` with merged params `p` on geometry with reference box `b` (`seed` for
|
||||
/// Random) move placed gradients? Free Distort does unless it leaves every corner in place;
|
||||
/// the Transform effect does without copies (with copies the original stays where it is, and
|
||||
/// `crate::reshape` gives each copy its own gradients).
|
||||
pub(crate) fn follows(id: &str, p: &Value, b: Rect, seed: u64) -> bool {
|
||||
match id {
|
||||
"distort.transform" => {
|
||||
let (m, copies) = distort::transform_map(b, p, seed);
|
||||
copies == 0 && m != vectorcraft_geom::Affine::IDENTITY && m.is_finite()
|
||||
}
|
||||
"distort.freeDistort" => {
|
||||
let corners = distort::free_distort_corners(b, p);
|
||||
let tol = 1e-9 * b.width().hypot(b.height()).max(1.0);
|
||||
let still =
|
||||
[(b.x0, b.y0), (b.x1, b.y0), (b.x1, b.y1), (b.x0, b.y1)].iter().zip(&corners).all(|(a, c)| c.distance((*a).into()) <= tol);
|
||||
!still && corners.iter().all(|c| c.x.is_finite() && c.y.is_finite())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// How placed gradients follow effect `id` (as [`Self::follows`]) on an object whose geometric
|
||||
/// bounds are `from` before the effect and `to` after it; empty when they stay in place.
|
||||
pub(crate) fn of(id: &str, p: &Value, b: Rect, from: Option<Rect>, to: Option<Rect>, seed: u64) -> Carry {
|
||||
if !Self::follows(id, p, b, seed) {
|
||||
return Carry::default();
|
||||
}
|
||||
let step = match id {
|
||||
"distort.transform" => Some(CarryStep::Affine(distort::transform_map(b, p, seed).0)),
|
||||
_ => match (from, to) {
|
||||
(Some(from), Some(to)) if from != to && [to.x0, to.y0, to.x1, to.y1].iter().all(|v| v.is_finite()) => {
|
||||
Some(CarryStep::Boxes { from, to })
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
};
|
||||
Carry(step.into_iter().collect())
|
||||
}
|
||||
|
||||
/// Does it leave gradients where they are?
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Does it move any of the gradients of `ap`? Callers check first, so an appearance shared
|
||||
/// with other objects is not copied for nothing.
|
||||
pub fn moves(&self, ap: &Appearance) -> bool {
|
||||
!self.0.is_empty() && ap.has_placed_gradient()
|
||||
}
|
||||
|
||||
/// Move the placed gradients of `ap` along, through each step in turn.
|
||||
pub fn apply(&self, ap: &mut Appearance) {
|
||||
if !self.moves(ap) {
|
||||
return;
|
||||
}
|
||||
for s in &self.0 {
|
||||
match *s {
|
||||
CarryStep::Affine(m) => ap.transform_gradients(m),
|
||||
CarryStep::Boxes { from, to } => crate::reshape::follow_boxes(ap, from, to),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn push(&mut self, more: Carry) {
|
||||
self.0.extend(more.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`apply_geometry_within`], adding to `carry` how gradients follow each effect.
|
||||
pub(crate) fn apply_geometry_carrying_within(
|
||||
effects: &[Effect],
|
||||
path: &PathData,
|
||||
bounds: Rect,
|
||||
ctx: &GeomContext,
|
||||
budget: Budget,
|
||||
mut carry: Option<&mut Carry>,
|
||||
) -> PathData {
|
||||
// `budget` raised to what `path` costs, measured once, and only for a path that has geometry
|
||||
// effects.
|
||||
let has_stroke = match (ctx.item, ctx.appearance) {
|
||||
@@ -185,6 +297,9 @@ pub(crate) fn apply_geometry_within(effects: &[Effect], path: &PathData, bounds:
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
|
||||
if let Some(c) = carry.as_deref_mut() {
|
||||
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +198,11 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
|
||||
Some(area) => Some(reshaped(&area, n)),
|
||||
None => {
|
||||
let mut m = crate::reshape::outlined_members_with(None, n, outline, nodes);
|
||||
crate::reshape::reshape_leaves(&mut m, &fx);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
|
||||
// The group's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
without_geometry_effects(&mut m);
|
||||
Some(m)
|
||||
}
|
||||
@@ -210,7 +214,11 @@ fn reshaped_art(n: &Node, hook: Option<OutlineHook>, nodes: &SymbolNodes) -> Opt
|
||||
let art = h(n).filter(|art| nodes.charge(art))?;
|
||||
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
|
||||
crate::reshape::outline_nested_text(&mut art, false);
|
||||
crate::reshape::reshape_leaves(&mut art, &fx);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx);
|
||||
// The art's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&art.appearance) {
|
||||
carry.apply(&mut art.appearance);
|
||||
}
|
||||
(art.opacity, art.blend, art.isolate) = (n.opacity, n.blend, n.isolate);
|
||||
art.appearance.effects = n.appearance.effects.clone();
|
||||
without_geometry_effects(&mut art);
|
||||
@@ -230,8 +238,12 @@ fn reshaped(src: &Node, n: &Node) -> Node {
|
||||
let mut m = src.clone();
|
||||
if let Some((path, rule)) = geometry(src) {
|
||||
let bounds = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
|
||||
let g = crate::apply_geometry_with(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
|
||||
let (g, carry) = crate::apply_geometry_carrying(&n.appearance.effects, &path, bounds, &crate::GeomContext::of(n));
|
||||
m.kind = NodeKind::Path { path: g, rule, live: None, clipping: false, guide: false };
|
||||
// Its gradients move with the outline, as the canvas paints them.
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
}
|
||||
without_geometry_effects(&mut m);
|
||||
m
|
||||
@@ -711,7 +723,12 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
crate::reshape::reshape_leaves(&mut m, &fx);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx);
|
||||
// The group's own fills and strokes paint the members as a whole: their gradients follow
|
||||
// the effects as the members' do.
|
||||
if paint && carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !crate::is_geometry(&e.id));
|
||||
if paint {
|
||||
|
||||
@@ -248,7 +248,7 @@ fn hidden_effect_or_plain_group_is_untouched() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_on_its_paths() {
|
||||
fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_those_placed_on_its_paths() {
|
||||
if skip_without_craft_fonts() {
|
||||
return;
|
||||
}
|
||||
@@ -273,7 +273,181 @@ fn a_transform_on_a_group_moves_the_gradients_of_its_type_and_not_those_placed_o
|
||||
let members = art.children().unwrap();
|
||||
let after = start(&members[1].children().unwrap()[0]);
|
||||
assert!((after - before - 30.0).abs() < 1e-6, "the type's gradient starts at {before}, then {after}");
|
||||
assert_eq!(start(&members[0]), 0.0, "the square's gradient stays where it was placed");
|
||||
assert!((start(&members[0]) - 30.0).abs() < 1e-6, "the square's gradient moves with it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_transform_moves_the_gradient_of_its_own_fill() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (15.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let mut g = Node::group(NodeId(10), vec![sq(1, 0.0, Color::BLACK), sq(2, 5.0, Color::BLACK)]);
|
||||
g.appearance
|
||||
.items
|
||||
.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp })))));
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut starts = vec![];
|
||||
art.walk(&mut |c: &Node| {
|
||||
if let Paint::Gradient(p) = c.appearance.fill_paint() {
|
||||
starts.push(p.geom.unwrap().start.x);
|
||||
}
|
||||
});
|
||||
assert!(!starts.is_empty() && starts.iter().all(|s| (s - 30.0).abs() < 1e-6), "{starts:?}");
|
||||
}
|
||||
|
||||
/// The art Pathfinder and compound shapes take (`shape_art`) paints as the canvas does: a path's
|
||||
/// gradient and a group's own fill move with a Transform effect.
|
||||
#[test]
|
||||
fn shape_art_moves_gradients_with_a_transform_effect() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let moved = || Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 40}), visible: true };
|
||||
let starts = |art: &Node| {
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c: &Node| {
|
||||
for item in &c.appearance.items {
|
||||
if let Paint::Gradient(p) = item.paint() {
|
||||
out.push(p.geom.unwrap().start.x);
|
||||
}
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
let mut path = Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0));
|
||||
path.appearance.effects.push(moved());
|
||||
let got = starts(&shape_art(&path, None).unwrap());
|
||||
assert!(got.len() == 1 && (got[0] - 40.0).abs() < 1e-6, "the path's gradient: {got:?}");
|
||||
let mut g = Node::group(NodeId(10), vec![sq(2, 0.0, Color::BLACK)]);
|
||||
g.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
|
||||
g.appearance.effects.push(moved());
|
||||
let got = starts(&shape_art(&g, None).unwrap());
|
||||
assert!(!got.is_empty() && got.iter().all(|s| (s - 40.0).abs() < 1e-6), "the group's own fill: {got:?}");
|
||||
}
|
||||
|
||||
/// A symbol instance's Transform effect moves the gradient placed on its art's own fill in the
|
||||
/// art Pathfinder takes, as it moves its members.
|
||||
#[test]
|
||||
fn shape_art_moves_the_gradient_of_a_symbol_instances_art_with_its_transform() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let mut art = Node::group(NodeId(20), vec![sq(21, 0.0, Color::BLACK)]);
|
||||
art.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed)));
|
||||
let hook = move |_: &Node| Some(art.clone());
|
||||
let mut instance = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
instance.appearance.effects.push(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
let out = shape_art(&instance, Some(&hook)).unwrap();
|
||||
let Paint::Gradient(g) = out.appearance.fill_paint() else { panic!("a gradient: {:?}", out.appearance) };
|
||||
assert!((g.geom.unwrap().start.x - 30.0).abs() < 1e-6, "{:?}", g.geom);
|
||||
assert!((out.geometric_bounds().unwrap().x0 - 30.0).abs() < 1e-6, "the art moves");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_transform_and_free_distort_move_the_gradients_placed_on_its_paths() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let ends = |e: Effect| {
|
||||
let square =
|
||||
Arc::new(Node::path(NodeId(1), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::basic(placed.clone(), Paint::None, 0.0)));
|
||||
let mut g = Node::group(NodeId(10), vec![square, sq(2, 20.0, Color::BLACK)]);
|
||||
g.appearance.effects.push(e);
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
match art.children().unwrap()[0].appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
};
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
// A quarter turn about the group's center (15, 5) takes (0, 5) to (15, 20) and (10, 5) to (15, 10).
|
||||
let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"rotate": 90}), visible: true });
|
||||
assert!(near(s, Point::new(15.0, 20.0)) && near(e, Point::new(15.0, 10.0)), "{s:?} {e:?}");
|
||||
// Free Distort moving the group's top corners 10 pt right slants the square to 0..20 across:
|
||||
// its gradient follows its own bounds (0..10 to 0..20), not the group's (0..30 to 0..40).
|
||||
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
|
||||
let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true });
|
||||
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
|
||||
}
|
||||
|
||||
/// A group nested in a group with a Transform effect or Free Distort keeps its own placed gradient
|
||||
/// on its members: the Transform's map moves it, and Free Distort moves and scales it from the
|
||||
/// nested group's bounds before the effect to its bounds after it.
|
||||
#[test]
|
||||
fn a_nested_groups_own_gradient_follows_the_outer_groups_transform_and_free_distort() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let ends = |e: Effect| {
|
||||
let mut inner = Node::group(NodeId(5), vec![sq(1, 0.0, Color::BLACK)]);
|
||||
inner.appearance.items.push(AppearanceItem::Fill(vectorcraft_doc::FillLayer::new(placed.clone())));
|
||||
let mut g = Node::group(NodeId(10), vec![Arc::new(inner), sq(2, 20.0, Color::BLACK)]);
|
||||
g.appearance.effects.push(e);
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
match art.children().unwrap()[0].appearance.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.map(|g| (g.start, g.end)).unwrap(),
|
||||
p => panic!("a gradient: {p:?}"),
|
||||
}
|
||||
};
|
||||
let near = |a: Point, b: Point| a.distance(b) < 1e-6;
|
||||
let (s, e) = ends(Effect { id: "distort.transform".into(), params: serde_json::json!({"moveH": 30}), visible: true });
|
||||
assert!(near(s, Point::new(30.0, 5.0)) && near(e, Point::new(40.0, 5.0)), "{s:?} {e:?}");
|
||||
let corners = serde_json::json!({"corners": [[1.0 / 3.0, 0.0], [4.0 / 3.0, 0.0], [1.0, 1.0], [0.0, 1.0]]});
|
||||
let (s, e) = ends(Effect { id: "distort.freeDistort".into(), params: corners, visible: true });
|
||||
assert!(near(s, Point::new(0.0, 5.0)) && near(e, Point::new(20.0, 5.0)), "{s:?} {e:?}");
|
||||
}
|
||||
|
||||
/// An image or a symbol instance that a group's Transform effect moves takes the placed gradients
|
||||
/// of its own fills along, as a path does, and each copy takes them as many times as it takes the
|
||||
/// transform.
|
||||
#[test]
|
||||
fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_doc::ImageObject;
|
||||
let stops = vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0))];
|
||||
let ramp = GradientPaint::new(Gradient::new(GradientKind::Linear, stops));
|
||||
let geom = GradientGeom { start: (0.0, 5.0).into(), end: (10.0, 5.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..ramp }));
|
||||
let image = || {
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
xf: vectorcraft_geom::Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
}))
|
||||
};
|
||||
let instance = || NodeKind::SymbolInstance { symbol: "s".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
let starts = |kind: NodeKind, params: serde_json::Value| {
|
||||
let mut object = Node::new(NodeId(1), kind);
|
||||
object.appearance = Appearance::basic(placed.clone(), Paint::None, 0.0).into();
|
||||
let mut g = Node::group(NodeId(10), vec![Arc::new(object), sq(2, 20.0, Color::BLACK)]);
|
||||
g.appearance.effects.push(Effect { id: "distort.transform".into(), params, visible: true });
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let mut out = vec![];
|
||||
art.walk(&mut |c: &Node| {
|
||||
if let (NodeKind::Image(_) | NodeKind::SymbolInstance { .. }, Paint::Gradient(p)) = (&c.kind, c.appearance.fill_paint()) {
|
||||
out.push(p.geom.unwrap().start.x);
|
||||
}
|
||||
});
|
||||
out
|
||||
};
|
||||
for kind in [image as fn() -> NodeKind, instance] {
|
||||
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind());
|
||||
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
|
||||
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur, Rasterize, and the
|
||||
//! Photoshop-style filters of [`pixel`]: Radial Blur, Smart Blur, Color Halftone, Crystallize, Mezzotint, Pointillize, Halftone Pattern,
|
||||
//! Unsharp Mask and Glowing Edges) are described by
|
||||
//! Unsharp Mask and Glowing Edges, and the SVG filters of [`svg_filter`]) are described by
|
||||
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
|
||||
//! the geometry.
|
||||
//! - **Stroke geometry** ([`stroke`]): arrowheads, dash patterns and width profiles, shared by the
|
||||
@@ -52,6 +52,7 @@ mod revolve;
|
||||
mod solids;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
pub mod svg_filter;
|
||||
pub mod trap;
|
||||
mod util;
|
||||
mod warp;
|
||||
@@ -84,11 +85,13 @@ pub use reshape::{
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use solids::{
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
|
||||
three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
|
||||
three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
pub use trap::TRAP;
|
||||
pub use vectorcraft_three_d::Mesh;
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
mod catalog;
|
||||
@@ -97,7 +100,9 @@ mod params;
|
||||
|
||||
pub use catalog::{EffectInfo, Lengths, WARP_STYLES, effect_catalog, effect_info, is_length, length_range, plugin_effects, raster_px_range};
|
||||
pub(crate) use geometry::{Budget, apply_one};
|
||||
pub use geometry::{GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_with};
|
||||
pub use geometry::{
|
||||
Carry, GeomContext, apply_geometry, apply_geometry_bez, apply_geometry_bez_carrying, apply_geometry_carrying, apply_geometry_with,
|
||||
};
|
||||
#[cfg(test)]
|
||||
pub(crate) use geometry::{MAX_EFFECT_LINES, apply_geometry_within};
|
||||
pub use params::{
|
||||
|
||||
@@ -119,6 +119,7 @@ pub fn scale_effect(e: &mut Effect, s: f64) {
|
||||
pub fn is_raster(id: &str) -> bool {
|
||||
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian" | RASTERIZE)
|
||||
|| PIXEL_EFFECTS.contains(&id)
|
||||
|| id == crate::SVG_FILTER
|
||||
}
|
||||
|
||||
/// Does `id` change geometry? (Crop Marks and Vector Halftone add art of their own instead,
|
||||
|
||||
@@ -287,6 +287,8 @@ pub enum PixelFx {
|
||||
DeInterlace { even: bool, interpolate: bool },
|
||||
/// Video › NTSC Colors: colours a television signal can't carry made less saturated.
|
||||
NtscColors,
|
||||
/// Effect › SVG Filters: an SVG `<filter>` element run on the pixels.
|
||||
Svg(crate::svg_filter::SvgFilterFx),
|
||||
}
|
||||
|
||||
/// What a raster's colour channels hold, for the filters that treat them apart (Color Halftone
|
||||
@@ -598,6 +600,7 @@ pub(crate) fn parse(id: &str, p: &Value) -> Option<PixelFx> {
|
||||
interpolate: text(p, "create", "duplication").eq_ignore_ascii_case("interpolation"),
|
||||
},
|
||||
"video.ntscColors" => PixelFx::NtscColors,
|
||||
crate::SVG_FILTER => PixelFx::Svg(crate::svg_filter::SvgFilterFx::parse(p)?),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -666,6 +669,7 @@ impl PixelFx {
|
||||
PixelFx::Crystallize { cell } | PixelFx::Pointillize { cell } | PixelFx::StainedGlass { cell, .. } => 1.5 * cell,
|
||||
// A square whose centre is on the object is drawn whole.
|
||||
PixelFx::Patchwork { square, .. } => square.max(1.0),
|
||||
PixelFx::Svg(ref f) => f.outset(bounds),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,12 +749,17 @@ impl PixelFx {
|
||||
// The lines above and below.
|
||||
PixelFx::DeInterlace { .. } => Some(video::MAX_LINE),
|
||||
PixelFx::NtscColors => Some(0.0),
|
||||
// A filter's object bounding box is the whole object's.
|
||||
PixelFx::Svg(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the effect on `data`, `w` × `h` premultiplied RGBA8 pixels lying in the document as
|
||||
/// `space` says. Does nothing when the sizes don't match.
|
||||
pub fn apply(&self, data: &mut [u8], w: usize, h: usize, space: &PixelSpace) {
|
||||
if let PixelFx::Svg(f) = self {
|
||||
return f.apply(data, w, h, space);
|
||||
}
|
||||
let Some(px) = pixels(data, w, h) else { return };
|
||||
let to_px = |len: f64| len / space.px.max(1e-9);
|
||||
match *self {
|
||||
@@ -829,6 +838,7 @@ impl PixelFx {
|
||||
}
|
||||
PixelFx::DeInterlace { even, interpolate } => video::deinterlace(px, w, h, space, even, interpolate),
|
||||
PixelFx::NtscColors => video::ntsc(px),
|
||||
PixelFx::Svg(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+217
-26
@@ -11,6 +11,7 @@
|
||||
//! strokes paint only the shape that a Convert to Shape or Outline Object effect on them gives
|
||||
//! them ([`image_rows`]).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::live::Hooks;
|
||||
@@ -594,9 +595,15 @@ pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect]) {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS);
|
||||
}
|
||||
|
||||
/// [`reshape_leaves`] whose paths keep to `budget` together, or to what they cost.
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) {
|
||||
reshape_leaves_apart(art, fx, budget, false);
|
||||
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
|
||||
/// strokes) follow the effects ([`crate::Carry`]).
|
||||
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect]) -> crate::Carry {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS)
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget) -> crate::Carry {
|
||||
reshape_leaves_apart(art, fx, budget, false)
|
||||
}
|
||||
|
||||
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
|
||||
@@ -616,10 +623,12 @@ fn hoist_copies(art: &mut Node) {
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) {
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. Returns how
|
||||
/// gradients that paint the art as a whole follow the effects.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool) -> crate::Carry {
|
||||
let mut whole = crate::Carry::default();
|
||||
if fx.is_empty() {
|
||||
return;
|
||||
return whole;
|
||||
}
|
||||
let mut leaves_safe = true;
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
@@ -638,15 +647,32 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
|
||||
let fx = opt_fx.as_slice();
|
||||
let seed = art.id.0;
|
||||
// The placed gradients of type's outlines in `art` (`outline_text`) move and scale with the
|
||||
// pieces, from the outlines' bounds before the effects to their bounds after them.
|
||||
let mut outlines = vec![];
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
let mut used = art_cost(art);
|
||||
let max = budget.at_least(used);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
// Placed gradients follow a Transform effect and Free Distort ([`crate::Carry`]): under
|
||||
// Free Distort (`boxes`) each path's move and scale from its bounds before the effect to
|
||||
// its bounds after it. Under that and the other effects, the placed gradients of type's
|
||||
// outlines in `art` (`outline_text`) move and scale with the outlines' bounds.
|
||||
let follows = crate::Carry::follows(&e.id, ¶ms, bounds, seed);
|
||||
let boxes = follows && e.id == "distort.freeDistort";
|
||||
let mut outlines = vec![];
|
||||
if !follows || boxes {
|
||||
type_outline_bounds(art, &mut outlines);
|
||||
}
|
||||
let mut leaves = vec![];
|
||||
if boxes {
|
||||
leaf_bounds(art, &mut leaves);
|
||||
}
|
||||
let mut groups = vec![];
|
||||
if follows {
|
||||
group_marks(art, &mut vec![], &HashSet::new(), &mut groups);
|
||||
}
|
||||
// Whether a path kept its shape (past the budget), and the ids of those that did.
|
||||
let mut kept = false;
|
||||
let mut kept_ids = HashSet::new();
|
||||
let transform = (e.id == "distort.transform").then(|| {
|
||||
let (m, copies) = crate::distort::transform_map(bounds, ¶ms, seed);
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
@@ -668,8 +694,12 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
|| leaf.appearance.has_placed_gradient()
|
||||
|| paints_copies_apart(&leaf.appearance, rule));
|
||||
if options.reach_appearance() {
|
||||
let made = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
let (made, moved) = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
used = used.minus(before).plus(made);
|
||||
if !moved {
|
||||
kept = true;
|
||||
kept_ids.insert(leaf.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if separate {
|
||||
@@ -678,19 +708,41 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else { return };
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
|
||||
kept = true;
|
||||
kept_ids.insert(leaf.id);
|
||||
return;
|
||||
};
|
||||
used = used.minus(before).plus(Budget::of(&out));
|
||||
if follows && !boxes && leaf.appearance.has_placed_gradient() {
|
||||
crate::Carry::of(&e.id, ¶ms, bounds, None, None, seed).apply(&mut leaf.appearance);
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
if boxes {
|
||||
rebase_leaves(art, &mut leaves.into_iter());
|
||||
}
|
||||
if let Some((m, copies, options, _)) = transform
|
||||
&& options.objects
|
||||
{
|
||||
transform_placed(art, m, copies);
|
||||
}
|
||||
if outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
// What paints the art as a whole, and the own fills and strokes of the groups inside it,
|
||||
// follow their bounds under Free Distort (they stay when no path changed them). Each stays
|
||||
// when a path under it kept its shape (past the budget).
|
||||
if !groups.is_empty() {
|
||||
let mut after = vec![];
|
||||
group_marks(art, &mut vec![], &kept_ids, &mut after);
|
||||
carry_groups(art, groups, after, &|from, to| crate::Carry::of(&e.id, ¶ms, bounds, from, to, seed));
|
||||
}
|
||||
if follows && !kept {
|
||||
whole.push(crate::Carry::of(&e.id, ¶ms, bounds, Some(bounds), art.geometric_bounds(), seed));
|
||||
}
|
||||
}
|
||||
if outlines.iter().any(Option::is_some) {
|
||||
rebase_type_outlines(art, &mut outlines.into_iter());
|
||||
}
|
||||
whole
|
||||
}
|
||||
|
||||
/// Largest factor a Transform effect's Scale Strokes & Effects scales one object's strokes by
|
||||
@@ -704,8 +756,16 @@ const MAX_STROKE_SCALE: f64 = 1.0e6;
|
||||
/// 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.
|
||||
fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, options: crate::distort::TransformOptions, budget: Budget) -> Budget {
|
||||
/// 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`.
|
||||
fn transform_leaf(
|
||||
leaf: &mut Node,
|
||||
path: &PathData,
|
||||
m: Affine,
|
||||
copies: usize,
|
||||
options: crate::distort::TransformOptions,
|
||||
budget: Budget,
|
||||
) -> (Budget, bool) {
|
||||
let scaling = vectorcraft_doc::Scaling {
|
||||
strokes: options.strokes,
|
||||
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
|
||||
@@ -722,18 +782,18 @@ fn transform_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, op
|
||||
if options.patterns {
|
||||
vectorcraft_doc::pattern::transform_pattern_paints(leaf, m);
|
||||
}
|
||||
return Budget::of(path);
|
||||
return (Budget::of(path), true);
|
||||
}
|
||||
if copies == 0 {
|
||||
// A path scaled up is drawn with more lines; past the budget it keeps its shape.
|
||||
let moved = path.transformed(m);
|
||||
if !budget.fits(Budget::of(&moved)) {
|
||||
return Budget::of(path);
|
||||
return (Budget::of(path), false);
|
||||
}
|
||||
leaf.transform(m, scaling);
|
||||
return Budget::of(&moved);
|
||||
return (Budget::of(&moved), true);
|
||||
}
|
||||
copy_leaf(leaf, path, m, copies, scaling, budget)
|
||||
(copy_leaf(leaf, path, m, copies, scaling, budget), true)
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
@@ -843,8 +903,10 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
|
||||
let reserved = (0..later(i)).fold(Budget { anchors: 0, lines: 0 }, |r, _| r.plus(cost));
|
||||
let limit = max.minus(reserved);
|
||||
let room = limit.minus(*used);
|
||||
// The fill's or stroke's gradient follows the geometry effects before the Transform effect.
|
||||
let mut carry = crate::Carry::default();
|
||||
let g = if crate::has_geometry(before) && !path.is_empty() {
|
||||
crate::geometry::apply_geometry_within(before, &path, bounds, &ctx.item(item), room)
|
||||
crate::geometry::apply_geometry_carrying_within(before, &path, bounds, &ctx.item(item), room, Some(&mut carry))
|
||||
} else {
|
||||
path.clone()
|
||||
};
|
||||
@@ -857,8 +919,11 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget) {
|
||||
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
*it.effects_mut() = rest;
|
||||
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 (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
|
||||
*used = used.plus(made);
|
||||
transform_piece(&mut p, ctx.seed, limit, used);
|
||||
pieces.push(Arc::new(p));
|
||||
@@ -902,7 +967,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
|
||||
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 (made, _) = transform_leaf(leaf, &path, map, copies, options, room);
|
||||
*used = used.minus(before).plus(made);
|
||||
return;
|
||||
}
|
||||
@@ -912,8 +977,13 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget) {
|
||||
AppearanceItem::Fill(_) => None,
|
||||
};
|
||||
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0) };
|
||||
let out = crate::geometry::apply_geometry_within(std::slice::from_ref(&e), &path, bounds, &ctx, room);
|
||||
let mut carry = crate::Carry::default();
|
||||
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
|
||||
*used = used.minus(before).plus(Budget::of(&out));
|
||||
// The fill's or stroke's gradient follows the effect as the object's does.
|
||||
if carry.moves(&leaf.appearance) {
|
||||
carry.apply(&mut leaf.appearance);
|
||||
}
|
||||
set_geometry(leaf, out, rule);
|
||||
});
|
||||
}
|
||||
@@ -957,11 +1027,128 @@ fn rebase_type_outlines(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rec
|
||||
}
|
||||
}
|
||||
|
||||
/// The geometric bounds of each path under `n` that [`for_each_leaf`] visits, outside groups of
|
||||
/// type's outlines (those follow the outlines' bounds, [`rebase_type_outlines`]).
|
||||
fn leaf_bounds(n: &Node, out: &mut Vec<Option<Rect>>) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => out.push(n.geometric_bounds()),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } if !n.extras().type_outlines => {
|
||||
for c in children {
|
||||
leaf_bounds(c, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A group inside the art whose own fills and strokes [`carry_groups`] moves (groups of type's
|
||||
/// outlines follow the outlines' bounds, [`rebase_type_outlines`]).
|
||||
fn is_member_group(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && !n.extras().type_outlines
|
||||
}
|
||||
|
||||
/// A group below the art that [`is_member_group`] takes and whose own fills or strokes hold a
|
||||
/// placed gradient: the child indices that lead to it from the art, its geometric bounds, and
|
||||
/// whether a path below it kept its shape.
|
||||
type GroupMark = (Vec<usize>, Option<Rect>, bool);
|
||||
|
||||
/// The geometric bounds of `n` (as [`Node::geometric_bounds`] gives them) and whether a path or
|
||||
/// compound path below it is in `kept`, measured in one walk. Each group below `n` that
|
||||
/// [`GroupMark`] describes goes into `out`, reached from `n` through `at`.
|
||||
fn group_marks(n: &Node, at: &mut Vec<usize>, kept: &HashSet<NodeId>, out: &mut Vec<GroupMark>) -> (Option<Rect>, bool) {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => (n.geometric_bounds(), kept.contains(&n.id)),
|
||||
NodeKind::Group { children, clip } | NodeKind::Layer { children, clip, .. } if !n.extras().type_outlines => {
|
||||
let layer = matches!(n.kind, NodeKind::Layer { .. });
|
||||
let skip = children.len().saturating_sub(n.editing_children().len());
|
||||
let (mut bounds, mut held) = (None, false);
|
||||
for (i, c) in children.iter().enumerate() {
|
||||
at.push(i);
|
||||
let (b, h) = group_marks(c, at, kept, out);
|
||||
if is_member_group(c) && c.appearance.has_placed_gradient() {
|
||||
out.push((at.clone(), b, h));
|
||||
}
|
||||
at.pop();
|
||||
held |= h;
|
||||
let counts = if *clip { i == 0 } else { i >= skip && (c.visible || !layer) };
|
||||
if counts {
|
||||
bounds = vectorcraft_geom::union_opt(bounds, b);
|
||||
}
|
||||
}
|
||||
(bounds, held)
|
||||
}
|
||||
// Type's outlines: measured as a whole, with each outline that kept its shape counted.
|
||||
NodeKind::Group { .. } | NodeKind::Layer { .. } => (n.geometric_bounds(), !kept.is_empty() && holds_kept(n, kept)),
|
||||
_ => (n.geometric_bounds(), false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a path or compound path below `n` (`n` included) in `kept`?
|
||||
fn holds_kept(n: &Node, kept: &HashSet<NodeId>) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => kept.contains(&n.id),
|
||||
_ => n.children().into_iter().flatten().any(|c| holds_kept(c, kept)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Move the placed gradients of the own fills and strokes of each group in `after` (the groups of
|
||||
/// `before`, measured again once the paths changed) by what `carry` gives for its bounds before
|
||||
/// and after. A group with a path below it that kept its shape keeps them in place.
|
||||
fn carry_groups(art: &mut Node, before: Vec<GroupMark>, after: Vec<GroupMark>, carry: &dyn Fn(Option<Rect>, Option<Rect>) -> crate::Carry) {
|
||||
for ((at, from, _), (at_after, to, held)) in before.into_iter().zip(after) {
|
||||
if held || at != at_after {
|
||||
continue;
|
||||
}
|
||||
let k = carry(from, to);
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(n) = node_at(art, &at)
|
||||
&& is_member_group(n)
|
||||
&& k.moves(&n.appearance)
|
||||
{
|
||||
k.apply(&mut n.appearance);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The node below `n` that the child indices `at` lead to.
|
||||
fn node_at<'a>(n: &'a mut Node, at: &[usize]) -> Option<&'a mut Node> {
|
||||
let Some((first, rest)) = at.split_first() else { return Some(n) };
|
||||
let c = n.children_mut()?.get_mut(*first)?;
|
||||
node_at(Arc::make_mut(c), rest)
|
||||
}
|
||||
|
||||
/// Move and scale the placed gradients of each path under `n` that [`leaf_bounds`] lists from its
|
||||
/// bounds in `before` (before its geometry changed) to its bounds now.
|
||||
fn rebase_leaves(n: &mut Node, before: &mut std::vec::IntoIter<Option<Rect>>) {
|
||||
if matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
if let Some(Some(from)) = before.next()
|
||||
&& let Some(to) = n.geometric_bounds()
|
||||
&& from != to
|
||||
&& n.appearance.has_placed_gradient()
|
||||
{
|
||||
follow_boxes(&mut n.appearance, from, to);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if n.extras().type_outlines {
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } = &mut n.kind {
|
||||
for c in children.iter_mut() {
|
||||
if matches!(c.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } | NodeKind::Group { .. } | NodeKind::Layer { .. }) {
|
||||
rebase_leaves(Arc::make_mut(c), before);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the placed gradients of `ap` from box `from` to box `to` through the map that takes one box
|
||||
/// onto the other (strokes: their stroke-inflated boxes). The start and end go where
|
||||
/// [`Appearance::rebase_gradients`] puts them, and a radial's ellipse and a linear's isolines are
|
||||
/// mapped with them.
|
||||
fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
|
||||
pub(crate) fn follow_boxes(ap: &mut Appearance, from: Rect, to: Rect) {
|
||||
for i in &mut ap.items {
|
||||
match i {
|
||||
AppearanceItem::Fill(f) => {
|
||||
@@ -1036,6 +1223,10 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
NodeKind::SymbolInstance { xf: at, .. } => *at = xf * *at,
|
||||
_ => {}
|
||||
}
|
||||
// The placed gradients of its own fills and strokes move with it, as a path's do.
|
||||
if xf != Affine::IDENTITY && one.appearance.has_placed_gradient() {
|
||||
one.appearance.transform_gradients(xf);
|
||||
}
|
||||
one
|
||||
};
|
||||
if copies == 0 {
|
||||
|
||||
@@ -226,6 +226,45 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, true)
|
||||
}
|
||||
|
||||
/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on
|
||||
/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for
|
||||
/// each of them.
|
||||
pub(crate) fn revolve_meshes(n: &Node) -> Result<Vec<crate::SolidMesh>, String> {
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path")?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result<crate::SolidMesh, String> {
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
let (paint, opacity, _) = paint_of(item);
|
||||
let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?;
|
||||
Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) })
|
||||
};
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let before = n.appearance.effects.get(..index).unwrap_or_default();
|
||||
let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) };
|
||||
let path = geometry(&path, before, &ctx);
|
||||
let item = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i))
|
||||
.or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i)))
|
||||
.ok_or("the revolved object has no fill or stroke")?;
|
||||
return Ok(vec![mesh(&path, fx, item)?]);
|
||||
}
|
||||
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 && e.id == REVOLVE)
|
||||
&& let Some(fx) = item.effects().get(index)
|
||||
{
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
out.push(mesh(&p, fx, item)?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn art(n: &Node, expand: bool) -> Option<Node> {
|
||||
if !has_revolve(n) {
|
||||
return None;
|
||||
|
||||
+181
-23
@@ -6,7 +6,7 @@ use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View};
|
||||
|
||||
#[cfg(test)]
|
||||
use vectorcraft_doc::color::Color;
|
||||
@@ -221,6 +221,10 @@ fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
|
||||
/// The most members a 3D effect on a group builds solids from.
|
||||
const MAX_MEMBERS: usize = 4096;
|
||||
|
||||
/// The most triangles the meshes of one 3D effect hold together (about 290 MB of corners and
|
||||
/// normals): past it the object is too complex to export.
|
||||
const MAX_MESH_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
|
||||
/// compound path and type in it, at any depth of its groups, each through its own geometry
|
||||
/// effects and painted with its own fills and strokes. Like the effect on a group in the
|
||||
@@ -338,6 +342,38 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
|
||||
/// a group's members from the back up, each with its shape's opacity and the region it paints.
|
||||
/// The solids share the bounds of all their parts (`fallback` when none has any): one center to
|
||||
/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
|
||||
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) {
|
||||
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
|
||||
let (area, rule) = region(&s.path, s.rule, i);
|
||||
(i, s.opacity, area, rule)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
||||
(parts, bounds)
|
||||
}
|
||||
|
||||
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
|
||||
/// its 3D effect, about the bounds of all of them.
|
||||
fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
|
||||
if before.iter().any(|e| e.visible && is_geometry(&e.id))
|
||||
&& let Some(b) = union_bounds(shapes)
|
||||
{
|
||||
let ctx = GeomContext::of(n);
|
||||
for s in shapes {
|
||||
s.path = apply_geometry_with(before, &s.path, b, &ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
|
||||
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
|
||||
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
|
||||
@@ -363,20 +399,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
// Each shape's top fill and its strokes, a group's members from the back up.
|
||||
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
|
||||
let (area, rule) = region(&s.path, s.rule, i);
|
||||
(i, s.opacity, area, rule)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// The solids share the bounds of all their shapes: one center to turn, twist and taper about,
|
||||
// and Inflate's grid reaches over the strokes' outlines.
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b);
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
@@ -493,14 +516,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
fn group_solid_art(n: &Node) -> Option<Node> {
|
||||
let (index, fx) = solid(&n.appearance.effects)?;
|
||||
let mut shapes = members(n, "").ok()?;
|
||||
let before = n.appearance.effects.get(..index)?;
|
||||
if before.iter().any(|e| e.visible && is_geometry(&e.id)) {
|
||||
let b = union_bounds(&shapes)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
for s in &mut shapes {
|
||||
s.path = apply_geometry_with(before, &s.path, b, &ctx);
|
||||
}
|
||||
}
|
||||
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
|
||||
let children = art_for(n, fx, &shapes)?;
|
||||
let remaining =
|
||||
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
|
||||
@@ -510,5 +526,147 @@ fn group_solid_art(n: &Node) -> Option<Node> {
|
||||
Some(art)
|
||||
}
|
||||
|
||||
/// One solid of a 3D object as a mesh, with the color it is painted.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SolidMesh {
|
||||
pub mesh: Mesh,
|
||||
/// The paint's display color (sRGB, 0..1) and its opacity: a gradient's stops averaged, gray
|
||||
/// for a pattern.
|
||||
pub color: [f32; 4],
|
||||
}
|
||||
|
||||
/// The color a mesh of `paint` at `opacity` takes ([`SolidMesh::color`]).
|
||||
pub(crate) fn mesh_color(paint: &Paint, opacity: f32) -> [f32; 4] {
|
||||
let (rgb, alpha) = match paint {
|
||||
Paint::Solid { color, .. } => (color.to_rgb(), 1.0),
|
||||
Paint::Gradient(g) if !g.gradient.stops.is_empty() => {
|
||||
let n = g.gradient.stops.len() as f32;
|
||||
let mut sum = [0.0f32; 4];
|
||||
for s in &g.gradient.stops {
|
||||
let [r, gr, b] = s.color.to_rgb();
|
||||
sum = [sum[0] + r, sum[1] + gr, sum[2] + b, sum[3] + s.opacity];
|
||||
}
|
||||
([sum[0] / n, sum[1] / n, sum[2] / n], sum[3] / n)
|
||||
}
|
||||
_ => ([0.5; 3], 1.0),
|
||||
};
|
||||
let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 };
|
||||
[unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(alpha * opacity)]
|
||||
}
|
||||
|
||||
/// Add `mesh` to `out`, counting its triangles into `total`, or an error when that passes `limit`.
|
||||
pub(crate) fn keep_mesh(out: &mut Vec<SolidMesh>, total: &mut usize, mesh: SolidMesh, limit: usize) -> Result<(), String> {
|
||||
*total = total.saturating_add(mesh.mesh.triangles.len());
|
||||
if *total > limit {
|
||||
return Err("The 3D object is too complex to export".into());
|
||||
}
|
||||
out.push(mesh);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns
|
||||
/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one
|
||||
/// from the outline of each stroke. A stroke whose solid is past the limits is left out.
|
||||
fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
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 view = view_options(&p);
|
||||
let mut out = vec![];
|
||||
let mut keep = |out: &mut Vec<SolidMesh>, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES);
|
||||
if fx.id == ROTATE {
|
||||
for s in shapes {
|
||||
for item in &s.items {
|
||||
let (paint, opacity, _) = paint_of(item, s.opacity);
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
keep(&mut out, vectorcraft_three_d::plane_mesh(&area, rule, b, view)?, mesh_color(&paint, opacity))?;
|
||||
}
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
// Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude &
|
||||
// Bevel solid, is built as it is reached.
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
});
|
||||
let extrude = extrude_options(&p);
|
||||
for (item, member, area, rule) in &parts {
|
||||
let item: &AppearanceItem = item;
|
||||
let mesh = match inflated.as_mut() {
|
||||
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
||||
None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view),
|
||||
};
|
||||
match mesh {
|
||||
Ok(mesh) => {
|
||||
let (paint, opacity, _) = paint_of(item, *member);
|
||||
keep(&mut out, mesh, mesh_color(&paint, opacity))?;
|
||||
}
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The solids `n`'s Extrude & Bevel, Inflate, Rotate or Revolve effect builds, as meshes with the
|
||||
/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes
|
||||
/// with no triangles are left out. On a group or layer, the effect's solids of its members
|
||||
/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no
|
||||
/// such effect, its solids can't be built, or none has a triangle.
|
||||
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
|
||||
// Triangles of every solid together, against [`MAX_MESH_TRIANGLES`].
|
||||
let mut total = 0usize;
|
||||
let meshes = if crate::has_revolve(n) {
|
||||
let mut out = vec![];
|
||||
for m in crate::revolve::revolve_meshes(n)? {
|
||||
keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?;
|
||||
}
|
||||
out
|
||||
} else if !has_solid(n) {
|
||||
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
|
||||
} else 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 to export")?;
|
||||
let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let mut shapes = members(n, label)?;
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
meshes_for(fx, &shapes, &mut total)?
|
||||
} else {
|
||||
let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?;
|
||||
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);
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)?
|
||||
} else {
|
||||
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));
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
let meshes: Vec<SolidMesh> = meshes
|
||||
.into_iter()
|
||||
.filter(|m| !m.mesh.is_empty())
|
||||
.map(|mut m| {
|
||||
m.color[3] *= n.opacity.clamp(0.0, 1.0);
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
if meshes.is_empty() {
|
||||
return Err("its 3D effect builds no solid".into());
|
||||
}
|
||||
Ok(meshes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_mesh;
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeId;
|
||||
use vectorcraft_doc::color::{Color, Gradient, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_geom::BezPath;
|
||||
|
||||
use super::*;
|
||||
|
||||
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
|
||||
|
||||
fn square_node(id: u64, x: f64) -> Node {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((x, 100.0));
|
||||
b.line_to((x + 100.0, 100.0));
|
||||
b.line_to((x + 100.0, 200.0));
|
||||
b.line_to((x, 200.0));
|
||||
b.close_path();
|
||||
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0))
|
||||
}
|
||||
|
||||
fn fx(id: &str, params: Value) -> Effect {
|
||||
crate::new_effect(id, ¶ms).unwrap()
|
||||
}
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_3d_effect_gives_a_mesh_for_its_fill_and_its_stroke() {
|
||||
for id in SOLIDS {
|
||||
let mut n = square_node(1, 100.0);
|
||||
n.appearance.effects.push(fx(id, json!({})));
|
||||
let meshes = three_d_meshes(&n).unwrap_or_else(|e| panic!("{id}: {e}"));
|
||||
assert_eq!(meshes.len(), 2, "{id}: the fill's solid and the stroke's");
|
||||
assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}: {:?}", meshes[0].color);
|
||||
assert!(close(meshes[1].color, [0.0, 0.0, 0.0, 1.0]), "{id}: {:?}", meshes[1].color);
|
||||
assert!(meshes.iter().all(|m| !m.mesh.is_empty()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_meshes_of_one_object_share_one_triangle_budget() {
|
||||
let mut n = square_node(1, 100.0);
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let meshes = three_d_meshes(&n).unwrap();
|
||||
let first = meshes[0].mesh.triangles.len();
|
||||
assert!(first > 0);
|
||||
// Every mesh counts against what the ones before it used.
|
||||
let (mut out, mut total) = (vec![], 0);
|
||||
keep_mesh(&mut out, &mut total, meshes[0].clone(), first).unwrap();
|
||||
assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err());
|
||||
assert_eq!(out.len(), 1);
|
||||
// Building past the budget stops with an error instead of holding every mesh.
|
||||
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
|
||||
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
|
||||
let mut total = 0;
|
||||
assert!(meshes_for(effect, &shapes, &mut total).is_ok());
|
||||
let mut total = MAX_MESH_TRIANGLES - first + 1;
|
||||
assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_extruded_mesh_is_as_deep_as_the_effect() {
|
||||
let mut n = square_node(1, 100.0);
|
||||
n.appearance.items.truncate(1);
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({ "depth": 30.0, "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 })));
|
||||
let meshes = three_d_meshes(&n).unwrap();
|
||||
let (lo, hi) = meshes[0].mesh.bounds().unwrap();
|
||||
assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_gives_a_mesh_in_the_top_fill() {
|
||||
let mut b = BezPath::new();
|
||||
b.move_to((100.0, 40.0));
|
||||
b.line_to((130.0, 60.0));
|
||||
b.line_to((130.0, 140.0));
|
||||
b.line_to((100.0, 160.0));
|
||||
let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0));
|
||||
n.appearance.effects.push(fx(crate::REVOLVE, json!({})));
|
||||
let meshes = three_d_meshes(&n).unwrap();
|
||||
assert_eq!(meshes.len(), 1);
|
||||
assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0]));
|
||||
assert!(meshes[0].mesh.triangles.len() >= 64 * 3, "three profile edges swept in 64 steps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_without_a_3d_effect_has_no_mesh() {
|
||||
let mut n = square_node(1, 100.0);
|
||||
assert!(three_d_meshes(&n).is_err());
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(three_d_meshes(&n).is_err(), "a hidden effect builds nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_object_and_the_paint_opacities_multiply() {
|
||||
let mut n = square_node(1, 100.0);
|
||||
n.appearance.items.truncate(1);
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
|
||||
f.opacity = 0.5;
|
||||
}
|
||||
n.opacity = 0.5;
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let meshes = three_d_meshes(&n).unwrap();
|
||||
assert!((meshes[0].color[3] - 0.25).abs() < 1e-6, "{:?}", meshes[0].color);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_effect_on_one_fill_gives_that_fills_mesh_alone() {
|
||||
let mut n = square_node(1, 100.0);
|
||||
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) {
|
||||
s.effects.push(fx(EXTRUDE, json!({})));
|
||||
}
|
||||
let meshes = three_d_meshes(&n).unwrap();
|
||||
assert_eq!(meshes.len(), 1);
|
||||
assert!(close(meshes[0].color, [0.0, 0.0, 0.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_gives_a_mesh_for_each_member() {
|
||||
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 300.0))]);
|
||||
g.appearance.effects.push(fx(EXTRUDE, json!({ "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 })));
|
||||
let meshes = three_d_meshes(&g).unwrap();
|
||||
assert_eq!(meshes.len(), 4, "each member's fill and stroke");
|
||||
// The members turn about their shared centre: the first is left of it, the second right.
|
||||
let (lo, _) = meshes[0].mesh.bounds().unwrap();
|
||||
let (_, hi) = meshes[2].mesh.bounds().unwrap();
|
||||
assert!(lo[0] < -150.0 && hi[0] > 150.0, "{lo:?} {hi:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gradient_takes_the_average_of_its_stops() {
|
||||
let stops =
|
||||
vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)) }];
|
||||
let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
assert!(close(mesh_color(&paint, 1.0), [0.5, 0.0, 0.5, 0.75]));
|
||||
let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY };
|
||||
assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5]));
|
||||
}
|
||||
@@ -13,6 +13,8 @@
|
||||
//! fill them ([`gradient_slices`]); Outline Stroke turns the strip into a gradient mesh
|
||||
//! ([`gradient_meshes`]).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use kurbo::{BezPath, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientPaint, Paint};
|
||||
use vectorcraft_doc::live::{H_DOWN, H_LEFT, H_RIGHT, H_UP};
|
||||
@@ -78,14 +80,26 @@ struct Lay<'a> {
|
||||
st: &'a StrokeLayer,
|
||||
/// Half the stroked width ([`aligned_width`]).
|
||||
hw: f64,
|
||||
/// How far ribs reach either side of the path where nothing wider is near.
|
||||
cover: f64,
|
||||
/// How far ribs reach past the stroke's edge.
|
||||
margin: f64,
|
||||
/// How far a strip runs on past the start and end of an open subpath (caps and heads).
|
||||
ext: (f64, f64),
|
||||
}
|
||||
|
||||
impl Lay<'_> {
|
||||
/// How far the stroke reaches either side of the path at fraction `t` of a subpath: its wider
|
||||
/// side, just before or just after a step there.
|
||||
fn half(&self, t: f64) -> f64 {
|
||||
let Some(p) = &self.st.profile else { return self.hw };
|
||||
let ((bl, br), (al, ar)) = p.around(t);
|
||||
self.hw * bl.max(br).max(al).max(ar).max(0.0)
|
||||
}
|
||||
|
||||
/// How far a rib at fraction `t` reaches either side of the path to cover the stroke there.
|
||||
fn cover(&self, t: f64) -> f64 {
|
||||
self.half(t) + self.margin
|
||||
}
|
||||
|
||||
/// The across band at fraction `t` of a subpath whose visible side is `side` (`Some(true)`:
|
||||
/// left; `None`: both, a centred stroke).
|
||||
fn band(&self, t: f64, side: Option<bool>) -> (f64, f64) {
|
||||
@@ -147,10 +161,11 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
if !(hw > 0.0 && hw.is_finite()) {
|
||||
return vec![];
|
||||
}
|
||||
let full = hw * st.profile_max();
|
||||
let heads = (st.head_reach(false), st.head_reach(true));
|
||||
let cap = cap_extent(st.cap, 2.0 * full);
|
||||
let lay = Lay { st, hw, cover: full + margin, margin, ext: (cap.max(heads.0) + margin, cap.max(heads.1) + margin) };
|
||||
let mut lay = Lay { st, hw, margin, ext: (0.0, 0.0) };
|
||||
// Caps reach as far past the ends as the stroke is wide there.
|
||||
let cap = |t: f64| cap_extent(st.cap, 2.0 * lay.half(t));
|
||||
lay.ext = (cap(0.0).max(heads.0) + margin, cap(1.0).max(heads.1) + margin);
|
||||
let subs = subpaths(bp);
|
||||
let count = subs.len();
|
||||
let mut out = vec![];
|
||||
@@ -165,7 +180,8 @@ fn strips(bp: &BezPath, rule: FillRule, st: &StrokeLayer, tol: f64, margin: f64,
|
||||
// A subpath of no length: a dot (round and projecting caps paint it).
|
||||
1 => {
|
||||
let (d, b, e) = (Vec2::new(1.0, 0.0), lay.band(0.0, side), lay.ext.0.max(lay.ext.1));
|
||||
vec![Rib::square(pts[0] - d * e, d, lay.cover, 0.0, b), Rib::square(pts[0] + d * e, d, lay.cover, 0.0, b)]
|
||||
let r = lay.cover(0.0);
|
||||
vec![Rib::square(pts[0] - d * e, d, r, 0.0, b), Rib::square(pts[0] + d * e, d, r, 0.0, b)]
|
||||
}
|
||||
_ => {
|
||||
let total = length(&pts, closed);
|
||||
@@ -215,9 +231,37 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
let n = pts.len();
|
||||
let seg_count = if closed { n } else { n - 1 };
|
||||
let dir: Vec<Vec2> = (0..seg_count).map(|i| unit(pts[(i + 1) % n] - pts[i])).collect();
|
||||
// Ribs near an arrowhead reach across it.
|
||||
let reach = |s: f64| {
|
||||
let mut r = lay.cover;
|
||||
// How far each point is from the next, and along the subpath.
|
||||
let seg: Vec<f64> = (0..n).map(|i| pts[i].distance(pts[(i + 1) % n])).collect();
|
||||
let along: Vec<f64> = seg
|
||||
.iter()
|
||||
.scan(0.0, |s, l| {
|
||||
let at = *s;
|
||||
*s += l;
|
||||
Some(at)
|
||||
})
|
||||
.collect();
|
||||
// How far the ribs at point `i` reach: past the stroke there and on its way to the points
|
||||
// either side, where it is no wider than at one of the two (each side of a width profile
|
||||
// stays between its widths at the points of a span, and its points have points here). The
|
||||
// caps and dots of a dashed stroke reach along the path as far as it is wide where they sit,
|
||||
// so there the ribs cover the widest the stroke gets that far either way. Not as far as the
|
||||
// stroke is wide anywhere: where a stroke crosses or runs close by itself, a narrow part
|
||||
// reaching that far would paint over a wide part beside it (#5960). The start of a closed
|
||||
// subpath is also its end. Ribs near an arrowhead reach across it.
|
||||
let cover: Vec<f64> = (0..n).map(|i| if closed && i == 0 { lay.cover(0.0).max(lay.cover(1.0)) } else { lay.cover(along[i] / total) }).collect();
|
||||
let window = match lay.st.dash {
|
||||
Some(_) if lay.st.profile.is_some() => cap_extent(lay.st.cap, 2.0 * lay.hw * lay.st.profile_max()),
|
||||
_ => 0.0,
|
||||
};
|
||||
let ahead = widest_ahead(&cover, &seg, closed, window);
|
||||
// Behind a point is ahead of it on the subpath run backwards.
|
||||
let back_seg: Vec<f64> = (0..n).map(|i| seg[(2 * n - 2 - i) % n]).collect();
|
||||
let back_cover: Vec<f64> = cover.iter().rev().copied().collect();
|
||||
let behind: Vec<f64> = widest_ahead(&back_cover, &back_seg, closed, window).into_iter().rev().collect();
|
||||
let reach = |i: usize| {
|
||||
let mut r = cover[i].max(ahead[i]).max(behind[i]);
|
||||
let s = along[i];
|
||||
if heads.0 > 0.0 && s <= heads.0 {
|
||||
r = r.max(heads.0 + lay.margin);
|
||||
}
|
||||
@@ -227,29 +271,69 @@ fn strip(lay: &Lay, pts: &[Point], closed: bool, total: f64, side: Option<bool>,
|
||||
r
|
||||
};
|
||||
let mut out = Vec::with_capacity(n + 4);
|
||||
let mut s = 0.0;
|
||||
if !closed {
|
||||
let (d, b) = (dir[0], lay.band(0.0, side));
|
||||
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0.0), 0.0, b));
|
||||
out.push(Rib::square(pts[0], d, reach(0.0), 0.0, b));
|
||||
s = pts[0].distance(pts[1]);
|
||||
out.push(Rib::square(pts[0] - d * lay.ext.0, d, reach(0), 0.0, b));
|
||||
out.push(Rib::square(pts[0], d, reach(0), 0.0, b));
|
||||
}
|
||||
let (first, last) = if closed { (0, n) } else { (1, n - 1) };
|
||||
for i in first..last {
|
||||
let v = i % n;
|
||||
let t = s / total;
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(s), t, lay.band(t, side));
|
||||
let t = along[v] / total;
|
||||
let ribs = lay.turn(pts[v], dir[(v + seg_count - 1) % seg_count], dir[v % seg_count], reach(v), t, lay.band(t, side));
|
||||
// A closed subpath starts after the fan of its first corner and ends with it.
|
||||
let skip = if closed && i == 0 { ribs.len() - 1 } else { 0 };
|
||||
out.extend(ribs.into_iter().skip(skip));
|
||||
s += pts[v].distance(pts[(v + 1) % n]);
|
||||
}
|
||||
if closed {
|
||||
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(total), 1.0, lay.band(1.0, side)));
|
||||
out.extend(lay.turn(pts[0], dir[seg_count - 1], dir[0], reach(0), 1.0, lay.band(1.0, side)));
|
||||
} else {
|
||||
let (d, b) = (dir[seg_count - 1], lay.band(1.0, side));
|
||||
out.push(Rib::square(pts[n - 1], d, reach(total), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(total), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1], d, reach(n - 1), 1.0, b));
|
||||
out.push(Rib::square(pts[n - 1] + d * lay.ext.1, d, reach(n - 1), 1.0, b));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// For each of the points of a subpath, how far the ribs of the points ahead of it reach
|
||||
/// (`cover`), out to the first point more than `window` along (or the end of an open subpath,
|
||||
/// or once round a closed one): `seg[i]` is how far point `i` is from the next (past the last,
|
||||
/// the first when `closed`). Both ends of the window only move forward, so a queue of the
|
||||
/// points that reach farther than every point after them gives each maximum, and a subpath
|
||||
/// costs time in proportion to its points however many a window spans.
|
||||
pub(super) fn widest_ahead(cover: &[f64], seg: &[f64], closed: bool, window: f64) -> Vec<f64> {
|
||||
let n = cover.len().min(seg.len());
|
||||
if n == 0 {
|
||||
return vec![];
|
||||
}
|
||||
// A closed subpath's points ahead are numbered on round it once more.
|
||||
let last = if closed { 2 * n - 1 } else { n - 1 };
|
||||
let mut pos = Vec::with_capacity(last + 1);
|
||||
let mut s = 0.0;
|
||||
for k in 0..=last {
|
||||
pos.push(s);
|
||||
s += seg[k % n];
|
||||
}
|
||||
let mut out = Vec::with_capacity(n);
|
||||
let mut queue: VecDeque<usize> = VecDeque::new();
|
||||
let mut next = 1;
|
||||
for i in 0..n {
|
||||
next = next.max(i + 1);
|
||||
let stop = if closed { i + n } else { last };
|
||||
let mut past = next > i + 1 && pos[next - 1] - pos[i] > window;
|
||||
while !past && next <= stop {
|
||||
let r = cover[next % n];
|
||||
while queue.back().is_some_and(|&k| cover[k % n] <= r) {
|
||||
queue.pop_back();
|
||||
}
|
||||
queue.push_back(next);
|
||||
past = pos[next] - pos[i] > window;
|
||||
next += 1;
|
||||
}
|
||||
while queue.front().is_some_and(|&k| k <= i) {
|
||||
queue.pop_front();
|
||||
}
|
||||
out.push(queue.front().map_or(0.0, |&k| cover[k % n]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
@@ -237,3 +237,113 @@ fn slices_cover_an_arrowhead_on_a_long_straight_leg() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the last slice holding `p` (the one filled last there) lay its gradient piece on a part of
|
||||
/// the path running across the page (left or right), for a gradient in `mode`? Along, the piece
|
||||
/// runs the way the path does; across, square to it.
|
||||
fn painted_by_level_part(slices: &[Slice], p: Point, mode: StrokeGradientMode) -> Option<bool> {
|
||||
slices.iter().rev().find(|s| s.shape.contains(p)).map(|s| {
|
||||
let d = s.to - s.from;
|
||||
(d.x.abs() > d.y.abs()) == (mode == StrokeGradientMode::Along)
|
||||
})
|
||||
}
|
||||
|
||||
/// #5960: where a variable-width stroke crosses itself, the later pass paints over the earlier one
|
||||
/// only where the two overlap. Its slices reach as far as the stroke is wide there, not as far as
|
||||
/// it is wide anywhere, so a narrow later pass leaves the wide earlier one its own colour beside it.
|
||||
#[test]
|
||||
fn a_narrow_pass_over_a_wide_one_paints_only_where_they_overlap() {
|
||||
// Right along y = 50, round, then down through the first leg at x = 50, near the narrow end.
|
||||
let crossing = poly(&[(0.0, 50.0), (100.0, 50.0), (100.0, 100.0), (50.0, 100.0), (50.0, 0.0)], false);
|
||||
for mode in [StrokeGradientMode::Along, StrokeGradientMode::Across] {
|
||||
let mut st = stroke(10.0, mode);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
let sl = slices(&crossing, &st);
|
||||
// In the first leg (about 4 pt either side of it there) but 4 pt beside the second leg,
|
||||
// which is less than 1 pt either side of itself there.
|
||||
let level = painted_by_level_part(&sl, Point::new(54.0, 52.0), mode);
|
||||
assert_eq!(level, Some(true), "{mode:?}: painted by the second leg");
|
||||
// Where the two overlap, the second leg is on top.
|
||||
let level = painted_by_level_part(&sl, Point::new(50.5, 52.0), mode);
|
||||
assert_eq!(level, Some(false), "{mode:?}: painted by the first leg");
|
||||
assert_covered(&crossing, &st);
|
||||
}
|
||||
}
|
||||
|
||||
/// #5960: a narrow pass running close beside a wide one doesn't paint the wide one's edge.
|
||||
#[test]
|
||||
fn a_narrow_pass_beside_a_wide_one_leaves_its_edge_alone() {
|
||||
// Right along y = 0, then back along y = 7: the way back is the narrow end of a taper.
|
||||
let hairpin = poly(&[(0.0, 0.0), (100.0, 0.0), (100.0, 7.0), (0.0, 7.0)], false);
|
||||
let total = 207.0;
|
||||
let mut st = stroke(10.0, StrokeGradientMode::Along);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
let sl = slices(&hairpin, &st);
|
||||
// Half way along the first leg, 3 pt off it (it is about 3.8 pt wide there either side); the
|
||||
// way back is about 1.2 pt wide either side there, so it reaches no nearer than 5.8.
|
||||
let p = Point::new(50.0, 3.0);
|
||||
let t = param(&sl, p).expect("in a slice");
|
||||
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
|
||||
assert_covered(&hairpin, &st);
|
||||
}
|
||||
|
||||
/// #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.
|
||||
#[test]
|
||||
fn a_densely_flattened_dashed_pass_beside_a_wide_one_leaves_its_edge_alone() {
|
||||
// The hairpin above, the way back in 0.01 pt steps: 500 points within a round cap's 5 pt.
|
||||
let mut pts = vec![(0.0, 0.0), (100.0, 0.0)];
|
||||
pts.extend((0..=10_000).map(|i| (100.0 - i as f64 * 0.01, 7.0)));
|
||||
let hairpin = poly(&pts, false);
|
||||
let total = 207.0;
|
||||
let mut st = stroke(10.0, StrokeGradientMode::Along);
|
||||
st.profile = Some(WidthProfile::taper_end());
|
||||
st.cap = LineCap::Round;
|
||||
st.dash = Some(Dash { pattern: vec![12.0, 6.0], ..Default::default() });
|
||||
let sl = slices(&hairpin, &st);
|
||||
let p = Point::new(50.0, 3.0);
|
||||
let t = param(&sl, p).expect("in a slice");
|
||||
assert!((t - 50.0 / total).abs() < 1e-6, "painted with {t}, not the first leg's {}", 50.0 / total);
|
||||
}
|
||||
|
||||
/// The widest the ribs ahead reach is the widest over the points out to the first one more than
|
||||
/// the window along, as a walk along the subpath finds it, open and closed.
|
||||
#[test]
|
||||
fn widest_ahead_is_the_widest_within_the_window() {
|
||||
let mut seed = 0x9e37_79b9_u64;
|
||||
let mut rnd = move || {
|
||||
seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
(seed >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
for _ in 0..200 {
|
||||
let n = 1 + (rnd() * 40.0) as usize;
|
||||
let cover: Vec<f64> = (0..n).map(|_| rnd() * 10.0).collect();
|
||||
// Some points on top of each other.
|
||||
let seg: Vec<f64> = (0..n).map(|_| if rnd() < 0.2 { 0.0 } else { rnd() * 3.0 }).collect();
|
||||
let window = [0.0, rnd() * 5.0, rnd() * 200.0][(rnd() * 3.0) as usize];
|
||||
for closed in [false, true] {
|
||||
let got = super::gradient::widest_ahead(&cover, &seg, closed, window);
|
||||
assert_eq!(got.len(), n);
|
||||
for (i, &got) in got.iter().enumerate() {
|
||||
let (mut j, mut d, mut want) = (i, 0.0, 0.0_f64);
|
||||
for _ in 0..n {
|
||||
let k = if closed {
|
||||
(j + 1) % n
|
||||
} else if j + 1 < n {
|
||||
j + 1
|
||||
} else {
|
||||
break;
|
||||
};
|
||||
want = want.max(cover[k]);
|
||||
d += seg[j];
|
||||
j = k;
|
||||
if d > window {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(got, want, "n {n}, closed {closed}, window {window}, point {i}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,686 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::Effect;
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::*;
|
||||
use crate::pixel::Channels;
|
||||
use crate::{PixelFx, RasterFx, raster_effects};
|
||||
|
||||
/// A `w` × `h` raster whose pixels are `px` points wide (origin top-left), at a raster effects
|
||||
/// resolution whose pixels are `line` points wide.
|
||||
fn space(w: usize, h: usize, px: f64, line: f64) -> PixelSpace {
|
||||
let (wd, hd) = (w as f64 * px, h as f64 * px);
|
||||
PixelSpace { to_doc: Affine::scale(px), px, center: Point::new(wd / 2.0, hd / 2.0), channels: Channels::Rgb, line, extent: 0.5 * wd.hypot(hd) }
|
||||
}
|
||||
|
||||
/// `w` × `h` premultiplied pixels: `f(x, y)` each.
|
||||
fn image(w: usize, h: usize, f: impl Fn(usize, usize) -> [u8; 4]) -> Vec<u8> {
|
||||
(0..h).flat_map(|y| (0..w).map(move |x| (x, y))).flat_map(|(x, y)| f(x, y)).collect()
|
||||
}
|
||||
|
||||
fn at(d: &[u8], w: usize, x: usize, y: usize) -> [u8; 4] {
|
||||
let i = (y * w + x) * 4;
|
||||
[d[i], d[i + 1], d[i + 2], d[i + 3]]
|
||||
}
|
||||
|
||||
/// The effect `svg.filter` with `params`, as the renderer reads it.
|
||||
fn filter(params: serde_json::Value) -> SvgFilterFx {
|
||||
let e = Effect { id: SVG_FILTER.into(), params, visible: true };
|
||||
match raster_effects(&[e]).first() {
|
||||
Some(RasterFx::Pixel(PixelFx::Svg(f))) => f.clone(),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A blue square of `size` pixels inset by `inset` in a transparent raster `w` wide.
|
||||
fn blue_square(w: usize, inset: usize) -> Vec<u8> {
|
||||
image(w, w, |x, y| if (inset..w - inset).contains(&x) && (inset..w - inset).contains(&y) { [0, 0, 255, 255] } else { [0; 4] })
|
||||
}
|
||||
|
||||
const RED_FLOOD: &str = r##"<filter id="Red"><feFlood flood-color="#ff0000"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
|
||||
#[test]
|
||||
fn check_names_a_filter_by_its_id() {
|
||||
assert_eq!(check(RED_FLOOD), Ok("Red".into()));
|
||||
assert_eq!(check(&format!(" {RED_FLOOD}\n")), Ok("Red".into()), "spaces around the element");
|
||||
let linked = r#"<filter id=" Shadow 1 "><feImage xlink:href="data:image/png;base64,AAAA"/></filter>"#;
|
||||
assert_eq!(check(linked), Ok("Shadow 1".into()), "the xlink prefix is declared for it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_refuses_what_is_not_one_drawable_filter() {
|
||||
for (xml, why) in [
|
||||
("", "give one <filter> element"),
|
||||
("<filter id=\"a\">", "isn't valid XML"),
|
||||
("<g id=\"a\"><feFlood/></g>", "give one <filter> element"),
|
||||
("<filter id=\"a\"><feFlood/></filter><filter id=\"b\"><feFlood/></filter>", "give one <filter> element"),
|
||||
("text<filter id=\"a\"><feFlood/></filter>", "give one <filter> element"),
|
||||
("<filter><feFlood/></filter>", "needs an id"),
|
||||
("<filter id=\" \"><feFlood/></filter>", "needs an id"),
|
||||
("<filter id=\"a\"></filter>", "no primitives"),
|
||||
("<filter id=\"a\"><feTurbulence numOctaves=\"11\"/></filter>", "numOctaves is at most 10"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"3 12\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"65536+65536\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><feConvolveMatrix order=\"3 x\" kernelMatrix=\"1\"/></filter>", "order is at most 9"),
|
||||
("<filter id=\"a\"><notAPrimitive/></filter>", "<notAPrimitive> isn't an element of an SVG filter"),
|
||||
("<filter id=\"a\"><feOffset/><script>alert(1)</script></filter>", "<script> isn't an element"),
|
||||
("<filter id=\"a\"><feMerge><feMergeNode/><a/></feMerge></filter>", "<a> isn't an element"),
|
||||
("<filter id=\"a\" onload=\"x()\"><feOffset/></filter>", "no event attributes such as onload"),
|
||||
("<filter id=\"a\"><feOffset ONCLICK=\"x()\"/></filter>", "no event attributes such as ONCLICK"),
|
||||
("<filter id=\"a\"><feImage xlink:href=\"javascript:x()\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"photo.png\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:image/svg+xml;base64,PHN2Zy8+\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:application/gzip;base64,H4sI\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\"><feImage href=\"data:;base64,AAAA\"/></filter>", "WebP data: URLs"),
|
||||
("<filter id=\"a\" width=\"0\"><feOffset/></filter>", "can't be drawn"),
|
||||
] {
|
||||
let e = check(xml).expect_err(xml);
|
||||
assert!(e.contains(why), "{xml}: {e}");
|
||||
}
|
||||
let many = format!("<filter id=\"a\">{}</filter>", "<feOffset/>".repeat(MAX_PRIMITIVES + 1));
|
||||
assert!(check(&many).is_err_and(|e| e.contains("at most 64 primitives")));
|
||||
let titled = format!("<filter id=\"a\"><title>T</title><desc>D</desc>{}</filter>", "<feOffset/>".repeat(MAX_PRIMITIVES));
|
||||
assert_eq!(check(&titled), Ok("a".into()), "a title and a description aren't primitives");
|
||||
assert!(check("<filter id=\"a\"><title>T</title></filter>").is_err_and(|e| e.contains("no primitives")));
|
||||
let long = format!("<filter id=\"{}\"><feOffset/></filter>", "x".repeat(MAX_NAME + 1));
|
||||
assert!(check(&long).is_err_and(|e| e.contains("at most 255 characters")));
|
||||
let big = format!("<filter id=\"a\"><feOffset/><!--{}--></filter>", " ".repeat(SvgFilter::MAX_XML));
|
||||
assert!(check(&big).is_err_and(|e| e.contains("at most 64 KB")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn with_id_renames_only_the_filter() {
|
||||
let xml = r#"<filter id="Old" x="0"><feOffset result="Old"/></filter>"#;
|
||||
assert_eq!(with_id(xml, "New").as_deref(), Some(r#"<filter id="New" x="0"><feOffset result="Old"/></filter>"#));
|
||||
assert_eq!(with_id("<filter><feOffset/></filter>", "New"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_in_lists_the_documents_filters_it_takes() {
|
||||
let svg = format!(
|
||||
"<?xml version=\"1.0\"?><!DOCTYPE svg PUBLIC \"-//W3C//DTD SVG 1.1//EN\" \"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd\"><svg xmlns=\"http://www.w3.org/2000/svg\"><defs>{RED_FLOOD}<filter id=\"Empty\"/><filter id=\"Off\"><feOffset dx=\"2\"/></filter></defs></svg>"
|
||||
);
|
||||
let (found, skipped) = filters_in(&svg);
|
||||
assert_eq!(found.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["Red", "Off"]);
|
||||
assert_eq!(found[0].1, RED_FLOOD, "each filter's XML as it is written");
|
||||
assert_eq!(skipped, 1, "the filter without primitives");
|
||||
}
|
||||
|
||||
/// [`filters_of`] on `svg` read as SVG import reads it (with DTDs).
|
||||
fn filters_in(svg: &str) -> (Vec<(String, String)>, usize) {
|
||||
let doc = roxmltree::Document::parse_with_options(svg, roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }).unwrap();
|
||||
filters_of(&doc, svg, usize::MAX, |_| false)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_of_stops_at_the_room_left_and_skips_taken_and_styled_filters() {
|
||||
let many = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>",
|
||||
(0..200).map(|i| format!("<filter id=\"f{i}\"><feOffset/></filter>")).collect::<String>()
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&many).unwrap();
|
||||
let (found, skipped) = filters_of(&doc, &many, 3, |name| name == "f1");
|
||||
assert_eq!(found.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["f0", "f2", "f3"]);
|
||||
assert_eq!(skipped, 197);
|
||||
// A filter whose look comes from the document's styles isn't taken.
|
||||
for styled in [
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.r{flood-color:red}</style><filter id=\"a\"><feFlood class=\"r\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"a\"><feFlood flood-color=\"currentColor\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"a\"><feFlood flood-color=\"CURRENTcolor\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><defs color-interpolation-filters=\"sRGB\"><filter id=\"a\"><feGaussianBlur stdDeviation=\"2\"/></filter></defs></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@media screen { feFlood { flood-color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>#a feOffset{dx:2}</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.s { content: \"{ /*\" } feFlood { flood-color: red }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.outer { fill: blue; & #a { color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>@scope (#a) { :scope { color: red } }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>svg { color-interpolation-filters: sRGB }</style><filter id=\"a\"><feGaussianBlur stdDeviation=\"2\"/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>feFlood[data-t=\\;] { flood-color: red }</style><filter id=\"a\"><feFlood/></filter></svg>",
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(styled).unwrap();
|
||||
assert_eq!(filters_of(&doc, styled, usize::MAX, |_| false), (vec![], 1), "{styled}");
|
||||
}
|
||||
// A style sheet for the shapes alone (as CSS export writes), applying the filter to them
|
||||
// included, doesn't reach the filter.
|
||||
for shapes in [
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>.cls-1{fill:#f00}</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><style>/* #a */ .shape { filter: url(#a) }</style><filter id=\"a\"><feOffset/></filter></svg>",
|
||||
// An identifier named currentColor takes no color.
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\"><filter id=\"currentColor\"><feOffset result=\"currentColor\"/></filter></svg>",
|
||||
] {
|
||||
let doc = roxmltree::Document::parse(shapes).unwrap();
|
||||
assert_eq!(filters_of(&doc, shapes, usize::MAX, |_| false).0.len(), 1, "{shapes}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deeply_nested_filters_are_refused_before_they_are_parsed() {
|
||||
let deep = format!("<filter id=\"a\"><feMerge>{}<feMergeNode/>{}</feMerge></filter>", "<g>".repeat(20), "</g>".repeat(20));
|
||||
assert!(check(&deep).is_err_and(|e| e.contains("nest at most 16 deep")));
|
||||
assert_eq!(with_id(&deep, "b"), None);
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": deep }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "drawn without it");
|
||||
// Markup in comments, CDATA and attribute values doesn't count.
|
||||
let quoted =
|
||||
format!("<filter id=\"a\" x=\"{}\"><!--{}--><feOffset/><![CDATA[{}]]></filter>", "<g>".repeat(20), "<g>".repeat(20), "<g>".repeat(20));
|
||||
assert_eq!(check("ed), Ok("a".into()));
|
||||
let gt = format!("<filter id=\"a\" title='{}'>{}<feOffset/></filter>", "x>".repeat(20), "<!-- > -->".repeat(3));
|
||||
assert_eq!(check(>), Ok("a".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_replaces_the_pixels_with_its_result() {
|
||||
let w = 20;
|
||||
let mut data = blue_square(w, 5);
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&data, w, 10, 10), [255, 0, 0, 255], "inside: the flood, cut to the source");
|
||||
assert_eq!(at(&data, w, 1, 1), [0; 4], "outside the source");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lengths_are_in_points_times_scale() {
|
||||
let w = 40;
|
||||
let shift = r#"<filter id="Shift" x="-1" y="-1" width="3" height="3"><feOffset dx="4"/></filter>"#;
|
||||
let run = |px: f64, scale: f64| {
|
||||
let n = (w as f64 / px) as usize;
|
||||
let mut data = image(n, n, |x, y| {
|
||||
let (x, y) = (x as f64 * px, y as f64 * px);
|
||||
if (10.0..20.0).contains(&x) && (10.0..20.0).contains(&y) { [0, 0, 255, 255] } else { [0; 4] }
|
||||
});
|
||||
filter(json!({ "xml": shift, "scale": scale })).apply(&mut data, n, n, &space(n, n, px, px));
|
||||
let column = |x: f64| at(&data, n, (x / px) as usize, (15.0 / px) as usize)[3];
|
||||
(column(9.5), column(21.0), column(23.5), column(25.0))
|
||||
};
|
||||
// The square spans 10..20 pt: moved by 4 pt, 14..24.
|
||||
assert_eq!(run(1.0, 1.0), (0, 255, 255, 0));
|
||||
assert_eq!(run(0.5, 1.0), (0, 255, 255, 0), "the same in points at another zoom");
|
||||
// One unit of the filter is half a point: moved by 2 pt, 12..22.
|
||||
assert_eq!(run(1.0, 0.5), (0, 255, 0, 0));
|
||||
}
|
||||
|
||||
/// A user-space region is placed from the effect's origin.
|
||||
#[test]
|
||||
fn user_space_starts_at_the_origin() {
|
||||
let w = 40;
|
||||
// Flood the square, cut to the region x 0..20 of the filter's user space.
|
||||
let half = r##"<filter id="Half" filterUnits="userSpaceOnUse" x="0" y="0" width="20" height="40"><feFlood flood-color="#ff0000"/><feComposite in2="SourceGraphic" operator="in"/></filter>"##;
|
||||
let run = |origin: [f64; 2]| {
|
||||
let mut data = image(w, w, |_, _| [0, 0, 255, 255]);
|
||||
filter(json!({ "xml": half, "origin": origin })).apply(&mut data, w, w, &space(w, w, 1.0, 1.0));
|
||||
(at(&data, w, 5, 20)[3], at(&data, w, 25, 20)[3])
|
||||
};
|
||||
assert_eq!(run([0.0, 0.0]), (255, 0));
|
||||
assert_eq!(run([20.0, 0.0]), (0, 255), "the region 20 pt to the right");
|
||||
let b = Rect::new(0.0, 0.0, 40.0, 40.0);
|
||||
assert!((filter(json!({ "xml": half, "origin": [-10.0, 0.0] })).outset(b) - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_no_finer_than_the_raster_effects_resolution() {
|
||||
let w = 16;
|
||||
// Columns one view pixel wide, alternately opaque and clear.
|
||||
let stripes = image(w, w, |x, _| if x % 2 == 0 { [0, 0, 255, 255] } else { [0; 4] });
|
||||
let keep = r#"<filter id="Keep" x="0" y="0" width="1" height="1"><feOffset/></filter>"#;
|
||||
let mut fine = stripes.clone();
|
||||
filter(json!({ "xml": keep })).apply(&mut fine, w, w, &space(w, w, 0.25, 0.25));
|
||||
assert_eq!(at(&fine, w, 4, 4)[3], 255);
|
||||
assert_eq!(at(&fine, w, 5, 4)[3], 0, "at the view's resolution the stripes stay");
|
||||
let mut coarse = stripes.clone();
|
||||
filter(json!({ "xml": keep })).apply(&mut coarse, w, w, &space(w, w, 0.25, 1.0));
|
||||
let (a, b) = (at(&coarse, w, 4, 4)[3], at(&coarse, w, 5, 4)[3]);
|
||||
assert!(a < 255 && b > 0, "four view pixels to a raster pixel: the stripes blend ({a}, {b})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draws_nothing_new_without_content_or_a_readable_filter() {
|
||||
let w = 8;
|
||||
let mut empty = vec![0; w * w * 4];
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut empty, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert!(empty.iter().all(|v| *v == 0), "a flood cut to a source that draws nothing");
|
||||
// A filter that doesn't read the source draws on art that draws no pixels: the raster is its box.
|
||||
let mut clear = vec![0; w * w * 4];
|
||||
filter(json!({ "xml": "<filter id=\"G\"><feFlood flood-color=\"#00ff00\"/></filter>" })).apply(&mut clear, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&clear, w, 4, 4), [0, 255, 0, 255]);
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": "<g/>" }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "not a filter: the object is drawn without it");
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": "<filter id=\"x\" width=\"0\"><feOffset/></filter>" }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "a filter resvg can't draw: the object is drawn without it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outset_is_how_far_the_region_reaches() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
assert!((filter(json!({ "xml": RED_FLOOD })).outset(b) - 10.0).abs() < 1e-9, "the default region: 10% of the width");
|
||||
let wide = r#"<filter id="w" x="-50%" y="0" width="200%" height="100%"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": wide })).outset(b) - 50.0).abs() < 1e-9);
|
||||
let user = r#"<filter id="u" filterUnits="userSpaceOnUse" x="-10" y="0" width="60" height="25"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": user, "scale": 2.0 })).outset(b) - 20.0).abs() < 1e-9, "user units times scale");
|
||||
let inside = r#"<filter id="i" x="0" y="0" width="1" height="1"><feOffset/></filter>"#;
|
||||
assert_eq!(filter(json!({ "xml": inside })).outset(b), 0.0);
|
||||
// User-space lengths in points: -30 pt is -40 user units.
|
||||
let points = r#"<filter id="p" filterUnits="userSpaceOnUse" x="-30pt" y="0" width="100" height="50"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": points })).outset(b) - 40.0).abs() < 1e-9);
|
||||
// In user space, a percentage is a share of the object's box; em, ex and a missing length
|
||||
// take the default region's share of it.
|
||||
let viewport = r#"<filter id="v" filterUnits="userSpaceOnUse" x="-50%" y="0" width="10" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": viewport })).outset(b) - 50.0).abs() < 1e-9);
|
||||
let far = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1000%" y="0" width="3000%" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": far })).outset(b) - 1900.0).abs() < 1e-9, "however far it reaches");
|
||||
let font = r#"<filter id="f" filterUnits="userSpaceOnUse" x="-100em" y="0" width="10" height="10"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": font })).outset(b) - 10.0).abs() < 1e-9, "relative to the font");
|
||||
let default = r#"<filter id="d" filterUnits="userSpaceOnUse"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": default })).outset(b) - 10.0).abs() < 1e-9);
|
||||
// In the bounding box, a length with a unit is invalid: the default.
|
||||
let unit = r#"<filter id="u" x="-5mm"><feOffset/></filter>"#;
|
||||
assert!((filter(json!({ "xml": unit })).outset(b) - 10.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_filter_keeps_the_prefixes_an_ancestor_declares() {
|
||||
let svg = r#"<svg:svg xmlns:svg="http://www.w3.org/2000/svg" xmlns:x="urn:other"><svg:defs><svg:filter id="Pre"><svg:feOffset dx="1"/></svg:filter></svg:defs></svg:svg>"#;
|
||||
let (found, skipped) = filters_in(svg);
|
||||
assert_eq!((found.len(), skipped), (1, 0));
|
||||
assert_eq!(
|
||||
found[0].1, r#"<svg:filter xmlns:svg="http://www.w3.org/2000/svg" id="Pre"><svg:feOffset dx="1"/></svg:filter>"#,
|
||||
"only the prefix it uses"
|
||||
);
|
||||
// A declaration on the filter itself, with spaces around its `=`, is kept as it is.
|
||||
let own = r#"<svg xmlns="http://www.w3.org/2000/svg"><defs><s:filter xmlns:s = "http://www.w3.org/2000/svg" id="Own"><s:feOffset/></s:filter></defs></svg>"#;
|
||||
let (found, skipped) = filters_in(own);
|
||||
assert_eq!((found.len(), skipped), (1, 0));
|
||||
assert_eq!(found[0].1, r#"<s:filter xmlns:s = "http://www.w3.org/2000/svg" id="Own"><s:feOffset/></s:filter>"#);
|
||||
// The same declaration again on the filter, and text like one inside an attribute's value.
|
||||
let again = r#"<s:svg xmlns:s="http://www.w3.org/2000/svg"><s:filter xmlns:s="http://www.w3.org/2000/svg" id="A"><s:feOffset/></s:filter><s:filter id="B" color-profile=" xmlns:s = x"><s:feOffset/></s:filter></s:svg>"#;
|
||||
let (found, skipped) = filters_in(again);
|
||||
assert_eq!((found.len(), skipped), (2, 0), "{found:?}");
|
||||
assert!(found[1].1.starts_with(r#"<s:filter xmlns:s="http://www.w3.org/2000/svg" id="B""#), "{found:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primitive_forms_tell_filters_with_the_same_primitives_apart() {
|
||||
let a = primitive_forms(r#"<filter id="a"><feOffset dx="1"/></filter>"#).unwrap();
|
||||
let b = primitive_forms(r#"<filter id="b"><feOffset dx="2"/></filter>"#).unwrap();
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(primitive_forms(r#"<filter id="c"><feOffset dx="1"/></filter>"#), Some(a), "the id doesn't count");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn costly_parameters_are_capped() {
|
||||
for (xml, why) in [
|
||||
("<filter id=\"a\"><feMorphology radius=\"101\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feMorphology radius=\"2 1e9\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feMorphology radius=\"50+500\"/></filter>", "radius is at most 100"),
|
||||
("<filter id=\"a\"><feGaussianBlur stdDeviation=\"501\"/></filter>", "stdDeviation is at most 500"),
|
||||
("<filter id=\"a\"><feDropShadow stdDeviation=\"1 9e9\"/></filter>", "stdDeviation is at most 500"),
|
||||
(
|
||||
"<filter id=\"a\"><feDiffuseLighting kernelUnitLength=\"101\"><feDistantLight/></feDiffuseLighting></filter>",
|
||||
"kernelUnitLength is at most 100",
|
||||
),
|
||||
] {
|
||||
let e = check(xml).expect_err(xml);
|
||||
assert!(e.contains(why), "{xml}: {e}");
|
||||
}
|
||||
assert_eq!(check("<filter id=\"a\"><feMorphology radius=\"100\"/></filter>"), Ok("a".into()));
|
||||
// usvg also reads attributes in the SVG, XLink and XML namespaces by their local names.
|
||||
for prefixed in [
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\"><feMorphology s:radius=\"1000\"/></filter>",
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\"><feGaussianBlur s:stdDeviation=\"1e6\"/></filter>",
|
||||
] {
|
||||
assert!(check(prefixed).is_err(), "{prefixed}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter is drawn and written rebuilt from its filter elements and their filter attributes:
|
||||
/// no comments, text, descriptions, other attributes or prefixes.
|
||||
#[test]
|
||||
fn the_filter_is_drawn_and_written_from_its_filter_parts_alone() {
|
||||
let xml = concat!(
|
||||
"<filter id=\"a\" xmlns:s=\"http://www.w3.org/2000/svg\" data-x=\"1\"><!-- note --><desc><script></desc><title>T</title>",
|
||||
"<feFlood flood-color=\"blue\" style=\"flood-color: red; fill: url(#x); flood-opacity: url(#y)\" foo=\""\"/>",
|
||||
"<feMorphology s:radius=\"2\"/><feImage xlink:href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
);
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert_eq!(
|
||||
f.xml(),
|
||||
"<filter id=\"a\"><feFlood flood-color=\"red\"/><feMorphology radius=\"2\"/><feImage href=\"data:image/png;base64,AAAA\"/></filter>"
|
||||
);
|
||||
// An image keeps how it is scaled, as a style declaration.
|
||||
for image in [
|
||||
"<filter id=\"i\"><feImage image-rendering=\"pixelated\" href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
"<filter id=\"i\"><feImage style=\"image-rendering: pixelated; fill: red\" href=\"data:image/png;base64,AAAA\"/></filter>",
|
||||
] {
|
||||
assert_eq!(
|
||||
filter(json!({ "xml": image })).xml(),
|
||||
"<filter id=\"i\"><feImage href=\"data:image/png;base64,AAAA\" style=\"image-rendering:pixelated\"/></filter>",
|
||||
"{image}"
|
||||
);
|
||||
}
|
||||
// `currentColor` keeps the color it names.
|
||||
let colored = filter(json!({ "xml": "<filter id=\"c\" color=\"red\"><feFlood flood-color=\"currentColor\" style=\"color: blue\"/></filter>" }));
|
||||
assert_eq!(colored.xml(), "<filter id=\"c\" color=\"red\"><feFlood flood-color=\"currentColor\" color=\"blue\"/></filter>");
|
||||
}
|
||||
|
||||
/// An image of a filter is a PNG, JPEG, GIF or WebP, by its bytes, within the pixel cap by its header.
|
||||
#[test]
|
||||
fn filter_images_are_small_raster_images() {
|
||||
fn png(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::ImageEncoder::write_image(
|
||||
image::codecs::png::PngEncoder::new(&mut out),
|
||||
&vec![0; (w * h * 4) as usize],
|
||||
w,
|
||||
h,
|
||||
image::ExtendedColorType::Rgba8,
|
||||
)
|
||||
.unwrap();
|
||||
out
|
||||
}
|
||||
let raster_image = |data| raster_image_sized(data).map(|(kind, _)| kind);
|
||||
assert!(matches!(raster_image(Arc::new(png(2, 2))), Some(usvg::ImageKind::PNG(_))));
|
||||
assert_eq!(raster_image_sized(Arc::new(png(3, 2))).map(|(_, n)| n), Some(6), "its pixel count");
|
||||
// A header that claims more pixels than the cap: the IHDR's width, with its CRC fixed.
|
||||
let mut big = png(2, 2);
|
||||
big[16..20].copy_from_slice(&5000u32.to_be_bytes());
|
||||
big[20..24].copy_from_slice(&5000u32.to_be_bytes());
|
||||
let crc = crc32(&big[12..29]);
|
||||
big[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
assert!(raster_image(Arc::new(big)).is_none(), "past the pixel cap");
|
||||
assert!(raster_image(Arc::new(b"<svg xmlns=\"http://www.w3.org/2000/svg\"/>".to_vec())).is_none(), "SVG");
|
||||
assert!(raster_image(Arc::new(vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0])).is_none(), "compressed SVG");
|
||||
}
|
||||
|
||||
/// A 2 × 2 PNG whose header claims `w` × `h` pixels (its IHDR rewritten, with the CRC fixed).
|
||||
fn png_claiming(w: u32, h: u32) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &[0; 16], 2, 2, image::ExtendedColorType::Rgba8).unwrap();
|
||||
out[16..20].copy_from_slice(&w.to_be_bytes());
|
||||
out[20..24].copy_from_slice(&h.to_be_bytes());
|
||||
let crc = crc32(&out[12..29]);
|
||||
out[29..33].copy_from_slice(&crc.to_be_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A filter's images share the pixel cap: images that are each within it but pass it together
|
||||
/// are refused.
|
||||
#[test]
|
||||
fn a_filters_images_share_the_pixel_cap() {
|
||||
let img = |w: u32, h: u32| format!("<feImage href=\"data:image/png;base64,{}\"/>", base64(&png_claiming(w, h)));
|
||||
let half = img(4096, 2048);
|
||||
let err = check(&format!("<filter id=\"a\">{half}{}</filter>", img(4096, 2049))).unwrap_err();
|
||||
assert!(err.contains("pixels in all"), "{err}");
|
||||
let err = check(&format!("<filter id=\"a\">{half}{half}{}</filter>", img(1, 1))).unwrap_err();
|
||||
assert!(err.contains("pixels in all"), "{err}");
|
||||
assert!(check(&format!("<filter id=\"a\">{half}{half}</filter>")).is_ok(), "two halves fill the cap exactly");
|
||||
}
|
||||
|
||||
/// The image limit is for a filter's own images: the object it runs on is its source graphic
|
||||
/// whatever its size.
|
||||
#[test]
|
||||
fn a_large_object_is_still_the_source_graphic() {
|
||||
let w = 4097;
|
||||
let mut d = blue_square(w, 0);
|
||||
filter(json!({ "xml": RED_FLOOD })).apply(&mut d, w, w, &space(w, w, 1.0, 1.0));
|
||||
assert_eq!(at(&d, w, w / 2, w / 2), [255, 0, 0, 255]);
|
||||
}
|
||||
|
||||
fn crc32(bytes: &[u8]) -> u32 {
|
||||
let mut c = !0u32;
|
||||
for b in bytes {
|
||||
c ^= u32::from(*b);
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
}
|
||||
!c
|
||||
}
|
||||
|
||||
/// A user-space region in percentages is written in user units, measured against the object's box.
|
||||
#[test]
|
||||
fn a_region_measured_against_the_box_is_written_out() {
|
||||
let far = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-50%" y="0" width="200%" height="10"><feOffset/></filter>"#;
|
||||
let f = filter(json!({ "xml": far, "origin": [10.0, 0.0] }));
|
||||
assert_eq!(
|
||||
f.written_xml(Rect::new(10.0, 0.0, 30.0, 10.0)).as_deref(),
|
||||
Some(r#"<filter id="r" filterUnits="userSpaceOnUse" x="-10" y="0" width="40" height="10"><feOffset/></filter>"#)
|
||||
);
|
||||
let plain = filter(json!({ "xml": RED_FLOOD }));
|
||||
assert_eq!(plain.written_xml(Rect::new(0.0, 0.0, 1.0, 1.0)).as_deref(), Some(plain.xml()));
|
||||
}
|
||||
|
||||
/// A filter at the size limit whose region is measured against the box still draws: written in
|
||||
/// user units, the region can make the drawn form longer than the limit.
|
||||
#[test]
|
||||
fn a_filter_at_the_size_limit_draws_with_its_measured_region() {
|
||||
let open = r#"<filter id="r" filterUnits="userSpaceOnUse" x="-1%" y="-1%" width="1%" height="1%"><feOffset result=""#;
|
||||
let close = r#""/><feOffset/></filter>"#;
|
||||
let xml = format!("{open}{}{close}", "a".repeat(SvgFilter::MAX_XML - open.len() - close.len()));
|
||||
assert_eq!(xml.len(), SvgFilter::MAX_XML);
|
||||
assert_eq!(check(&xml), Ok("r".into()));
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
let written = f.written_xml(Rect::new(0.123456789, 0.123456789, 1000.987654321, 1000.987654321)).unwrap();
|
||||
assert!(written.len() > SvgFilter::MAX_XML, "the region written out is longer: {} bytes", written.len());
|
||||
assert!(with_id(&written, DRAWN_ID).is_some(), "the drawn form is read");
|
||||
}
|
||||
|
||||
/// Only a type selector naming a filter or one of its elements, or `*`, lets a style sheet reach
|
||||
/// filters: class, id, attribute and pseudo-class names that contain "filter" or "fe" don't.
|
||||
#[test]
|
||||
fn style_sheets_reach_filters_only_through_their_type_selectors() {
|
||||
let reaches = |sheet: &str| {
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style></svg>");
|
||||
Styles::of(&roxmltree::Document::parse(&svg).unwrap()).reach_filters
|
||||
};
|
||||
for sheet in
|
||||
[".filter-button { color: red }", "#feature, .fe-icon { fill: blue }", "[data-filter] { fill: red }", "a:hover, rect.filter { stroke: red }"]
|
||||
{
|
||||
assert!(!reaches(sheet), "{sheet}");
|
||||
}
|
||||
for sheet in
|
||||
["filter { x: 0 }", "FeFlood { flood-color: red }", "svg|feBlend { mode: multiply }", "* { color: red }", "g :is(feGaussianBlur) { x: 0 }"]
|
||||
{
|
||||
assert!(reaches(sheet), "{sheet}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An attribute selector styles a filter whose elements carry that attribute, whatever its value
|
||||
/// or namespace prefix; a filter without it is not affected.
|
||||
#[test]
|
||||
fn an_attribute_selector_styles_a_filter_with_that_attribute() {
|
||||
let depends = |sheet: &str, filter: &str| {
|
||||
let svg = format!("<svg xmlns=\"http://www.w3.org/2000/svg\"><style>{sheet}</style><defs>{filter}</defs></svg>");
|
||||
let doc = roxmltree::Document::parse(&svg).unwrap();
|
||||
let f = doc.descendants().find(|n| n.tag_name().name() == "filter").unwrap();
|
||||
depends_on_styles(f, &Styles::of(&doc))
|
||||
};
|
||||
let tagged = r#"<filter id="a"><feFlood data-tone="warm"/></filter>"#;
|
||||
let plain = r#"<filter id="a"><feFlood/></filter>"#;
|
||||
for sheet in ["[data-tone] { flood-color: red }", "[data-tone=\"warm\"] { flood-color: red }", "[svg|data-tone ~= warm] { x: 0 }"] {
|
||||
assert!(depends(sheet, tagged), "{sheet}");
|
||||
assert!(!depends(sheet, plain), "{sheet}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_of_reads_at_most_256_filters() {
|
||||
let many = format!(
|
||||
"<svg xmlns=\"http://www.w3.org/2000/svg\">{}</svg>",
|
||||
(0..300).map(|i| format!("<filter id=\"f{i}\"><feOffset/></filter>")).collect::<String>()
|
||||
);
|
||||
let doc = roxmltree::Document::parse(&many).unwrap();
|
||||
let (found, skipped) = filters_of(&doc, &many, usize::MAX, |_| false);
|
||||
assert_eq!((found.len(), skipped), (MAX_FILTERS_READ, 300 - MAX_FILTERS_READ));
|
||||
}
|
||||
|
||||
/// However large `scale` makes them, a morphology radius spans at most 32 pixels of the raster the
|
||||
/// filter runs on, and a standard deviation at most 512.
|
||||
#[test]
|
||||
fn costly_lengths_are_clamped_in_pixels() {
|
||||
let sp = space(10, 10, 0.5, 0.5);
|
||||
let morph = filter(json!({ "xml": "<filter id=\"m\"><feMorphology radius=\"100\"/></filter>", "scale": 1000.0 }));
|
||||
let s = morph.resolution(&sp, 10.0);
|
||||
assert!(100.0 * 1000.0 / 0.5 * s <= 32.0 + 1e-6, "{s}");
|
||||
let blur = filter(json!({ "xml": "<filter id=\"b\"><feGaussianBlur stdDeviation=\"500\"/></filter>", "scale": 1000.0 }));
|
||||
let s = blur.resolution(&sp, 10.0);
|
||||
assert!(500.0 * 1000.0 / 0.5 * s <= 512.0 + 1e-6, "{s}");
|
||||
// At a fine raster too (1200 ppi): the cap holds.
|
||||
let fine = space(10, 10, 0.06, 0.06);
|
||||
let s = morph.resolution(&fine, 10.0);
|
||||
assert!(100.0 * 1000.0 / 0.06 * s <= 32.0 + 1e-6, "{s}");
|
||||
let small = filter(json!({ "xml": "<filter id=\"s\"><feMorphology radius=\"1\"/></filter>" }));
|
||||
assert_eq!(small.resolution(&sp, 10.0), 1.0, "a small radius runs at the raster's own pixels");
|
||||
// In the object's bounding box, a radius is a fraction of its box: half a 4096-pixel box.
|
||||
let boxed = filter(json!({ "xml": "<filter id=\"o\" primitiveUnits=\"objectBoundingBox\"><feMorphology radius=\"0.5\"/></filter>" }));
|
||||
let s = boxed.resolution(&sp, 4096.0);
|
||||
assert!(0.5 * 4096.0 * s <= 32.0 + 1e-6, "{s}");
|
||||
}
|
||||
|
||||
/// A filter that `check` refuses is never drawn, whatever path put it in an effect.
|
||||
#[test]
|
||||
fn an_unchecked_filter_is_not_drawn() {
|
||||
for xml in [
|
||||
"<filter id=\"a\"><feOffset/><script>alert(1)</script></filter>",
|
||||
"<filter id=\"a\"><feMorphology radius=\"1e6\"/></filter>",
|
||||
"<filter id=\"a\" onload=\"x()\"><feOffset/></filter>",
|
||||
] {
|
||||
let e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": xml }), visible: true };
|
||||
assert!(raster_effects(&[e]).is_empty(), "{xml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scale Strokes & Effects scales the filter's lengths with the art: the points a unit measures.
|
||||
#[test]
|
||||
fn scaling_the_effect_scales_its_units() {
|
||||
let mut e = Effect { id: SVG_FILTER.into(), params: json!({ "xml": RED_FLOOD }), visible: true };
|
||||
crate::scale_effect(&mut e, 2.0);
|
||||
assert_eq!(e.params["scale"], json!(2.0));
|
||||
crate::scale_effect(&mut e, 0.25);
|
||||
assert_eq!(e.params["scale"], json!(0.5));
|
||||
}
|
||||
|
||||
/// The form SVG output writes, for a filter that fills a 10 × 10 point box.
|
||||
fn written(xml: &str) -> Option<String> {
|
||||
filter(json!({ "xml": xml })).written_xml(Rect::new(0.0, 0.0, 10.0, 10.0))
|
||||
}
|
||||
|
||||
/// SVG 1.1 output writes an image's reference as `xlink:href`; the filter as it is drawn and
|
||||
/// listed keeps `href`.
|
||||
#[test]
|
||||
fn an_image_is_written_with_an_xlink_reference() {
|
||||
let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2)));
|
||||
let f = filter(json!({ "xml": format!(r#"<filter id="i"><feImage href="{data}"/></filter>"#) }));
|
||||
assert_eq!(f.xml(), format!(r#"<filter id="i"><feImage href="{data}"/></filter>"#));
|
||||
let out = f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).unwrap();
|
||||
assert_eq!(out, format!(r#"<filter id="i"><feImage xlink:href="{data}"/></filter>"#));
|
||||
assert!(f.writes_as_svg11());
|
||||
assert_eq!(check(&out), Ok("i".into()), "the written form reads back");
|
||||
}
|
||||
|
||||
/// SVG 1.1 has no `feDropShadow`: it is written as the primitives Filter Effects gives as its
|
||||
/// equivalent, with its defaults, input, result and subregion, and results no other primitive uses.
|
||||
#[test]
|
||||
fn a_drop_shadow_is_written_as_svg_1_1_primitives() {
|
||||
assert_eq!(
|
||||
written(r#"<filter id="s"><feDropShadow/></filter>"#).as_deref(),
|
||||
Some(concat!(
|
||||
r#"<filter id="s"><feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="2" dy="2" result="vcShadow0"/><feFlood/>"#,
|
||||
r#"<feComposite in2="vcShadow0" operator="in" result="vcShadow1"/><feMerge><feMergeNode in="vcShadow1"/><feMergeNode in="SourceGraphic"/></feMerge></filter>"#,
|
||||
))
|
||||
);
|
||||
// Its input, result, subregion, color space and flood; a name the filter uses is not taken.
|
||||
let given = r#"<filter id="s"><feOffset dx="1" result="vcShadow0"/><feDropShadow in="vcShadow0" result="r" x="1" width="5" dx="3" dy="-1" stdDeviation="1 2" flood-color="rgba(255,0,0,0.5)" flood-opacity="50%" color-interpolation-filters="sRGB"/></filter>"#;
|
||||
assert_eq!(
|
||||
written(given).as_deref(),
|
||||
Some(concat!(
|
||||
r#"<filter id="s"><feOffset dx="1" result="vcShadow0"/>"#,
|
||||
r#"<feGaussianBlur in="vcShadow0" stdDeviation="1 2" color-interpolation-filters="sRGB"/><feOffset dx="3" dy="-1" result="vcShadow1" color-interpolation-filters="sRGB"/>"#,
|
||||
r##"<feFlood flood-color="#ff0000" flood-opacity="0.25098" color-interpolation-filters="sRGB"/>"##,
|
||||
r#"<feComposite in2="vcShadow1" operator="in" result="vcShadow2" color-interpolation-filters="sRGB"/>"#,
|
||||
r#"<feMerge x="1" width="5" result="r" color-interpolation-filters="sRGB"><feMergeNode in="vcShadow2"/><feMergeNode in="vcShadow0"/></feMerge></filter>"#,
|
||||
))
|
||||
);
|
||||
// An implicit input, or a result no earlier primitive has, is the previous result, named.
|
||||
for shadow in [r#"<feDropShadow dx="1"/>"#, r#"<feDropShadow in="nowhere" dx="1"/>"#] {
|
||||
let out = written(&format!(r#"<filter id="s"><feFlood flood-color="blue"/>{shadow}</filter>"#)).unwrap();
|
||||
assert!(out.starts_with(r##"<filter id="s"><feFlood flood-color="#0000ff" result="vcShadow0"/><feGaussianBlur in="vcShadow0""##), "{out}");
|
||||
assert!(out.ends_with(r#"<feMergeNode in="vcShadow0"/></feMerge></filter>"#), "{out}");
|
||||
assert_eq!(check(&out), Ok("s".into()), "the written form reads back");
|
||||
}
|
||||
// What SVG 1.1 can't hold leaves the filter to be written as other pixel effects are.
|
||||
for xml in [
|
||||
r#"<filter id="s"><feDropShadow flood-color="currentColor"/></filter>"#,
|
||||
r#"<filter id="s" primitiveUnits="objectBoundingBox"><feDropShadow/></filter>"#,
|
||||
] {
|
||||
let f = filter(json!({ "xml": xml }));
|
||||
assert!(!f.writes_as_svg11() && f.written_xml(Rect::new(0.0, 0.0, 10.0, 10.0)).is_none(), "{xml}");
|
||||
}
|
||||
// An offset that isn't a number is the shadow's default, which an `feOffset` doesn't share.
|
||||
let out = written(r#"<filter id="s"><feDropShadow dx="bad" dy="-3"/></filter>"#).unwrap();
|
||||
assert!(out.contains(r#"<feOffset dx="2" dy="-3""#), "{out}");
|
||||
// Expanded shadows past the primitive cap.
|
||||
let many = format!(r#"<filter id="s">{}</filter>"#, "<feDropShadow/>".repeat(13));
|
||||
assert!(!filter(json!({ "xml": many })).writes_as_svg11());
|
||||
}
|
||||
|
||||
/// Each input is written as the primitive it reads when drawn: a result name only usvg gives (to
|
||||
/// a primitive without a `result`, here the shadow), and the inputs usvg draws as `SourceGraphic`.
|
||||
#[test]
|
||||
fn inputs_are_written_as_the_primitives_they_read() {
|
||||
let xml = r#"<filter id="s"><feOffset dx="1"/><feDropShadow in="BackgroundImage"/><feOffset dx="2"/><feMerge><feMergeNode in="result2"/><feMergeNode in="FillPaint"/></feMerge><feComposite in="result1" in2="StrokePaint"/></filter>"#;
|
||||
let out = written(xml).unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
concat!(
|
||||
r#"<filter id="s"><feOffset dx="1" result="vcShadow1"/>"#,
|
||||
r#"<feGaussianBlur in="SourceGraphic" stdDeviation="2"/><feOffset dx="2" dy="2" result="vcShadow2"/><feFlood/>"#,
|
||||
r#"<feComposite in2="vcShadow2" operator="in" result="vcShadow3"/><feMerge result="vcShadow0"><feMergeNode in="vcShadow3"/><feMergeNode in="SourceGraphic"/></feMerge>"#,
|
||||
r#"<feOffset dx="2"/><feMerge><feMergeNode in="vcShadow0"/><feMergeNode in="SourceGraphic"/></feMerge>"#,
|
||||
r#"<feComposite in="vcShadow1" in2="SourceGraphic"/></filter>"#,
|
||||
)
|
||||
);
|
||||
assert_eq!(check(&out), Ok("s".into()), "the written form reads back");
|
||||
}
|
||||
|
||||
/// Other values SVG 1.1 lacks: SVG 1.1 values that draw alike, or no SVG 1.1 form.
|
||||
#[test]
|
||||
fn values_svg_1_1_lacks_are_written_as_resvg_draws_them() {
|
||||
// `lighter` (Filter Effects) is drawn as the default `over`, and a blur has no `edgeMode`.
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c"><feComposite in2="SourceAlpha" operator="lighter"/><feGaussianBlur stdDeviation="1" edgeMode="wrap"/></filter>"#)
|
||||
.as_deref(),
|
||||
Some(r#"<filter id="c"><feComposite in2="SourceAlpha"/><feGaussianBlur stdDeviation="1"/></filter>"#)
|
||||
);
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c"><feBlend in2="SourceAlpha" mode="multiply"/></filter>"#).as_deref(),
|
||||
Some(r#"<filter id="c"><feBlend in2="SourceAlpha" mode="multiply"/></filter>"#)
|
||||
);
|
||||
// CSS colors as `#rrggbb`, a flood color's alpha in its opacity, a lighting color's dropped.
|
||||
assert_eq!(
|
||||
written(r#"<filter id="c" color="hsl(120, 100%, 50%)"><feFlood flood-color="transparent"/><feDiffuseLighting lighting-color="rgba(0,0,255,0.5)"><feDistantLight/></feDiffuseLighting></filter>"#)
|
||||
.as_deref(),
|
||||
Some(
|
||||
r##"<filter id="c" color="#00ff00"><feFlood flood-color="#000000" flood-opacity="0"/><feDiffuseLighting lighting-color="#0000ff"><feDistantLight/></feDiffuseLighting></filter>"##
|
||||
)
|
||||
);
|
||||
// Image scaling as an SVG 1.1 keyword.
|
||||
let data = format!("data:image/png;base64,{}", base64(&png_claiming(2, 2)));
|
||||
for (given, kept) in
|
||||
[("pixelated", "optimizeSpeed"), ("crisp-edges", "optimizeSpeed"), ("smooth", "optimizeQuality"), ("auto", "optimizeQuality")]
|
||||
{
|
||||
let out = written(&format!(r#"<filter id="i"><feImage style="image-rendering:{given}" href="{data}"/></filter>"#)).unwrap();
|
||||
assert!(out.contains(&format!(r#"style="image-rendering:{kept}""#)), "{given}: {out}");
|
||||
}
|
||||
// The blend modes SVG 1.1 lacks have no SVG 1.1 form.
|
||||
for mode in LATER_BLEND_MODES {
|
||||
let f = filter(json!({ "xml": format!(r#"<filter id="b"><feBlend in2="SourceAlpha" mode="{mode}"/></filter>"#) }));
|
||||
assert!(!f.writes_as_svg11(), "{mode}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn base64_pads_as_rfc_4648_does() {
|
||||
assert_eq!(base64(b""), "");
|
||||
assert_eq!(base64(b"f"), "Zg==");
|
||||
assert_eq!(base64(b"fo"), "Zm8=");
|
||||
assert_eq!(base64(b"foo"), "Zm9v");
|
||||
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
||||
}
|
||||
@@ -407,6 +407,54 @@ fn stacked_effects_on_art_stay_within_the_anchor_budget() {
|
||||
assert_eq!(reshaped, 10);
|
||||
}
|
||||
|
||||
/// A path that keeps its shape because its Transform effect would pass the budget keeps the
|
||||
/// gradients that paint the art as a whole in place too, also with Scale Strokes & Effects on.
|
||||
#[test]
|
||||
fn art_past_the_budget_keeps_its_own_gradients_in_place() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer, NodeId, color::Paint};
|
||||
let id = NodeId(9);
|
||||
let stops = vec![GradientStop::new(0.0, vectorcraft_color::Color::BLACK), GradientStop::new(1.0, vectorcraft_color::Color::WHITE)];
|
||||
let geom = GradientGeom { start: (0.0, 50.0).into(), end: (100.0, 50.0).into(), aspect: 1.0, focal: None };
|
||||
let placed = Paint::Gradient(Box::new(GradientPaint { geom: Some(geom), ..GradientPaint::new(Gradient::new(GradientKind::Linear, stops)) }));
|
||||
let circle = || Arc::new(Node::path(id, shapes::ellipse(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()));
|
||||
// The group's own fill, a gradient placed across the circle, after `carry` moves it.
|
||||
let own_end = |carry: &crate::Carry| {
|
||||
let mut ap = Appearance::default();
|
||||
ap.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
|
||||
carry.apply(&mut ap);
|
||||
match ap.fill_paint() {
|
||||
Paint::Gradient(g) => g.geom.map(|g| g.end.x),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
for params in [json!({"scaleH": 1000, "scaleV": 1000}), json!({"scaleH": 1000, "scaleV": 1000, "strokes": true})] {
|
||||
let grow = fx("distort.transform", params.clone());
|
||||
let mut art = Node::group(id, vec![circle()]);
|
||||
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget::EFFECTS);
|
||||
let end = own_end(&carry).unwrap();
|
||||
assert!((end - 550.0).abs() < 1e-6, "{params}: within the budget the group's gradient grows with the circle: {end}");
|
||||
// No room beyond what the circle costs: scaled up, it would be drawn with more lines.
|
||||
let mut art = Node::group(id, vec![circle()]);
|
||||
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
|
||||
let b = art.geometric_bounds().unwrap();
|
||||
assert!(b.width() < 101.0 && b.height() < 101.0, "{params}: the circle keeps its shape: {b:?}");
|
||||
assert_eq!(own_end(&carry), Some(100.0), "{params}: the group's gradient stays where it was placed");
|
||||
// A group inside the art whose square took the effect moves its own gradient all the same.
|
||||
let square = Arc::new(Node::path(NodeId(3), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default()));
|
||||
let mut inner = Node::group(NodeId(2), vec![square]);
|
||||
inner.appearance.items.push(AppearanceItem::Fill(FillLayer::new(placed.clone())));
|
||||
let mut art = Node::group(id, vec![circle(), Arc::new(inner)]);
|
||||
let carry = crate::reshape::reshape_leaves_within(&mut art, &[&grow], Budget { anchors: usize::MAX, lines: 0 });
|
||||
assert_eq!(own_end(&carry), Some(100.0), "{params}: the art's own gradient stays");
|
||||
let inner = &art.children().unwrap()[1];
|
||||
let Paint::Gradient(g) = inner.appearance.fill_paint() else { panic!("a gradient") };
|
||||
assert!((g.geom.unwrap().end.x - 550.0).abs() < 1e-6, "{params}: the inner group's gradient: {:?}", g.geom);
|
||||
}
|
||||
}
|
||||
|
||||
/// #3578: a large Roughen draws each of its points as many lines; they stay within the budget,
|
||||
/// the Roughen built again with fewer points where they wouldn't.
|
||||
#[test]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The command registry. Ids follow Illustrator's menu structure.
|
||||
|
||||
pub mod actionset;
|
||||
mod addons;
|
||||
pub(crate) mod appearance;
|
||||
mod assets;
|
||||
@@ -83,6 +84,7 @@ pub(crate) mod slices;
|
||||
mod stroke;
|
||||
mod style;
|
||||
pub mod stylelib;
|
||||
mod svgfilter;
|
||||
mod swatch;
|
||||
pub mod swatchlib;
|
||||
pub(crate) mod symboledit;
|
||||
@@ -317,6 +319,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(svgfilter::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
@@ -344,6 +347,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(halftone::specs());
|
||||
v.extend(perspgrid::specs());
|
||||
v.extend(shortcutset::specs());
|
||||
v.extend(actionset::specs());
|
||||
v.extend(importmatches::specs());
|
||||
v.extend(addons::specs());
|
||||
v
|
||||
@@ -590,11 +594,16 @@ pub(crate) fn object_param(p: &Value, key: &str, cmd: &str) -> Result<Option<Nod
|
||||
}
|
||||
|
||||
/// Objects a command targets: its `ids`, else its `id` as `ids` of one ([`named_ids`]), else the
|
||||
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection.
|
||||
/// selection. An `ids` or `id` that isn't an object id is an error, never the selection. The
|
||||
/// selection is refused while an object of a dynamic symbol instance's art is selected, as by
|
||||
/// [`edit::selected_roots`].
|
||||
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
|
||||
match named_ids(p)? {
|
||||
Some(ids) => Ok(ids),
|
||||
None => Ok(s.doc()?.selection.objects.clone()),
|
||||
None => {
|
||||
edit::refuse_symbol_part(s)?;
|
||||
Ok(s.doc()?.selection.objects.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,6 +676,7 @@ pub(crate) const MAX_IMPORTED_PRESETS: usize = 1000;
|
||||
|
||||
pub(crate) use edit::{settle_interaction, typing_in_progress};
|
||||
pub(crate) use object::bounds_rewrite;
|
||||
pub(crate) use object::{keep_aligned_in, refresh_pixel_aligned};
|
||||
pub(crate) use vectorcraft_doc::range::parse_range;
|
||||
|
||||
/// A date param in Unix seconds that defaults to the clock: `None` when absent (now, see
|
||||
|
||||
@@ -0,0 +1,560 @@
|
||||
//! Action sets another app saves as text (`.aia` files that start `/version`), read into their
|
||||
//! actions and steps without changing anything (`actionSet.read`).
|
||||
//!
|
||||
//! The text is a set of fields and blocks. A slash name followed by a value sets a field, and a
|
||||
//! slash name followed by `{` opens a block that `}` closes. A value is a decimal number, text in
|
||||
//! parentheses, a byte count and hexadecimal bytes in square brackets (`[ 5 48656c6c6f ]`, UTF-8
|
||||
//! text, which can run over several words), or a byte count and hexadecimal bytes in angle brackets
|
||||
//! (`< 3 0a0b0c >`, raw bytes). The set's `/version` comes first, and its `/name` is hexadecimal
|
||||
//! text. Each action is an `/action-N` block with a hexadecimal `/name`; each step is an `/event-N`
|
||||
//! block inside one, with its `/internalName` in parentheses, the `/localizedName` it is shown with
|
||||
//! as hexadecimal text, and `/isOn` 0 when it is left out of playback; each value a step was
|
||||
//! recorded with is a `/parameter-N` block inside it, which is counted, and whose hexadecimal
|
||||
//! `/value` is checked against its byte count. The set's `/actionCount`, an action's `/eventCount`
|
||||
//! and a step's `/parameterCount` give how many actions, steps and recorded values follow.
|
||||
//! The layout follows the README of Inventsable's Redesigning-Illustrator-Actions-in-Scripting
|
||||
//! (https://github.com/Inventsable/Redesigning-Illustrator-Actions-in-Scripting), the forum post
|
||||
//! "Made a two-way aia to json converter"
|
||||
//! (https://community.adobe.com/t5/illustrator-discussions/made-a-two-way-aia-to-json-converter/m-p/13751909),
|
||||
//! sttk3's article on names as UTF-8 hexadecimal
|
||||
//! (https://sttk3.com/blog/tips/illustrator/dynamic-generate-action.html), and the MIT-licensed
|
||||
//! reader in zachshallbetter/adobe-mcp (https://github.com/zachshallbetter/adobe-mcp). The raw form
|
||||
//! follows the forum thread "Creating a dynamic action"
|
||||
//! (https://community.adobe.com/questions-652/creating-a-dynamic-action-to-use-with-app-doscript-method-814406).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, EngineError, Result, Session, always, bad, cmd, text_param};
|
||||
|
||||
/// The most bytes of a file read.
|
||||
const MAX_BYTES: usize = 8 << 20;
|
||||
/// The most bytes taken in: [`MAX_BYTES`] after a byte order mark, and one more that shows the text
|
||||
/// goes on.
|
||||
const READ_LIMIT: usize = MAX_BYTES + 4;
|
||||
/// The most blocks open at once.
|
||||
const MAX_DEPTH: usize = 16;
|
||||
/// The most actions read.
|
||||
const MAX_ACTIONS: usize = 1000;
|
||||
/// The most steps read in one action.
|
||||
const MAX_STEPS: usize = 10_000;
|
||||
/// The most steps read in the set: a small text can hold hundreds of thousands of empty steps, and
|
||||
/// the answer holds an object for each.
|
||||
const MAX_SET_STEPS: usize = 100_000;
|
||||
/// The longest name kept (bytes).
|
||||
const MAX_NAME: usize = 1024;
|
||||
/// The most tokens listed as unread.
|
||||
const MAX_UNREAD: usize = 1000;
|
||||
/// The longest unread token shown (characters).
|
||||
const UNREAD_SHOWN: usize = 80;
|
||||
|
||||
/// The extensions of action sets other apps save.
|
||||
pub const EXTS: &[&str] = &["aia"];
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "actionSet.read",
|
||||
"Read Action Set File",
|
||||
[],
|
||||
None,
|
||||
"{path? | data?: the file's text | dataBase64?, name?: the file's name (default: the path's)} read an action set another app saved as text (.aia, starting \"/version\") without changing anything → {set: the set's /name (else the file's name without its extension, else empty), version, actions: [{name, steps: [{name: the step's /internalName, label: its /localizedName, or null when it has none or it is blank, on: false when /isOn is 0, params: how many /parameter blocks it has, line}]}], unread: [{line, text}] (tokens that aren't read, at most 1000), truncated: true when a cap stopped the reading (8 MiB, 16 open blocks, 1,000 actions, 10,000 steps in an action, 100,000 steps in the set; names past 1,024 bytes are cut), when the text ends inside a block or a value or after a field's name, when /actionCount, /eventCount or a step's /parameterCount differs from the actions, steps or recorded values read (a negative count, one too large for a number and one that isn't a number always differ), or when a hexadecimal name, label or recorded /value isn't as many bytes as its count says}. Give exactly one of path, data and dataBase64. Text that doesn't start with /version and a number is an error",
|
||||
always,
|
||||
read
|
||||
)]
|
||||
}
|
||||
|
||||
/// A step of an action: an `/event-N` block.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaStep {
|
||||
/// Its `/internalName`.
|
||||
pub name: String,
|
||||
/// Its `/localizedName`, if it has one.
|
||||
pub label: Option<String>,
|
||||
/// Its `/isOn`, if it has one.
|
||||
pub on: Option<i64>,
|
||||
/// How many `/parameter-N` blocks it has.
|
||||
pub params: usize,
|
||||
/// Its `/parameterCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
/// The line its block opens on (from 1).
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
/// An action: an `/action-N` block.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaAction {
|
||||
pub name: String,
|
||||
pub steps: Vec<AiaStep>,
|
||||
/// Its `/eventCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
}
|
||||
|
||||
/// An action set read from text.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct AiaSet {
|
||||
pub version: i64,
|
||||
/// The set's `/name`, if it has one.
|
||||
pub name: Option<String>,
|
||||
pub actions: Vec<AiaAction>,
|
||||
/// Its `/actionCount`, if it has one.
|
||||
pub declared: Option<i64>,
|
||||
/// The tokens that weren't read, with their lines.
|
||||
pub unread: Vec<(usize, String)>,
|
||||
/// A cap stopped the reading or cut a name, the text ends inside a block or a value or after a
|
||||
/// field's name, its counts differ from the actions, steps and recorded values read (a negative
|
||||
/// count, one too large for an `i64` and one that isn't a number always differ), or a hexadecimal
|
||||
/// name, label or recorded `/value` isn't as many bytes as its count says.
|
||||
pub truncated: bool,
|
||||
/// How many steps were read in all the actions.
|
||||
steps: usize,
|
||||
}
|
||||
|
||||
/// A token of the text.
|
||||
enum Token {
|
||||
/// A slash name.
|
||||
Name(Vec<u8>),
|
||||
/// An integer; `None` when it doesn't fit an `i64`.
|
||||
Int(Option<i64>),
|
||||
/// Text in parentheses, a backslash taking the next byte as it is.
|
||||
Text(Vec<u8>),
|
||||
/// The bytes of `[ count hex… ]`; `None` when the hexadecimal digits don't make whole bytes or
|
||||
/// as many bytes as the count says.
|
||||
Hex(Option<Vec<u8>>),
|
||||
/// `< count hex… >`.
|
||||
Raw,
|
||||
Open,
|
||||
Close,
|
||||
/// Anything else, as written.
|
||||
Other(Vec<u8>),
|
||||
}
|
||||
|
||||
/// The tokens of `text`, each with its line.
|
||||
struct Tokens<'a> {
|
||||
text: &'a [u8],
|
||||
at: usize,
|
||||
line: usize,
|
||||
/// The text ended inside a value in parentheses, square brackets or angle brackets.
|
||||
unclosed: bool,
|
||||
}
|
||||
|
||||
impl<'a> Tokens<'a> {
|
||||
/// Past one byte, counting a line at LF, at CR alone, and once for CRLF.
|
||||
fn step(&mut self) -> Option<u8> {
|
||||
let b = *self.text.get(self.at)?;
|
||||
if b == b'\n' || b == b'\r' && self.text.get(self.at + 1) != Some(&b'\n') {
|
||||
self.line += 1;
|
||||
}
|
||||
self.at += 1;
|
||||
Some(b)
|
||||
}
|
||||
|
||||
/// The bytes up to `end` (not taken in); `None` when the text ends first.
|
||||
fn until(&mut self, end: u8) -> Option<&'a [u8]> {
|
||||
let (text, start) = (self.text, self.at);
|
||||
while text.get(self.at).is_some_and(|&b| b != end) {
|
||||
self.step();
|
||||
}
|
||||
let inside = text.get(start..self.at)?;
|
||||
text.get(self.at)?;
|
||||
self.at += 1;
|
||||
Some(inside)
|
||||
}
|
||||
|
||||
fn next_token(&mut self) -> Option<(usize, Token)> {
|
||||
while self.text.get(self.at).is_some_and(|&b| b.is_ascii_whitespace() || b == 0) {
|
||||
self.step();
|
||||
}
|
||||
let line = self.line;
|
||||
let b = *self.text.get(self.at)?;
|
||||
let ends = |b: u8| b.is_ascii_whitespace() || b == 0 || b"{}[]()<>/".contains(&b);
|
||||
let token = match b {
|
||||
b'{' | b'}' => {
|
||||
self.at += 1;
|
||||
if b == b'{' { Token::Open } else { Token::Close }
|
||||
}
|
||||
b'(' => {
|
||||
self.at += 1;
|
||||
let (mut text, mut depth) = (vec![], 0usize);
|
||||
self.unclosed = true;
|
||||
while let Some(c) = self.step() {
|
||||
match c {
|
||||
b'\\' => text.extend(self.step()),
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
text.push(c);
|
||||
}
|
||||
b')' if depth == 0 => {
|
||||
self.unclosed = false;
|
||||
break;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
text.push(c);
|
||||
}
|
||||
_ => text.push(c),
|
||||
}
|
||||
}
|
||||
Token::Text(text)
|
||||
}
|
||||
b'[' | b'<' => {
|
||||
self.at += 1;
|
||||
let close = if b == b'[' { b']' } else { b'>' };
|
||||
let inside = self.until(close);
|
||||
self.unclosed = inside.is_none();
|
||||
let inside = inside.unwrap_or_default().to_vec();
|
||||
if b == b'<' {
|
||||
Token::Raw
|
||||
} else {
|
||||
// The first word is the byte count; the bytes follow in one or more words.
|
||||
let mut words = inside.split(|c| c.is_ascii_whitespace()).filter(|w| !w.is_empty());
|
||||
let count = words.next().and_then(|w| std::str::from_utf8(w).ok()).and_then(|w| w.parse::<usize>().ok());
|
||||
let digits: Vec<u8> = words.flatten().copied().collect();
|
||||
Token::Hex(hex_bytes(&digits).filter(|b| Some(b.len()) == count))
|
||||
}
|
||||
}
|
||||
b'/' => {
|
||||
self.at += 1;
|
||||
let start = self.at;
|
||||
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
|
||||
self.at += 1;
|
||||
}
|
||||
Token::Name(self.text.get(start..self.at).unwrap_or_default().to_vec())
|
||||
}
|
||||
_ => {
|
||||
let start = self.at;
|
||||
while self.text.get(self.at).is_some_and(|&c| !ends(c)) {
|
||||
self.at += 1;
|
||||
}
|
||||
// Always past at least one byte.
|
||||
self.at = self.at.max(start + 1);
|
||||
let word = self.text.get(start..self.at).unwrap_or_default();
|
||||
let number = word.strip_prefix(b"-").or_else(|| word.strip_prefix(b"+")).unwrap_or(word);
|
||||
if !number.is_empty() && number.iter().all(u8::is_ascii_digit) {
|
||||
Token::Int(std::str::from_utf8(word).ok().and_then(|s| s.parse().ok()))
|
||||
} else {
|
||||
Token::Other(word.to_vec())
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((line, token))
|
||||
}
|
||||
}
|
||||
|
||||
/// The bytes hexadecimal `digits` stand for, two digits a byte.
|
||||
fn hex_bytes(digits: &[u8]) -> Option<Vec<u8>> {
|
||||
let nibble = |c: u8| (c as char).to_digit(16).and_then(|d| u8::try_from(d).ok());
|
||||
if !digits.len().is_multiple_of(2) {
|
||||
return None;
|
||||
}
|
||||
digits.chunks(2).map(|pair| Some((nibble(*pair.first()?)? << 4) | nibble(*pair.get(1)?)?)).collect()
|
||||
}
|
||||
|
||||
/// What a block is.
|
||||
enum Kind {
|
||||
Action,
|
||||
Step,
|
||||
Param,
|
||||
/// A block that isn't read, or one past a cap.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// `name` ends with `-` and a number after `prefix`, as `/action-1` does.
|
||||
fn numbered(name: &[u8], prefix: &[u8]) -> bool {
|
||||
name.strip_prefix(prefix).is_some_and(|n| !n.is_empty() && n.iter().all(u8::is_ascii_digit))
|
||||
}
|
||||
|
||||
/// A name as text: at most [`MAX_NAME`] bytes, read as UTF-8 with replacement characters.
|
||||
fn name_text(name: &[u8], set: &mut AiaSet) -> String {
|
||||
if name.len() > MAX_NAME {
|
||||
set.truncated = true;
|
||||
}
|
||||
let mut s = String::from_utf8_lossy(name.get(..MAX_NAME).unwrap_or(name)).into_owned();
|
||||
// A character cut at the limit reads as one replacement character.
|
||||
if name.len() > MAX_NAME && s.ends_with('\u{FFFD}') {
|
||||
s.pop();
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// How the unread list shows a value in square brackets that isn't as many bytes as its count says.
|
||||
const BAD_HEX: &str = "[ … ] (not the hexadecimal bytes its count says)";
|
||||
|
||||
/// List a token that isn't read.
|
||||
fn unread(set: &mut AiaSet, line: usize, text: impl AsRef<[u8]>) {
|
||||
if set.unread.len() < MAX_UNREAD {
|
||||
let text: String = String::from_utf8_lossy(text.as_ref()).chars().take(UNREAD_SHOWN).collect();
|
||||
set.unread.push((line, text));
|
||||
}
|
||||
}
|
||||
|
||||
/// What the block `name` that opens on `line` inside the blocks `stack` is: a new action, step or
|
||||
/// recorded value of `set`, or a block that isn't read (listed when it is out of place).
|
||||
fn open_block(set: &mut AiaSet, stack: &[Kind], name: &[u8], line: usize) -> Kind {
|
||||
match stack.last() {
|
||||
_ if stack.len() >= MAX_DEPTH => {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
None if numbered(name, b"action-") => {
|
||||
if set.actions.len() < MAX_ACTIONS {
|
||||
set.actions.push(AiaAction::default());
|
||||
Kind::Action
|
||||
} else {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
Some(Kind::Action) if numbered(name, b"event-") => match set.actions.last_mut() {
|
||||
Some(a) if a.steps.len() < MAX_STEPS && set.steps < MAX_SET_STEPS => {
|
||||
a.steps.push(AiaStep { line, ..AiaStep::default() });
|
||||
set.steps += 1;
|
||||
Kind::Step
|
||||
}
|
||||
_ => {
|
||||
set.truncated = true;
|
||||
Kind::Other
|
||||
}
|
||||
},
|
||||
Some(Kind::Step) if numbered(name, b"parameter-") => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.params += 1;
|
||||
}
|
||||
Kind::Param
|
||||
}
|
||||
Some(Kind::Param | Kind::Other) => Kind::Other,
|
||||
_ => {
|
||||
unread(set, line, format!("/{} {{", String::from_utf8_lossy(name)));
|
||||
Kind::Other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the field `name` of the block `kind` (the set when `None`) to the hexadecimal text `bytes`
|
||||
/// on `line`: the set's or an action's `/name`, or a step's `/localizedName`.
|
||||
fn set_hex(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, bytes: Option<Vec<u8>>, line: usize) {
|
||||
let Some(bytes) = bytes else {
|
||||
set.truncated = true;
|
||||
unread(set, line, BAD_HEX);
|
||||
return;
|
||||
};
|
||||
match (kind, name) {
|
||||
(None, Some(b"name")) => set.name = Some(name_text(&bytes, set)),
|
||||
(Some(Kind::Action), Some(b"name")) => {
|
||||
let text = name_text(&bytes, set);
|
||||
if let Some(a) = set.actions.last_mut() {
|
||||
a.name = text;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"localizedName")) => {
|
||||
let text = name_text(&bytes, set);
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.label = Some(text);
|
||||
}
|
||||
}
|
||||
(_, Some(_)) => {}
|
||||
(_, None) => unread(set, line, "[ … ]"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark `set` truncated when the hexadecimal `/value` on `line` isn't as many bytes as its count
|
||||
/// says.
|
||||
fn check_value(set: &mut AiaSet, bytes: Option<Vec<u8>>, line: usize) {
|
||||
if bytes.is_none() {
|
||||
set.truncated = true;
|
||||
unread(set, line, BAD_HEX);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark `set` truncated when the field `name` of the block `kind` (the set when `None`) is a count
|
||||
/// (the set's `/actionCount`, an action's `/eventCount` or a step's `/parameterCount`) whose value is
|
||||
/// not a number that fits an `i64`.
|
||||
fn bad_count(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>) {
|
||||
if matches!((kind, name), (None, Some(b"actionCount")) | (Some(Kind::Action), Some(b"eventCount")) | (Some(Kind::Step), Some(b"parameterCount")))
|
||||
{
|
||||
set.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the field `name` of the block `kind` (the set when `None`) to the integer `value` on `line`:
|
||||
/// the set's `/actionCount`, an action's `/eventCount`, a step's `/parameterCount`, or a step's
|
||||
/// `/isOn`. A count too large for an `i64` marks the set truncated.
|
||||
fn set_int(set: &mut AiaSet, kind: Option<&Kind>, name: Option<&[u8]>, value: Option<i64>, line: usize) {
|
||||
if value.is_none() {
|
||||
bad_count(set, kind, name);
|
||||
}
|
||||
match (kind, name) {
|
||||
(None, Some(b"actionCount")) => set.declared = value,
|
||||
(Some(Kind::Action), Some(b"eventCount")) => {
|
||||
if let Some(a) = set.actions.last_mut() {
|
||||
a.declared = value;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"parameterCount")) => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.declared = value;
|
||||
}
|
||||
}
|
||||
(Some(Kind::Step), Some(b"isOn")) => {
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.on = value;
|
||||
}
|
||||
}
|
||||
(_, Some(_)) => {}
|
||||
(_, None) => unread(set, line, value.map_or_else(|| "(a number too large)".to_string(), |v| v.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an action set saved as text. An error when the text isn't one: it doesn't start with
|
||||
/// `/version` and an integer.
|
||||
pub fn read_aia(bytes: &[u8]) -> std::result::Result<AiaSet, String> {
|
||||
let mut set = AiaSet::default();
|
||||
let text = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
let text = match text.get(..MAX_BYTES) {
|
||||
Some(head) if text.len() > MAX_BYTES => {
|
||||
set.truncated = true;
|
||||
head
|
||||
}
|
||||
_ => text,
|
||||
};
|
||||
let mut tokens = Tokens { text, at: 0, line: 1, unclosed: false };
|
||||
match (tokens.next_token(), tokens.next_token()) {
|
||||
(Some((_, Token::Name(n))), Some((_, Token::Int(Some(v))))) if n == b"version" => set.version = v,
|
||||
_ => return Err("not an action set: the text doesn't start with /version and a number".into()),
|
||||
}
|
||||
let mut stack: Vec<Kind> = vec![];
|
||||
// A slash name waiting for its value or its `{`: (line, name).
|
||||
let mut pending: Option<(usize, Vec<u8>)> = None;
|
||||
while let Some((line, token)) = tokens.next_token() {
|
||||
let in_step = matches!(stack.last(), Some(Kind::Step));
|
||||
let field = |pending: &mut Option<(usize, Vec<u8>)>| pending.take().map(|(_, n)| n);
|
||||
match token {
|
||||
Token::Name(name) => {
|
||||
if let Some((l, n)) = pending.replace((line, name)) {
|
||||
bad_count(&mut set, stack.last(), Some(&n));
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
}
|
||||
Token::Open => {
|
||||
let (l, name) = pending.take().unwrap_or((line, vec![]));
|
||||
bad_count(&mut set, stack.last(), Some(&name));
|
||||
let kind = open_block(&mut set, &stack, &name, l);
|
||||
stack.push(kind);
|
||||
}
|
||||
Token::Close => {
|
||||
if let Some((l, n)) = pending.take() {
|
||||
bad_count(&mut set, stack.last(), Some(&n));
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
if stack.pop().is_none() {
|
||||
unread(&mut set, line, "}");
|
||||
}
|
||||
}
|
||||
Token::Hex(bytes) if matches!(stack.last(), Some(Kind::Param)) => {
|
||||
if field(&mut pending).as_deref() == Some(b"value".as_slice()) {
|
||||
check_value(&mut set, bytes, line);
|
||||
}
|
||||
}
|
||||
// The other values inside a recorded value, or a block that isn't read.
|
||||
_ if matches!(stack.last(), Some(Kind::Param | Kind::Other)) => {
|
||||
pending = None;
|
||||
}
|
||||
Token::Hex(bytes) => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
set_hex(&mut set, stack.last(), name.as_deref(), bytes, line);
|
||||
}
|
||||
Token::Text(t) => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
match name {
|
||||
Some(n) if in_step && n == b"internalName" => {
|
||||
let name = name_text(&t, &mut set);
|
||||
if let Some(s) = set.actions.last_mut().and_then(|a| a.steps.last_mut()) {
|
||||
s.name = name;
|
||||
}
|
||||
}
|
||||
Some(_) => {}
|
||||
None => unread(&mut set, line, [b"(".as_slice(), &t, b")"].concat()),
|
||||
}
|
||||
}
|
||||
Token::Int(v) => {
|
||||
let name = field(&mut pending);
|
||||
set_int(&mut set, stack.last(), name.as_deref(), v, line);
|
||||
}
|
||||
Token::Raw => {
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
if name.is_none() {
|
||||
unread(&mut set, line, "< … >");
|
||||
}
|
||||
}
|
||||
Token::Other(word) => {
|
||||
// A real number, or another word as a field's value.
|
||||
let name = field(&mut pending);
|
||||
bad_count(&mut set, stack.last(), name.as_deref());
|
||||
if name.is_none() {
|
||||
unread(&mut set, line, word);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The text ends after a field's name, before its value.
|
||||
if let Some((l, n)) = pending {
|
||||
set.truncated = true;
|
||||
unread(&mut set, l, [b"/".as_slice(), &n].concat());
|
||||
}
|
||||
// An unclosed value is the last token: it takes the rest of the text.
|
||||
if tokens.unclosed {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, "a value not closed at the end");
|
||||
}
|
||||
if !stack.is_empty() {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, format!("blocks left open at the end: {}", stack.len()));
|
||||
}
|
||||
// Not as many actions, steps or recorded values as the counts give; a negative count never matches.
|
||||
let differs = |declared: Option<i64>, read: usize| declared.is_some_and(|d| !usize::try_from(d).is_ok_and(|d| d == read));
|
||||
let step_differs = |a: &AiaAction| a.steps.iter().any(|s| differs(s.declared, s.params));
|
||||
if differs(set.declared, set.actions.len()) || set.actions.iter().any(|a| differs(a.declared, a.steps.len()) || step_differs(a)) {
|
||||
set.truncated = true;
|
||||
unread(&mut set, tokens.line, "not as many actions, steps or recorded values as the counts give");
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn read(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "actionSet.read";
|
||||
let (path, data, b64) = (text_param(p, "path", C)?, text_param(p, "data", C)?, text_param(p, "dataBase64", C)?);
|
||||
let name = text_param(p, "name", C)?;
|
||||
let bytes: std::borrow::Cow<[u8]> = match (path, data, b64) {
|
||||
(Some(path), None, None) => super::fileio::read_file_head(path, READ_LIMIT)?.into(),
|
||||
(None, Some(text), None) => text.as_bytes().get(..READ_LIMIT).unwrap_or(text.as_bytes()).into(),
|
||||
(None, None, Some(b64)) => super::scripts::decode_head(b64, READ_LIMIT).ok_or_else(|| bad(C, "bad dataBase64"))?.into(),
|
||||
_ => return Err(bad(C, "give exactly one of `path`, `data` and `dataBase64`")),
|
||||
};
|
||||
let set = read_aia(&bytes).map_err(EngineError::Other)?;
|
||||
let file = name.or(path).unwrap_or_default();
|
||||
let stem = std::path::Path::new(file).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let name = set.name.clone().filter(|n| !n.trim().is_empty()).unwrap_or(stem);
|
||||
let actions: Vec<Value> = set
|
||||
.actions
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let steps: Vec<Value> = a
|
||||
.steps
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let label = s.label.clone().filter(|l| !l.trim().is_empty());
|
||||
json!({ "name": s.name, "label": label, "on": s.on != Some(0), "params": s.params, "line": s.line })
|
||||
})
|
||||
.collect();
|
||||
json!({ "name": a.name, "steps": steps })
|
||||
})
|
||||
.collect();
|
||||
Ok(json!({
|
||||
"set": name,
|
||||
"version": set.version,
|
||||
"actions": actions,
|
||||
"unread": set.unread.iter().map(|(line, text)| json!({ "line": line, "text": text })).collect::<Vec<_>>(),
|
||||
"truncated": set.truncated,
|
||||
}))
|
||||
}
|
||||
@@ -1004,7 +1004,6 @@ fn replace_fonts(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, None, &|t, _| lacks(t), &mut replace);
|
||||
drop(replace);
|
||||
Ok(runs)
|
||||
})?;
|
||||
Ok(json!({ "runs": runs, "font": family }))
|
||||
|
||||
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Eyedropper",
|
||||
[],
|
||||
None,
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art). On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
"{source: id, ids?, pickUp?, apply? (trees as eyedropper.setOptions, merged over the Eyedropper Options for this call), reverse?: bool, append?: bool, defaultsOnly?: bool} copy the attributes both picked up and applied (by default the whole appearance stack, opacity and blend mode, and from type to type the character and paragraph attributes) from `source` to ids (default: highlighted characters while editing type, after Select All in a frame of threaded text the text of the story's frames, leaving out `source`, otherwise the object selection; highlighted characters reject enabled non-neutral object transparency), the fill, stroke and weight also to the paint defaults; `defaultsOnly` updates only paint defaults and cannot be combined with ids, reverse or append; `reverse` (Alt-click) copies from the first selected object (or ids) onto `source` instead, or with nothing selected what the Eyedropper picked up last (a click without Alt); `append` (Shift+Alt-click) adds the source's fills, strokes and effects on top of each target's stack; placed gradients land at the same place relative to each target's bounds (defaults: fitted to new art); with an object of a dynamic symbol instance selected by Direct Selection and no ids, reverse, append or defaultsOnly, that object takes the source's solid fill and stroke for this instance only, as the Eyedropper Options allow, and a sampled gradient or pattern, a source that is not a path, compound path or type, or options that pick up neither color, are refused. On a path directly in a graph series, the graph also stores the paint, weight and opacity of the topmost visible fill and stroke (exceptions: docs/mcp.md) → {ids}",
|
||||
has_doc,
|
||||
copy_from
|
||||
),
|
||||
@@ -598,6 +598,9 @@ fn add_item(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
|
||||
n.appearance.items.push(item);
|
||||
added.push((*id, fill));
|
||||
}
|
||||
if !fill {
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
@@ -620,6 +623,7 @@ fn clear_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &ids {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
clear(n);
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
@@ -661,6 +665,35 @@ fn painted_rows(d: &vectorcraft_doc::Document, rows: impl IntoIterator<Item = (N
|
||||
rows.into_iter().filter(|(id, fill)| d.node(*id).is_some_and(|n| !super::graph::shown_paint(n, *fill).0.is_none())).collect()
|
||||
}
|
||||
|
||||
/// Store on the graph what an edit of `ids` (from `before` to `d`) leaves on the topmost visible fill and stroke of the
|
||||
/// series marks among them: a paint, weight or opacity that changed, and the paint (`paints`) and opacity (`opacities`) of
|
||||
/// the kinds of row the edit wrote, as (fill, stroke). With `top_only` the edit wrote the topmost row of each kind, which
|
||||
/// counts only when it is visible. Returns whether the stored paint changed.
|
||||
pub(super) fn capture_series_writes(
|
||||
before: &vectorcraft_doc::Document,
|
||||
d: &mut vectorcraft_doc::Document,
|
||||
ids: &[NodeId],
|
||||
paints: (bool, bool),
|
||||
opacities: (bool, bool),
|
||||
top_only: bool,
|
||||
) -> bool {
|
||||
let top_shows = |d: &vectorcraft_doc::Document, id: NodeId, kind: bool| {
|
||||
let items = d.node(id).map_or(&[][..], |n| &n.appearance.items[..]);
|
||||
items.iter().rev().find(|it| it.is_fill() == kind).is_some_and(AppearanceItem::visible)
|
||||
};
|
||||
let written = |d: &vectorcraft_doc::Document, (fill, stroke): (bool, bool)| -> Vec<(NodeId, bool)> {
|
||||
let rows = ids.iter().flat_map(|id| [fill.then_some((*id, true)), stroke.then_some((*id, false))]).flatten();
|
||||
painted_rows(d, rows.filter(|(id, kind)| !top_only || top_shows(d, *id, *kind)))
|
||||
};
|
||||
let mut opacity_rows = top_opacity_changed(before, d, ids);
|
||||
opacity_rows.extend(written(d, opacities));
|
||||
let mut rows = top_paint_changed(before, d, ids);
|
||||
rows.extend(written(d, paints));
|
||||
let kept = super::graph::capture_series_opacity(d, &opacity_rows);
|
||||
let painted = super::graph::capture_series_top_paints(d, &rows);
|
||||
kept || painted
|
||||
}
|
||||
|
||||
/// The kind of the row at `at` (fill `true`, stroke `false`) when it is the topmost visible row of that kind in `ap`.
|
||||
fn top_row_kind(ap: &Appearance, at: usize) -> Option<bool> {
|
||||
let fill = ap.items.get(at)?.is_fill();
|
||||
@@ -698,9 +731,11 @@ fn transfer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
to.appearance = ap;
|
||||
(to.opacity, to.blend, to.isolate, to.knockout, to.knockout_shape) = (src.opacity, src.blend, src.isolate, src.knockout, src.knockout_shape);
|
||||
super::object::snap_if_aligned(to);
|
||||
if !copy && let Some(n) = d.node_mut(source) {
|
||||
clear(n);
|
||||
(n.isolate, n.knockout, n.knockout_shape) = (false, Default::default(), false);
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
let changed = if copy { vec![target] } else { vec![source, target] };
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&changed));
|
||||
@@ -759,6 +794,7 @@ fn reduce_basic(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.blend = BlendMode::Normal;
|
||||
n.appearance.items.push(AppearanceItem::Stroke(st));
|
||||
}
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &both_kinds(&ids));
|
||||
@@ -818,6 +854,7 @@ fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||||
*vis = v;
|
||||
}
|
||||
super::object::snap_if_aligned(n);
|
||||
let items = &n.appearance.items;
|
||||
if let Some(fill) =
|
||||
items.get(idx).map(AppearanceItem::is_fill).filter(|f| items.iter().rposition(|it| it.is_fill() == *f && it.visible()) == Some(idx))
|
||||
@@ -878,6 +915,7 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for &i in idx.iter().rev() {
|
||||
n.appearance.remove_item(i);
|
||||
}
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
}
|
||||
let kept = super::graph::capture_series_opacity(d, &top_opacity_changed(&before, d, &ids));
|
||||
@@ -982,7 +1020,19 @@ fn show_all_hidden(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = require_targets(s, p, "appearance.showAllHidden")?;
|
||||
let before = s.doc()?.doc.clone();
|
||||
let ids = s.edit("Show All Hidden Attributes", |d, _| {
|
||||
let shown: Vec<NodeId> = roots.iter().copied().filter(|id| d.node_mut(*id).is_some_and(|n| n.appearance.show_all())).collect();
|
||||
let shown: Vec<NodeId> = roots
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| {
|
||||
d.node_mut(*id).is_some_and(|n| {
|
||||
let shown = n.appearance.show_all();
|
||||
if shown {
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
shown
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if shown.is_empty() {
|
||||
return Err(EngineError::Other("Show All Hidden Attributes: nothing is hidden".into()));
|
||||
}
|
||||
@@ -1136,6 +1186,42 @@ fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "`defaultsOnly` cannot be combined with `ids`, `reverse` or `append`"));
|
||||
}
|
||||
let attrs = s.prefs.eyedropper.attrs(p).map_err(|e| bad(C, e))?;
|
||||
// An object of a dynamic symbol instance's art selected with Direct Selection takes the clicked
|
||||
// object's solid fill and stroke, as the Eyedropper Options allow, for this instance only. A
|
||||
// gradient or pattern sampled for it, a source that is not a path, compound path or type (a
|
||||
// symbol instance's own appearance is empty), and options that pick up neither color are
|
||||
// refused, and the paint defaults stay as they are. A path of a compound path stands for the
|
||||
// compound path, which holds the paint.
|
||||
if !reverse
|
||||
&& !append
|
||||
&& !defaults_only
|
||||
&& p.get("ids").is_none()
|
||||
&& let Some((id, path)) = super::brushsym::selected_part(s)?
|
||||
{
|
||||
let d = &s.doc()?.doc;
|
||||
let src = Arc::new(d.node(super::path::compound_or_self(d, clicked)).ok_or(EngineError::NoNode(clicked))?.clone());
|
||||
let (fill, stroke) = (super::paint::proxy_paint(&src, false, None), super::paint::proxy_paint(&src, true, None));
|
||||
let a = attrs.appearance;
|
||||
if !vectorcraft_doc::symbol_overrides::takes_part_paint(&src)
|
||||
|| !(a.fill.color || a.stroke.color)
|
||||
|| (a.fill.color && !super::paint::part_takes(&fill))
|
||||
|| (a.stroke.color && !super::paint::part_takes(&stroke))
|
||||
{
|
||||
return Err(super::brushsym::part_only());
|
||||
}
|
||||
super::brushsym::edit_part(s, id, &path, "Eyedropper", |o| {
|
||||
if a.fill.color {
|
||||
o.fill = Some(fill);
|
||||
}
|
||||
if a.stroke.color {
|
||||
o.stroke = Some(stroke);
|
||||
}
|
||||
})?;
|
||||
// The clicked object becomes the Eyedropper's sample, and new art takes its paint.
|
||||
Picked::of(&src).to_defaults(s, &attrs.appearance);
|
||||
s.eyedropper_sample = Some(src);
|
||||
return Ok(json!({ "ids": [id.0] }));
|
||||
}
|
||||
let range = if !defaults_only && !reverse && !append && p.get("ids").is_none() {
|
||||
super::textedit::editing_range(s)?.filter(|(_, a, b)| a != b)
|
||||
} else {
|
||||
@@ -1343,6 +1429,7 @@ impl Picked {
|
||||
let placed = self.placed(n.geometric_bounds());
|
||||
n.appearance.items.extend(placed.items.into_iter().filter(|it| if it.is_fill() { ap.fill.color } else { ap.stroke.color }));
|
||||
n.appearance.effects.extend(placed.effects);
|
||||
super::object::snap_if_aligned(n);
|
||||
return;
|
||||
}
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
@@ -1366,6 +1453,7 @@ impl Picked {
|
||||
} else {
|
||||
focal_attrs(&mut n.appearance, &placed, ap);
|
||||
}
|
||||
super::object::snap_if_aligned(n);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
//! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects
|
||||
//! the selection this way and opens Export for Screens on its Assets tab (a UI command).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_doc::{ExportAsset, NodeId};
|
||||
|
||||
use super::edit::{roots_of, selected_roots};
|
||||
use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param};
|
||||
use super::fileio::{
|
||||
DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files,
|
||||
safe_file_name, store_screen_settings, with_screen_export_preset, write_file,
|
||||
};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param};
|
||||
|
||||
/// The longest asset name kept (characters).
|
||||
const MAX_NAME: usize = 255;
|
||||
@@ -39,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assets",
|
||||
[],
|
||||
None,
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order",
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -48,10 +53,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Assets",
|
||||
[],
|
||||
None,
|
||||
"{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
"{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
cmd!(
|
||||
"assets.add3D",
|
||||
"Export 3D Object",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}",
|
||||
has_doc,
|
||||
add_3d
|
||||
),
|
||||
cmd!(
|
||||
"assets.set3DFormat",
|
||||
"3D Export Format",
|
||||
[],
|
||||
None,
|
||||
"{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}",
|
||||
has_doc,
|
||||
set_3d_format
|
||||
),
|
||||
cmd!(
|
||||
"assets.export3D",
|
||||
"Export 3D Assets",
|
||||
[],
|
||||
None,
|
||||
"{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})",
|
||||
has_doc,
|
||||
export_3d
|
||||
),
|
||||
cmd!(
|
||||
"assets.settings.set",
|
||||
"Asset Export Settings",
|
||||
@@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if name.is_some() && sets.len() > 1 {
|
||||
return Err(bad(C, "a name names one asset: give multiple: false, or one object"));
|
||||
}
|
||||
let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id);
|
||||
// A 3D asset of the same art is another asset.
|
||||
let existing =
|
||||
|d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id);
|
||||
let d = &s.doc()?.doc;
|
||||
// Nothing new: no undo step.
|
||||
if sets.iter().all(|set| existing(d, set).is_some()) {
|
||||
@@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name());
|
||||
let id = d.alloc_id().0;
|
||||
d.assets.push(ExportAsset { id, name, nodes });
|
||||
d.assets.push(ExportAsset { id, name, nodes, format_3d: None });
|
||||
out.push(id);
|
||||
added += 1;
|
||||
}
|
||||
@@ -142,6 +176,162 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given.
|
||||
fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result<String> {
|
||||
match (p.get("format"), default) {
|
||||
(None, Some(d)) => Ok(d.to_string()),
|
||||
(Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()),
|
||||
_ => {
|
||||
let key = "format";
|
||||
Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", "))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`.
|
||||
fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
|
||||
let ids = asset_ids(p, key, cmd)?;
|
||||
for id in &ids {
|
||||
match d.asset(*id) {
|
||||
None => return Err(bad(cmd, "no such assets (see assets.list)")),
|
||||
Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn add_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.add3D";
|
||||
let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?;
|
||||
let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? };
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "select an object with a 3D effect (or give its ids)"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
let mut ids = d.paint_order(ids);
|
||||
ids.dedup();
|
||||
for id in &ids {
|
||||
let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?;
|
||||
if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) {
|
||||
let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0));
|
||||
return Err(bad(C, format!("{what} can't be exported in 3D: {e}")));
|
||||
}
|
||||
}
|
||||
let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id);
|
||||
// Nothing new: no undo step.
|
||||
if ids.iter().all(|id| existing(d, *id).is_some()) {
|
||||
return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::<Vec<_>>(), "added": 0 }));
|
||||
}
|
||||
s.edit("Export 3D Object", |d, _| {
|
||||
let (mut out, mut added) = (vec![], 0);
|
||||
for node in ids {
|
||||
if let Some(id) = existing(d, node) {
|
||||
out.push(id);
|
||||
continue;
|
||||
}
|
||||
let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name());
|
||||
let id = d.alloc_id().0;
|
||||
d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) });
|
||||
out.push(id);
|
||||
added += 1;
|
||||
}
|
||||
Ok(json!({ "assets": out, "added": added }))
|
||||
})
|
||||
}
|
||||
|
||||
fn set_3d_format(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.set3DFormat";
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = three_d_asset_ids(d, p, "assets", C)?;
|
||||
let format = format_3d_param(p, C, None)?;
|
||||
let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count();
|
||||
// Nothing changes: no undo step.
|
||||
if changed == 0 {
|
||||
return Ok(json!({ "changed": 0 }));
|
||||
}
|
||||
s.edit("3D Export Format", |d, _| {
|
||||
for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) {
|
||||
a.format_3d = Some(format.clone());
|
||||
}
|
||||
Ok(json!({ "changed": changed }))
|
||||
})
|
||||
}
|
||||
|
||||
fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "assets.export3D";
|
||||
let d = &s.doc()?.doc;
|
||||
let ids = if p.get("assets").is_some_and(|v| !v.is_null()) {
|
||||
three_d_asset_ids(d, p, "assets", C)?
|
||||
} else {
|
||||
d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect()
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)"));
|
||||
}
|
||||
let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty());
|
||||
let zip = switch_or(p, "zip", false, C)?;
|
||||
// Every file's name, compared without case: a later file that would take one gets -2, -3…
|
||||
let mut taken: HashSet<String> = HashSet::new();
|
||||
let mut files: Vec<(String, Vec<u8>)> = vec![];
|
||||
for id in ids {
|
||||
let Some(a) = d.asset(id) else { continue };
|
||||
let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D);
|
||||
let mut parts = vec![];
|
||||
for node in &a.nodes {
|
||||
let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?;
|
||||
let what = &a.name;
|
||||
parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?);
|
||||
}
|
||||
let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into());
|
||||
let mut stem = base.clone();
|
||||
let mut k = 1;
|
||||
loop {
|
||||
let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?;
|
||||
if names.iter().all(|n| !taken.contains(&n.to_lowercase())) {
|
||||
break;
|
||||
}
|
||||
k += 1;
|
||||
stem = format!("{base}-{k}");
|
||||
}
|
||||
let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?;
|
||||
taken.extend(made.iter().map(|(n, _)| n.to_lowercase()));
|
||||
files.extend(made);
|
||||
}
|
||||
let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect();
|
||||
if zip {
|
||||
let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?;
|
||||
// "-3D" keeps it apart from the picture assets' ZIP, which is named after the document.
|
||||
let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip"));
|
||||
return match folder {
|
||||
Some(dir) => {
|
||||
create_dir(dir)?;
|
||||
let path = format!("{dir}/{name}");
|
||||
write_file(&path, &archive)?;
|
||||
Ok(json!({ "path": path, "bytes": archive.len(), "files": names }))
|
||||
}
|
||||
None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })),
|
||||
};
|
||||
}
|
||||
match folder {
|
||||
Some(dir) => {
|
||||
create_dir(dir)?;
|
||||
let mut written = vec![];
|
||||
for (name, bytes) in &files {
|
||||
let path = format!("{dir}/{name}");
|
||||
write_file(&path, bytes)?;
|
||||
written.push(path);
|
||||
}
|
||||
Ok(json!({ "files": written }))
|
||||
}
|
||||
None => {
|
||||
let files: Vec<Value> =
|
||||
files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect();
|
||||
Ok(json!({ "files": files }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The asset ids of param `key`.
|
||||
fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result<Vec<u64>> {
|
||||
let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?;
|
||||
@@ -186,6 +376,7 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
"name": a.name,
|
||||
"nodes": a.nodes.iter().map(|n| n.0).collect::<Vec<_>>(),
|
||||
"bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]),
|
||||
"format3D": a.format_3d,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
@@ -227,8 +418,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() {
|
||||
q.insert("folders".into(), json!("none"));
|
||||
}
|
||||
let d = &s.doc()?.doc;
|
||||
// 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given.
|
||||
if !q.contains_key("assets") {
|
||||
let all: Vec<u64> = s.doc()?.doc.assets.iter().map(|a| a.id).collect();
|
||||
let all: Vec<u64> = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect();
|
||||
if all.is_empty() {
|
||||
return Err(bad(C, "no assets yet: collect art for export first (assets.add)"));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ use brushes::{
|
||||
brush_apply, brush_delete, brush_duplicate, brush_freehand, brush_get, brush_list, brush_move, brush_new, brush_options, brush_remove,
|
||||
brush_selection_art, brush_set_current, brush_unused, expand_brush,
|
||||
};
|
||||
use dynamic::{symbol_instance_reset, symbol_instance_set_appearance};
|
||||
pub(crate) use dynamic::{edit_part, part_only, selected_part};
|
||||
use dynamic::{symbol_instance_reset, symbol_instance_select_part, symbol_instance_set_appearance};
|
||||
use symbols::selected_instances;
|
||||
pub(crate) use symbols::{
|
||||
expand_instances_under, expandable_instances, instance_in_place, natural_size, normalize, place_xf, set_natural_size, store_symbol,
|
||||
@@ -266,6 +267,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
symbol_instance_set_appearance
|
||||
),
|
||||
cmd!(
|
||||
"symbol.instance.selectPart",
|
||||
"Select Symbol Instance Part",
|
||||
[],
|
||||
None,
|
||||
"{id, path: [i…] (as symbol.instance.setAppearance)} select one path, compound path or type object of a dynamic symbol instance's art, as a Direct Selection click on it does: the instance is selected alone, and until the selection changes paint.setFill and paint.setStroke with a solid color or none, paint.invert and paint.complement on a solid color (without ids, id or item; type whose characters differ in color is refused) and appearance.copyFrom with a solid fill and stroke (the Eyedropper, without ids, reverse or append; options that pick up neither color are refused) change that object's fill or stroke for this instance only; gradients and patterns are refused, and so are the commands that change the selected objects without ids (transparency.set, the gradient commands, object.group, edit.cut and edit.clear among them); edit.copy copies the instance whole, and selection exports, artboard.fitToSelection, object.liquify, object.isolate, library.add and select.save use the instance (docs/mcp.md); selectPart itself is refused for a static symbol, a path naming no object, a group, image or other kind of object, a hidden, locked or template object or one inside such a group, and a hidden or locked instance → {id, path}",
|
||||
has_doc,
|
||||
symbol_instance_select_part
|
||||
),
|
||||
cmd!(
|
||||
"symbol.instance.reset",
|
||||
"Reset Symbol Instance",
|
||||
|
||||
@@ -61,6 +61,62 @@ fn dynamic_instance(s: &Session, p: &Value, cmd: &str) -> Result<(NodeId, Arc<No
|
||||
Ok((id, sym.art.clone()))
|
||||
}
|
||||
|
||||
/// The selected object of a dynamic symbol instance's art (Direct Selection): the instance and the
|
||||
/// object's path in the symbol's art.
|
||||
pub(crate) fn selected_part(s: &Session) -> Result<Option<(NodeId, Vec<u32>)>> {
|
||||
Ok(s.doc()?.selection.symbol_part.as_ref().map(|sp| (sp.instance, sp.path.clone())))
|
||||
}
|
||||
|
||||
/// Change instance `id`'s own appearance for the object at `path` of its art by `f`, as one undo
|
||||
/// step named `label`; nothing is recorded when the change leaves it as it was. Replies how many
|
||||
/// objects the instance changes.
|
||||
pub(crate) fn edit_part(s: &mut Session, id: NodeId, path: &[u32], label: &str, f: impl FnOnce(&mut SymbolOverride)) -> Result<usize> {
|
||||
let list = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?.extras().symbol_overrides.clone();
|
||||
let old = list.iter().find(|o| o.path == path).cloned().unwrap_or_else(|| SymbolOverride { path: path.to_vec(), ..Default::default() });
|
||||
let mut new = old.clone();
|
||||
f(&mut new);
|
||||
if new == old {
|
||||
return Ok(list.len());
|
||||
}
|
||||
s.edit(label, |d, _| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut e = n.extras_mut();
|
||||
let list = &mut e.symbol_overrides;
|
||||
list.retain(|o| o.path != path);
|
||||
if !new.is_empty() {
|
||||
list.push(new);
|
||||
}
|
||||
Ok(list.len())
|
||||
})
|
||||
}
|
||||
|
||||
/// The refusal of anything but a solid color fill or stroke change while an object of a symbol
|
||||
/// instance's art is selected ([`selected_part`]), and of selecting any other object of the art.
|
||||
pub(crate) fn part_only() -> EngineError {
|
||||
EngineError::Other("Only the solid color fill and stroke of a path, compound path or type selected in a symbol instance can change".into())
|
||||
}
|
||||
|
||||
pub(super) fn symbol_instance_select_part(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "symbol.instance.selectPart";
|
||||
if p.get("id").is_none() {
|
||||
return Err(bad(C, "missing id"));
|
||||
}
|
||||
let (id, art) = dynamic_instance(s, p, C)?;
|
||||
let path = path_param(p, &art, C)?;
|
||||
// Direct Selection passes over hidden, locked and template objects, and over everything in a
|
||||
// group or layer that is: the instance in the document, and the object in the symbol's art.
|
||||
let editable = |n: &Node| n.visible && !n.locked && !n.is_template();
|
||||
if !s.doc()?.doc.is_editable(id) || !(0..=path.len()).all(|k| symbol_overrides::part(&art, &path[..k]).is_some_and(editable)) {
|
||||
return Err(bad(C, "the object is hidden or locked"));
|
||||
}
|
||||
if !symbol_overrides::part(&art, &path).is_some_and(symbol_overrides::takes_part_paint) {
|
||||
return Err(part_only());
|
||||
}
|
||||
let shown = json!(path);
|
||||
s.select(|_, sel| sel.set_symbol_part(id, path))?;
|
||||
Ok(json!({ "id": id.0, "path": shown }))
|
||||
}
|
||||
|
||||
/// The `path` param: child indices from the art's root, naming an object of `art`.
|
||||
fn path_param(p: &Value, art: &Node, cmd: &str) -> Result<Vec<u32>> {
|
||||
let path: Option<Vec<u32>> =
|
||||
@@ -117,16 +173,7 @@ pub(super) fn symbol_instance_set_appearance(s: &mut Session, p: &Value) -> Resu
|
||||
let d = &s.doc()?.doc;
|
||||
let old = d.node(id).and_then(|n| n.extras().symbol_overrides.iter().find(|o| o.path == path).cloned());
|
||||
let new = changed(s, p, old.unwrap_or_else(|| SymbolOverride { path: path.clone(), ..Default::default() }), C)?;
|
||||
let count = s.edit("Symbol Instance Appearance", |d, _| {
|
||||
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
|
||||
let mut e = n.extras_mut();
|
||||
let list = &mut e.symbol_overrides;
|
||||
list.retain(|o| o.path != path);
|
||||
if !new.is_empty() {
|
||||
list.push(new);
|
||||
}
|
||||
Ok(list.len())
|
||||
})?;
|
||||
let count = edit_part(s, id, &path, "Symbol Instance Appearance", |o| *o = new)?;
|
||||
Ok(json!({ "id": id.0, "overrides": count }))
|
||||
}
|
||||
|
||||
|
||||
@@ -888,6 +888,8 @@ pub(super) fn symbol_adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let radius = number_or(p, "radius", 40.0, C)?.clamp(1.0, 2000.0);
|
||||
let intensity = number_or(p, "intensity", 5.0, C)?.clamp(1.0, 10.0) / 10.0;
|
||||
let alt = switch_or(p, "alt", false, C)?;
|
||||
// Refused while an object of a dynamic instance's art is selected: the instance stays as it is.
|
||||
crate::cmd::edit::refuse_symbol_part(s)?;
|
||||
let st = s.doc()?;
|
||||
let color = match color_param(p, "color", C)? {
|
||||
Some(c) => crate::cmd::paint::new_color(s, p, c),
|
||||
|
||||
@@ -13,7 +13,7 @@ use vectorcraft_tools::distort::liquify::{Dabber, LiquifyParams, PathStroke, Sam
|
||||
use vectorcraft_tools::distort::perspective::{self as persp, PerspectiveGrid, Plane};
|
||||
use vectorcraft_tools::distort::{PinSet, arap, collect_points, mesh_for, project_node, stroke_owner, warp_from_rest, warp_node_with};
|
||||
|
||||
use super::edit::{duplicate_in, outermost, selected_roots};
|
||||
use super::edit::{duplicate_in, outermost, refuse_symbol_part, selected_roots};
|
||||
use super::{
|
||||
CommandSpec, Result, Session, bad, check_numbers, check_switches, cmd, f64_or, f64_req, finite_numbers, has_doc, has_selection, id_param,
|
||||
matrix_param, named_id, named_ids, number_or, ok, point_indices, point_param, str_param, switch_or, targets,
|
||||
@@ -730,8 +730,13 @@ fn puppet_warp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if pins.iter().chain(&moved).any(|q| !q.x.is_finite() || !q.y.is_finite()) {
|
||||
return Err(bad(C, "pins must be finite"));
|
||||
}
|
||||
let ids =
|
||||
if rest { puppet_ids(p, || Ok(s.doc()?.selection.objects.clone()))? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
|
||||
// While an object of a dynamic symbol instance's art is selected, the selection is refused, as
|
||||
// `selected_roots` refuses it.
|
||||
let selected = || {
|
||||
refuse_symbol_part(s)?;
|
||||
Ok(s.doc()?.selection.objects.clone())
|
||||
};
|
||||
let ids = if rest { puppet_ids(p, selected)? } else { outermost(&s.doc()?.doc, puppet_ids(p, || selected_roots(s))?) };
|
||||
let expand = f64_or(p, "expand", 3.0).clamp(0.0, 1000.0);
|
||||
let angles: Vec<Option<f64>> = match p.get("angles") {
|
||||
None | Some(Value::Null) => vec![None; pins.len()],
|
||||
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut)",
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (selected ruler guides go too; the Artboard tool chosen: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy → {copied, guides}",
|
||||
"{lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides)",
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected",
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
@@ -829,16 +829,38 @@ fn shared_selection(selection: vectorcraft_doc::Selection, like: Option<&crate::
|
||||
}
|
||||
|
||||
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
|
||||
///
|
||||
/// Refused with [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art
|
||||
/// is selected ([`super::brushsym::selected_part`]): only the paint commands change that object,
|
||||
/// and only its solid fill and stroke, so a command that gathers the selection here leaves the
|
||||
/// instance as it is. Copy copies the instance whole ([`copy_cut_targets`]).
|
||||
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
|
||||
refuse_symbol_part(s)?;
|
||||
let st = s.doc()?;
|
||||
Ok(roots_of(&st.doc, st.selection.in_paint_order(&st.doc)))
|
||||
}
|
||||
|
||||
/// [`super::brushsym::part_only`] while an object of a dynamic symbol instance's art is selected:
|
||||
/// for the commands that read the selected objects without [`selected_roots`]. A command that
|
||||
/// changes nothing in the document ([`Session::reading`]) reads the instance instead.
|
||||
pub(crate) fn refuse_symbol_part(s: &Session) -> Result<()> {
|
||||
if !s.reading && s.doc()?.selection.symbol_part.is_some() { Err(super::brushsym::part_only()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// [`refuse_symbol_part`] as a command's `enabled` check: the command is dimmed, and refused with
|
||||
/// [`super::brushsym::part_only`]'s message, while an object of a dynamic symbol instance's art is
|
||||
/// selected.
|
||||
pub(crate) fn no_symbol_part(s: &Session) -> std::result::Result<(), String> {
|
||||
if !s.reading && s.active().is_some_and(|st| st.selection.symbol_part.is_some()) { Err(super::brushsym::part_only().to_string()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// [`selected_roots`] for the commands that move or reshape the art (Move, Transform, Rotate…): a
|
||||
/// path of a compound path selected on its own (Group Selection, or in the isolated compound path)
|
||||
/// is an object of its own, so the hole of a ring moves without the ring (#2441). Paint and the
|
||||
/// rest still go to the compound path, which holds the paint.
|
||||
/// rest still go to the compound path, which holds the paint. Refused while an object of a symbol
|
||||
/// instance's art is selected, as [`selected_roots`] is: it moves with its instance only.
|
||||
pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
refuse_symbol_part(s)?;
|
||||
let st = s.doc()?;
|
||||
Ok(roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_, _| true))
|
||||
}
|
||||
@@ -847,8 +869,10 @@ pub(crate) fn selected_roots_to_transform(s: &Session) -> Result<Vec<NodeId>> {
|
||||
/// ([`super::inside_isolation`]): `root` itself and objects outside it are left out before the
|
||||
/// objects held by another selected object are dropped. A selected path of a compound path stands
|
||||
/// for its compound unless the compound is `root` or `members` is set (as in
|
||||
/// [`selected_roots_to_transform`]).
|
||||
/// [`selected_roots_to_transform`]). Refused while an object of a symbol instance's art is
|
||||
/// selected, as [`selected_roots`] is.
|
||||
pub(crate) fn selected_roots_inside(s: &Session, root: NodeId, members: bool) -> Result<Vec<NodeId>> {
|
||||
refuse_symbol_part(s)?;
|
||||
let st = s.doc()?;
|
||||
let ids = st.selection.in_paint_order(&st.doc).into_iter().filter(|id| super::inside_isolation(&st.doc, root, *id)).collect();
|
||||
Ok(roots_within(&st.doc, ids, |compound, _| members || compound == root))
|
||||
@@ -906,7 +930,10 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let st = s.doc()?;
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature");
|
||||
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
|
||||
// the whole path then (#2122), unless a selection tool Cmd lent them picked the anchors (#6123).
|
||||
let picked = s.lent_selection.as_ref().is_some_and(|(uid, sel)| *uid == st.uid && *sel == st.selection);
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
|
||||
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
|
||||
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
|
||||
@@ -925,6 +952,7 @@ fn copy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), false);
|
||||
}
|
||||
// With an object of a dynamic symbol instance's art selected, the instance is copied.
|
||||
let (direct, targets) = copy_cut_targets(s)?;
|
||||
let st = s.doc()?;
|
||||
// Selected ruler guides come along (#3226), as canvas guides: their layer and artboard stay
|
||||
@@ -965,6 +993,7 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.tool_id() == "artboard" {
|
||||
return super::panelcmds::artboard_copy(s, &artboard_clipboard_params(p), true);
|
||||
}
|
||||
refuse_symbol_part(s)?;
|
||||
// Ruler guides selected on their own: Copy takes them, Clear removes them.
|
||||
if s.doc()?.selection.is_empty() {
|
||||
copy(s, p)?;
|
||||
@@ -1046,6 +1075,10 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let guides = switch_or(p, "guides", false, "edit.clear")?;
|
||||
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
|
||||
// An object of a symbol instance's art can't be removed from the instance alone.
|
||||
if explicit.is_none() && s.doc()?.selection.symbol_part.is_some() {
|
||||
return Err(super::brushsym::part_only());
|
||||
}
|
||||
// Direct-selected anchors or segments: remove those, and any objects selected whole with them.
|
||||
if explicit.is_none() && (!s.doc()?.selection.anchors.is_empty() || !s.doc()?.selection.segments.is_empty()) {
|
||||
return super::path::delete_anchors(s, p);
|
||||
@@ -1277,10 +1310,11 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
for (k, n) in clip.nodes.iter().enumerate() {
|
||||
let mut c = d.reid(n);
|
||||
imported.apply(&mut c);
|
||||
let sc = vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() };
|
||||
if xf != Affine::IDENTITY {
|
||||
// A cross-document drag carries the graphic's appearance with it,
|
||||
// including the pattern phase, independently of transform-tool settings.
|
||||
c.transform(xf, vectorcraft_doc::Scaling { patterns: mode == PasteMode::Drag, ..Default::default() });
|
||||
c.transform(xf, sc);
|
||||
}
|
||||
let layer = match clip.source_layers.get(k) {
|
||||
Some(Some(name)) if remember => Some(*layers.entry(name).or_insert_with(|| layer_named(d, name))),
|
||||
@@ -1316,6 +1350,9 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
if let NodeKind::Path { live, .. } = &mut child_node.kind {
|
||||
*live = None;
|
||||
}
|
||||
if child_node.extras().pixel_aligned {
|
||||
child_node.extras_mut().pixel_aligned = false;
|
||||
}
|
||||
let insert_idx = if idx == usize::MAX { usize::MAX } else { idx.saturating_add(offset) };
|
||||
new_ids.push(d.insert(par, insert_idx, child_node)?);
|
||||
}
|
||||
@@ -1328,6 +1365,10 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
if let NodeKind::Path { live, .. } = &mut c.kind {
|
||||
*live = None;
|
||||
}
|
||||
// A path inside a compound path snaps with the compound path, whose property it takes.
|
||||
if c.extras().pixel_aligned {
|
||||
c.extras_mut().pixel_aligned = false;
|
||||
}
|
||||
new_ids.push(d.insert(par, idx, c)?);
|
||||
}
|
||||
_ => {
|
||||
@@ -1336,14 +1377,19 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
let offset = *placed.entry(outer_par).or_default();
|
||||
*placed.entry(outer_par).or_default() += 1;
|
||||
let insert_idx = if beside_idx == usize::MAX { usize::MAX } else { beside_idx.saturating_add(offset) };
|
||||
super::object::keep_pixel_aligned(&mut c, sc);
|
||||
new_ids.push(d.insert(outer_par, insert_idx, c)?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
super::object::keep_pixel_aligned(&mut c, sc);
|
||||
new_ids.push(d.insert(par, idx, c)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in &new_ids {
|
||||
super::object::keep_compound_aligned(d, *id, vectorcraft_doc::Scaling::default());
|
||||
}
|
||||
sel.set(new_ids.iter().copied());
|
||||
// Copied ruler guides (#3226): on the layer new art goes to, moved as the objects are (a
|
||||
// paste of guides alone keeps them where they were, but onto another artboard or every
|
||||
@@ -1415,7 +1461,10 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Duplicate `roots` directly above each original, transform the copies, select them
|
||||
/// ([`copy_above`]).
|
||||
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut vectorcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
|
||||
let out = copy_above(d, roots, |_, _, c| c.transform(xf, true))?;
|
||||
let out = copy_above(d, roots, |_, _, c| {
|
||||
c.transform(xf, true);
|
||||
super::object::keep_pixel_aligned(c, true.into());
|
||||
})?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,22 @@ use super::expand::has_seeded;
|
||||
use super::rasterfx;
|
||||
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, fileio, has_doc, str_param, switch_or};
|
||||
|
||||
/// The name of the `svg.filter` effect drawing `xml`: its `<filter>` id. An effect names the
|
||||
/// document's filter of that name, so `xml` that differs from the document's filter of the same
|
||||
/// name is refused.
|
||||
fn svg_filter_name(s: &Session, xml: &str, cmd: &str) -> Result<String> {
|
||||
let name = effects::svg_filter::check(xml).map_err(|e| bad(cmd, e))?;
|
||||
if let Some(held) = s.doc()?.doc.svg_filters.iter().find(|f| f.name == name)
|
||||
&& held.xml != xml.trim()
|
||||
{
|
||||
return Err(bad(
|
||||
cmd,
|
||||
format!("the document has another SVG filter named {name:?}: use svgFilter.apply, or svgFilter.define with `replace` to edit it"),
|
||||
));
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// The effect commands of the Effect menu and the Appearance panel.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -35,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effects",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}], activeItem} for the selection",
|
||||
"{ids?|id?: object ids (default: the selection), target?: \"object\"|\"contents\"} → {catalog: [{id, label, menu, params, defaults, raster, lengths: {always, absolute (while relative is false)} (distance params Scale Strokes & Effects scales)}] (installed effect plug-ins last: id plugin.<plug-in id>, menu Effect › Plug-ins), applied: [{id, effects, items: [{index, kind: fill|stroke, effects}]}] (an effect has skipped: true while Safe Mode skips it: a plug-in effect, which the object keeps and draws without), activeItem} for the selection",
|
||||
always,
|
||||
list
|
||||
),
|
||||
@@ -71,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -245,6 +261,11 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if texture_load_effect(id) {
|
||||
load_texture(&mut params, C)?;
|
||||
}
|
||||
// An SVG filter needs its <filter> element, and is named by its id.
|
||||
if id == effects::SVG_FILTER {
|
||||
let xml = params.get("xml").and_then(Value::as_str).ok_or_else(|| bad(C, "give `xml`, one SVG <filter> element"))?;
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
aligned(p, C)?;
|
||||
@@ -307,7 +328,22 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect();
|
||||
let mut applied = vec![];
|
||||
if s.active().is_some() {
|
||||
let fx = |e: &[Effect]| serde_json::to_value(e).unwrap_or(Value::Null);
|
||||
// A plug-in effect Safe Mode skips says so (`skipped`): the object keeps it.
|
||||
let fx = |e: &[Effect]| {
|
||||
Value::Array(
|
||||
e.iter()
|
||||
.map(|e| {
|
||||
let mut v = serde_json::to_value(e).unwrap_or(Value::Null);
|
||||
if vectorcraft_plugins::effect::skipped(&e.id)
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.insert("skipped".into(), json!(true));
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
};
|
||||
for id in appearance_targets(s, p)? {
|
||||
if let Some(n) = s.doc()?.doc.node(id) {
|
||||
let items: Vec<Value> = n
|
||||
@@ -521,6 +557,12 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(json!({ "ids": [] }));
|
||||
}
|
||||
}
|
||||
// An SVG filter is named by its id: another filter renames the effect.
|
||||
if edited.iter().any(|e| e == effects::SVG_FILTER)
|
||||
&& let Some(xml) = params.get("xml").and_then(Value::as_str)
|
||||
{
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
for &(id, target, index) in &targets {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
@@ -879,8 +921,40 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
changed.then_some(n)
|
||||
}
|
||||
|
||||
/// Does `n`, an object in it, or the art of a symbol placed in it hold a plug-in effect that
|
||||
/// expanding it draws: a visible one, the object's own or a visible fill's or stroke's, on an
|
||||
/// object that expands ([`expandable`]; in a symbol's art, `in_art`, every object does). A symbol's
|
||||
/// art counts where an instance of it expands. `seen` holds the symbols already looked into.
|
||||
fn holds_plugin_effect(d: &Document, n: &Node, in_art: bool, seen: &mut std::collections::HashSet<String>) -> bool {
|
||||
let plugin = |e: &Effect| e.visible && vectorcraft_plugins::effect::plugin_id(&e.id).is_some();
|
||||
let mut symbols = vec![];
|
||||
let mut holds = false;
|
||||
n.walk(&mut |c| {
|
||||
if !in_art && !expandable(c) {
|
||||
return;
|
||||
}
|
||||
holds |= c.appearance.effects.iter().any(plugin) || c.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(plugin));
|
||||
if let NodeKind::SymbolInstance { symbol, .. } = &c.kind {
|
||||
symbols.push(symbol.clone());
|
||||
}
|
||||
});
|
||||
holds
|
||||
|| symbols.into_iter().any(|name| {
|
||||
seen.insert(name.clone()) && d.symbols.iter().find(|s| s.name == name).is_some_and(|s| holds_plugin_effect(d, &s.art, true, seen))
|
||||
})
|
||||
}
|
||||
|
||||
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let roots = appearance_targets(s, p)?;
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
let d = &s.doc()?.doc;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
}
|
||||
}
|
||||
let failed = vectorcraft_render::decode_failures();
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
|
||||
@@ -407,8 +407,9 @@ fn expand_fills(d: &mut Document, l: &Node, to: GradientTo) -> Option<Node> {
|
||||
let mut expanded = false;
|
||||
for item in &l.appearance.items {
|
||||
// Items that paint nothing go; a gradient that can't be expanded stays.
|
||||
let art = gradient_fill(item).and_then(|(f, g)| {
|
||||
let (fl, path, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let art = gradient_fill(item).and_then(|_| {
|
||||
let (fl, path, bounds, moved) = fill_shape(l, item, &path, bounds)?;
|
||||
let (f, g) = gradient_fill(&moved)?;
|
||||
gradient_art(d, &fl, &path, bounds, f, g, to)
|
||||
});
|
||||
let Some(art) = art else {
|
||||
@@ -470,29 +471,39 @@ fn gradient_art(d: &mut Document, l: &Node, path: &PathData, bounds: Rect, f: &F
|
||||
/// path (a plain path then), and every geometry effect of its own removed. `None` when it has no
|
||||
/// visible geometry effect.
|
||||
fn reshaped(l: &Node) -> Option<Node> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry, is_geometry};
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry, is_geometry};
|
||||
if !has_geometry(&l.appearance.effects) {
|
||||
return None;
|
||||
}
|
||||
let (path, _) = node_path(l)?;
|
||||
let g = apply_geometry_with(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
|
||||
let (g, carry) = apply_geometry_carrying(&l.appearance.effects, &path, path.bounds()?, &GeomContext::of(l));
|
||||
let mut m = with_path(l, g);
|
||||
// Its gradients move with the outline, as the canvas paints them.
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
}
|
||||
m.appearance.effects.retain(|e| !is_geometry(&e.id));
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Path `l` (outline `path`, box `bounds`) as its fill `item` paints it on the canvas, with its
|
||||
/// outline and box: when the fill has visible geometry effects, `l` with the outline they make of
|
||||
/// `path` as a plain path, and that outline's box.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_with, has_geometry};
|
||||
/// outline, box and fill: when the fill has visible geometry effects, `l` with the outline they
|
||||
/// make of `path` as a plain path, that outline's box, and the fill with its gradient moved along
|
||||
/// with the outline.
|
||||
fn fill_shape(l: &Node, item: &AppearanceItem, path: &PathData, bounds: Rect) -> Option<(Node, PathData, Rect, AppearanceItem)> {
|
||||
use vectorcraft_render::effects::{GeomContext, apply_geometry_carrying, has_geometry};
|
||||
if !has_geometry(item.effects()) {
|
||||
return Some((l.clone(), path.clone(), bounds));
|
||||
return Some((l.clone(), path.clone(), bounds, item.clone()));
|
||||
}
|
||||
// In the fill's context, as drawing runs them: a plug-in effect is told which fill it is on.
|
||||
let outline = apply_geometry_with(item.effects(), path, bounds, &GeomContext::of(l).item(item));
|
||||
let (outline, carry) = apply_geometry_carrying(item.effects(), path, bounds, &GeomContext::of(l).item(item));
|
||||
let b = outline.bounds()?;
|
||||
Some((with_path(l, outline.clone()), outline, b))
|
||||
let mut one = Appearance { items: vec![item.clone()], ..Default::default() };
|
||||
if carry.moves(&one) {
|
||||
carry.apply(&mut one);
|
||||
}
|
||||
let moved = one.items.pop().unwrap_or_else(|| item.clone());
|
||||
Some((with_path(l, outline.clone()), outline, b, moved))
|
||||
}
|
||||
|
||||
/// Path or compound path `l` with `path` as its path, a plain path.
|
||||
@@ -809,7 +820,7 @@ fn expand_patterns_where(d: &mut Document, l: &Node, which: impl Fn(&Document, &
|
||||
later -= usize::from(fill.is_some());
|
||||
let room = if later > 0 { left.saturating_sub(l.count() + 1) } else { left };
|
||||
let tiles = fill.filter(|_| !full).and_then(|(f, def, xf)| {
|
||||
let (fl, _, bounds) = fill_shape(l, item, &path, bounds)?;
|
||||
let (fl, _, bounds, _) = fill_shape(l, item, &path, bounds)?;
|
||||
let Some(t) = pattern_tiles(d, &def, xf, bounds, room) else {
|
||||
full = true;
|
||||
return None;
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
|
||||
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
|
||||
//! - `metafile`: EMF and WMF export, open and place.
|
||||
//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`).
|
||||
//!
|
||||
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
|
||||
//! agents query `document.formats`.
|
||||
@@ -32,6 +33,7 @@ mod export;
|
||||
pub(crate) mod imagemap;
|
||||
mod legacy_ai;
|
||||
mod load;
|
||||
mod mesh3d;
|
||||
pub(crate) use load::{MAX_RASTER_ALLOC, size_note};
|
||||
mod heifread;
|
||||
mod jp2read;
|
||||
@@ -74,6 +76,8 @@ pub use load::{
|
||||
read_for_open, texture_image,
|
||||
};
|
||||
pub use load::{losses_summary, overwrite_losses};
|
||||
pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label};
|
||||
pub(crate) use mesh3d::{mesh_file_names, mesh_files};
|
||||
pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name};
|
||||
pub(crate) use native::preview_png;
|
||||
pub use native::with_compression_pref;
|
||||
@@ -125,7 +129,9 @@ pub(crate) use access::{drive_share_path, share_path, touched};
|
||||
pub(crate) use access::{may_read, may_write};
|
||||
pub use commands::specs;
|
||||
pub use export_names::artboard_file_names;
|
||||
pub(crate) use export_names::{default_name, merge, unique_file_names, with_black_output, write_or_return};
|
||||
pub(crate) use export_names::{
|
||||
artboard_file_names_keeping_dots, default_name, merge, unique_file_names_keeping_dots, with_black_output, write_or_return,
|
||||
};
|
||||
use export_names::{distinct_names_by, write_encoded};
|
||||
use formats::{ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, import_name, writable};
|
||||
pub use formats::{
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_cad::{ColorDepth, DxfOptions, DxfVersion, Preserve, RasterFormat};
|
||||
use vectorcraft_cad::{ColorDepth, DxfOptions, DxfPreset, DxfVersion, Preserve, RasterFormat};
|
||||
use vectorcraft_doc::{Document, Unit};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export DXF",
|
||||
[],
|
||||
None,
|
||||
"{path?, version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
|
||||
"{path?, dxfPreset?: custom (default)|cnc (CNC / CAM: version 2000, the oldest version that has both lightweight polylines and lineweights; unit mm, scale 1; colors 256, all 2000 holds; preserve editability and alterPaths false, so strokes stay center lines; outlineText true, so type is paths; rasterFormat png; the options given override it), version?: R12|R13|R14|2000|2004|2007|2010|2013|2018 (default), unit?: mm (default; any unit name: pt, in, cm…), scale?: 1 (drawing units per unit), scaleLineweights?: false, colors?: 8|16|256|true (default; true colour needs 2004+, else 256), rasterFormat?: png (default)|jpeg (placed images, written next to the file), preserve?: appearance (default: type as outlines; strokes a CAD line can't draw as filled outlines; brush art)|editability (type as text; every stroke a line with its lineweight and dashes), alterPaths?: false (strokes a CAD line can't draw as their filled outlines, with editability too; also strokes wider than 2.11 mm, and every stroke in R13 and R14), outlineText?: false, selectedOnly?: false (the selected objects, in their layers), artboard?: 0 (the origin is its bottom-left corner), useArtboards?: true (one drawing per chosen artboard, artboards?/range?, holding the art over it: {stem}-{artboard}.dxf) | false (the origin at the art's bounds)} → {path, bytes, warnings, files?, linked?: [image files]}; no path → {dataBase64, …, linked?: [{name, dataBase64}]}. Layers become DXF layers (hidden: off; locked; non-printing: not plotted); paths become polylines and splines, fills their outlines (which laser and cutter software reads) and solid hatches. Same as document.export {format: dxf}",
|
||||
has_doc,
|
||||
export_dxf
|
||||
)]
|
||||
@@ -33,6 +33,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
|
||||
super::ARTBOARDS,
|
||||
super::RANGE,
|
||||
super::USE_ARTBOARDS,
|
||||
FormatOption {
|
||||
name: "dxfPreset",
|
||||
ty: "string",
|
||||
default: "\"custom\"",
|
||||
description: "custom, or cnc (CNC / CAM: version 2000, mm, 256 colors, editability, type as outlines, PNG images); the options given override it",
|
||||
},
|
||||
FormatOption { name: "version", ty: "string", default: "\"2018\"", description: "R12, R13, R14, 2000, 2004, 2007, 2010, 2013 or 2018" },
|
||||
FormatOption {
|
||||
name: "unit",
|
||||
@@ -120,6 +126,7 @@ impl Unsupported {
|
||||
struct Params {
|
||||
#[serde(flatten)]
|
||||
boards: ArtboardPick,
|
||||
dxf_preset: Option<String>,
|
||||
version: Option<Value>,
|
||||
unit: Option<String>,
|
||||
scale: Option<f64>,
|
||||
@@ -150,7 +157,8 @@ fn choice<T>(field: &str, v: Option<String>, read: impl Fn(&str) -> Option<T>, d
|
||||
/// The DXF options in `p` (the region and crop are set per drawing) and the artboard choice.
|
||||
fn parse(p: &Value) -> Result<(DxfOptions, ArtboardPick)> {
|
||||
let q: Params = if p.is_object() { Params::deserialize(p).map_err(|e| bad(C, format!("DXF options: {e}")))? } else { Params::default() };
|
||||
let d = DxfOptions::default();
|
||||
// The options a preset sets are the defaults of those `p` leaves out.
|
||||
let d = choice("dxfPreset", q.dxf_preset, DxfPreset::from_id, DxfPreset::Custom, "custom or cnc")?.options();
|
||||
let scale = q.scale.unwrap_or(d.scale);
|
||||
if !(scale.is_finite() && vectorcraft_cad::SCALE_RANGE.contains(&scale)) {
|
||||
return Err(bad(C, format!("DXF scale must be a positive number up to {}, not {scale}", vectorcraft_cad::SCALE_RANGE.end())));
|
||||
@@ -181,6 +189,11 @@ pub fn check(p: &Value) -> std::result::Result<(), String> {
|
||||
parse(p).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// The DXF options in `p` (the region and crop left at their defaults).
|
||||
pub fn options(p: &Value) -> std::result::Result<DxfOptions, String> {
|
||||
parse(p).map(|(o, _)| o).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Encode `doc` as DXF: one drawing whose origin is the chosen artboard's bottom-left corner
|
||||
/// (default the first), or with `use_artboards` one drawing per chosen artboard (default all)
|
||||
/// holding the art over it.
|
||||
|
||||
@@ -214,10 +214,11 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::
|
||||
}
|
||||
|
||||
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
|
||||
/// layers are guides and are left out.
|
||||
/// layers are guides and are left out. With an object of a dynamic symbol instance's art selected,
|
||||
/// the instance is exported whole.
|
||||
pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
|
||||
let ids = edit::selected_roots(s)?;
|
||||
let st = s.doc()?;
|
||||
let ids = edit::roots_of(&st.doc, st.selection.in_paint_order(&st.doc));
|
||||
let is_template = |id| st.doc.node(id).is_some_and(|l| matches!(l.kind, NodeKind::Layer { template: true, .. }));
|
||||
// On a template layer or sublayer, at any depth.
|
||||
let on_template = |id| st.doc.ancestry(id).is_some_and(|a| a.into_iter().any(is_template));
|
||||
|
||||
@@ -5,19 +5,46 @@ use super::{Encoded, EngineError, Result, Session, Value, json};
|
||||
/// `-`, `Artboard-N` when unnamed, and `-2`, `-3`… after a name already used (compared without
|
||||
/// case: `Icon` and `icon` are one file on most desktop file systems).
|
||||
pub fn artboard_file_names(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names(boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1))))
|
||||
unique_file_names(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// [`artboard_file_names`] with the dots of a name kept (`example.artboard`), for Export for Screens,
|
||||
/// which puts every stem through `safe_file_name` after this (trailing periods, device names).
|
||||
pub(crate) fn artboard_file_names_keeping_dots(doc: &vectorcraft_doc::Document, boards: &[usize]) -> Vec<String> {
|
||||
unique_file_names_keeping_dots(artboard_names(doc, boards))
|
||||
}
|
||||
|
||||
/// Each of `boards`' name, with the file name to use when it has none.
|
||||
fn artboard_names<'a>(doc: &'a vectorcraft_doc::Document, boards: &'a [usize]) -> impl Iterator<Item = (&'a str, String)> {
|
||||
boards.iter().map(|&b| (doc.artboards.get(b).map_or("", |a| a.name.as_str()), format!("Artboard-{}", b + 1)))
|
||||
}
|
||||
|
||||
/// File-name parts for `(name, fallback)` pairs (artboards, assets): the name with unsafe
|
||||
/// characters as `-`, the fallback when it is empty, and `-2`, `-3`… after a name already used
|
||||
/// (compared without case).
|
||||
pub(crate) fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
fn unique_file_names<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '-' }).collect();
|
||||
if base.is_empty() { fallback } else { base }
|
||||
}))
|
||||
}
|
||||
|
||||
/// [`unique_file_names`] keeping `.` in a name (an artboard called `example.artboard` exports as
|
||||
/// `example.artboard.png`, #6143). A name of only dots (`.` and `..` name other folders) takes the
|
||||
/// fallback, and leading dots are dashes (`.draft` would be a hidden file); the caller still runs
|
||||
/// each stem through `safe_file_name`, which drops trailing periods and guards Windows device names.
|
||||
pub(crate) fn unique_file_names_keeping_dots<'a>(names: impl IntoIterator<Item = (&'a str, String)>) -> Vec<String> {
|
||||
distinct_names(names.into_iter().map(|(name, fallback)| {
|
||||
let base: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' }).collect();
|
||||
if base.trim_matches('.').is_empty() {
|
||||
return fallback;
|
||||
}
|
||||
// A leading dot would hide the file or folder on Unix-like systems: it is a dash, as before.
|
||||
let rest = base.trim_start_matches('.');
|
||||
format!("{}{rest}", "-".repeat(base.len() - rest.len()))
|
||||
}))
|
||||
}
|
||||
|
||||
/// `names` with `-2`, `-3`… after a name already used (compared without case).
|
||||
pub(super) fn distinct_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
|
||||
distinct_names_by(names, str::to_lowercase)
|
||||
|
||||
@@ -29,6 +29,8 @@ pub struct Loaded {
|
||||
/// A PDF, or a `.ai` or `.ait` that holds a PDF, read through the editor's own copy of its art
|
||||
/// that it carries (its layers), not from its PDF part alone.
|
||||
pub editing_data: bool,
|
||||
/// Whether the native source declared a PDF; other formats carry no native save preference.
|
||||
pub native_pdf_compatible: Option<bool>,
|
||||
}
|
||||
|
||||
impl Loaded {
|
||||
@@ -341,6 +343,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
let mut converted = false;
|
||||
let mut preview_only = false;
|
||||
let mut editing_data = false;
|
||||
let mut native_pdf_compatible = None;
|
||||
let (mut doc, mut warnings, restored) = match format.id {
|
||||
// EPS, .ps and PostScript .ai: the document our EPS files carry, else the PostScript read.
|
||||
_ if matches!(format.id, "eps" | "ps") || vectorcraft_pdf::is_postscript(bytes) => {
|
||||
@@ -353,6 +356,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
}
|
||||
"vectorcraft" | "template" => {
|
||||
let (d, info, warnings) = native_file(bytes)?;
|
||||
native_pdf_compatible = Some(info.pdf_compatible);
|
||||
converted = info.is_old() || super::extension(name) == vectorcraft_format::LEGACY_EXTENSION;
|
||||
(d, warnings, false)
|
||||
}
|
||||
@@ -500,7 +504,7 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
||||
doc.title = title;
|
||||
}
|
||||
warnings.extend(doc.images.values().filter_map(|b| size_note(&b.bytes)));
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data })
|
||||
Ok(Loaded { doc, format, warnings, restored, converted, preview_only, editing_data, native_pdf_compatible })
|
||||
}
|
||||
|
||||
/// Import SVG text, reading the files its images link to (relative links from `folder`, the SVG's
|
||||
@@ -593,7 +597,7 @@ pub fn open_template(s: &mut Session, name: &str, bytes: &[u8], path: Option<&st
|
||||
/// Make a loaded file the new active document; `links` is what became of its linked files, and
|
||||
/// `opts` are the options it was read with.
|
||||
fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<String>, opts: &LoadOptions, as_template: bool) -> Result<Value> {
|
||||
let Loaded { mut doc, format, warnings, restored, converted, .. } = loaded.with_grid_prefs(&s.prefs);
|
||||
let Loaded { mut doc, format, warnings, restored, converted, native_pdf_compatible, .. } = loaded.with_grid_prefs(&s.prefs);
|
||||
// EPS now has an ordinary Save path. Its preview, partial-program and damaged-editing
|
||||
// fallbacks must ask before overwriting the richer source they could not fully read.
|
||||
let losses: Vec<String> = warnings
|
||||
@@ -630,6 +634,9 @@ fn open_loaded(s: &mut Session, loaded: Loaded, links: Resolved, path: Option<St
|
||||
let converted = saves_back && converted && s.prefs.append_converted;
|
||||
let index = s.add_document(doc, path);
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(pdf) = native_pdf_compatible {
|
||||
st.save_options.insert("pdfCompatible".into(), json!(pdf));
|
||||
}
|
||||
if saves_back {
|
||||
st.format = format.id;
|
||||
// An SVG that reopened as the full document saves as one again: Save keeps Preserve
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//! 3D objects as mesh files, for a 3D asset of Window › Asset Export (`assets.export3D`): OBJ with
|
||||
//! its MTL materials, glTF 2.0 (one `.gltf` file, its buffer embedded as a data URI), USDA and
|
||||
//! USDZ (the USDA layer in an uncompressed ZIP, its data on a 64-byte boundary).
|
||||
//!
|
||||
//! Each solid of the object ([`vectorcraft_render::effects::three_d_meshes`]) is its own mesh, with a
|
||||
//! material in the color it is painted (solids of one color share it). Positions are y up and
|
||||
//! about the point the object turns about: in points in OBJ and USD (whose layer says how many
|
||||
//! meters a unit is), in meters in glTF, as its specification asks. glTF and USD colors are linear,
|
||||
//! as their specifications ask; MTL colors are the display colors. Materials are two-sided, since
|
||||
//! an open solid or a turned plane is seen from both sides.
|
||||
//!
|
||||
//! Formats: glTF 2.0 <https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html>, USD
|
||||
//! <https://openusd.org/release/index.html> (USDZ: <https://openusd.org/release/spec_usdz.html>).
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_render::effects::SolidMesh;
|
||||
|
||||
/// The formats a 3D asset exports to, as `assets.set3DFormat` names them: USDA first, the default.
|
||||
pub const FORMATS_3D: [&str; 4] = ["usda", "usdz", "gltf", "obj"];
|
||||
/// The format a new 3D asset exports to.
|
||||
pub const DEFAULT_FORMAT_3D: &str = "usda";
|
||||
/// The meters in a point (1/72 inch).
|
||||
const METERS_PER_POINT: f64 = 0.0254 / 72.0;
|
||||
|
||||
/// The label of `format` for menus: `USDA`, `USDZ`, `glTF`, `OBJ`.
|
||||
pub fn format_3d_label(format: &str) -> &'static str {
|
||||
match format {
|
||||
"usdz" => "USDZ",
|
||||
"gltf" => "glTF",
|
||||
"obj" => "OBJ",
|
||||
_ => "USDA",
|
||||
}
|
||||
}
|
||||
|
||||
/// `stem` as an OBJ file name: whitespace and `#` become `_`, so the `mtllib` line names the MTL
|
||||
/// file as one word.
|
||||
fn obj_stem(stem: &str) -> String {
|
||||
stem.chars().map(|c| if c.is_whitespace() || c == '#' { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
/// The names of the files [`mesh_files`] writes for `stem` in `format`.
|
||||
pub(crate) fn mesh_file_names(stem: &str, format: &str) -> Result<Vec<String>, String> {
|
||||
match format {
|
||||
"obj" => {
|
||||
let stem = obj_stem(stem);
|
||||
Ok(vec![format!("{stem}.obj"), format!("{stem}.mtl")])
|
||||
}
|
||||
"gltf" | "usda" | "usdz" => Ok(vec![format!("{stem}.{format}")]),
|
||||
_ => {
|
||||
let key = "format";
|
||||
Err(format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The files of a 3D object named `stem` in `format` ([`FORMATS_3D`]): `(file name, bytes)`, the
|
||||
/// OBJ with its MTL after it.
|
||||
pub(crate) fn mesh_files(stem: &str, format: &str, parts: &[SolidMesh]) -> Result<Vec<(String, Vec<u8>)>, String> {
|
||||
let names = mesh_file_names(stem, format)?;
|
||||
let bytes = match format {
|
||||
"obj" => {
|
||||
let (obj, mtl) = obj(&obj_stem(stem), parts);
|
||||
vec![obj.into_bytes(), mtl.into_bytes()]
|
||||
}
|
||||
"gltf" => vec![gltf(stem, parts).into_bytes()],
|
||||
"usda" => vec![usda(stem, parts).into_bytes()],
|
||||
_ => vec![super::zip::store_aligned(&[(format!("{stem}.usda"), usda(stem, parts).into_bytes())], 64)?],
|
||||
};
|
||||
Ok(names.into_iter().zip(bytes).collect())
|
||||
}
|
||||
|
||||
/// The distinct colors of `parts` in order, and each part's index among them.
|
||||
fn materials(parts: &[SolidMesh]) -> (Vec<[f32; 4]>, Vec<usize>) {
|
||||
let mut colors: Vec<[f32; 4]> = vec![];
|
||||
let index = parts
|
||||
.iter()
|
||||
.map(|p| {
|
||||
colors.iter().position(|c| *c == p.color).unwrap_or_else(|| {
|
||||
colors.push(p.color);
|
||||
colors.len() - 1
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
(colors, index)
|
||||
}
|
||||
|
||||
/// An sRGB channel (0..1) as linear light.
|
||||
fn linear(c: f32) -> f32 {
|
||||
if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) }
|
||||
}
|
||||
|
||||
/// A number as text: the shortest decimal that reads back as the same value.
|
||||
fn num(v: f64) -> String {
|
||||
format!("{v}")
|
||||
}
|
||||
|
||||
fn obj(stem: &str, parts: &[SolidMesh]) -> (String, String) {
|
||||
let (colors, material) = materials(parts);
|
||||
let mut o = String::new();
|
||||
let _ = writeln!(o, "mtllib {stem}.mtl");
|
||||
let _ = writeln!(o, "o {stem}");
|
||||
for p in parts {
|
||||
for v in &p.mesh.positions {
|
||||
let _ = writeln!(o, "v {} {} {}", num(v[0]), num(v[1]), num(v[2]));
|
||||
}
|
||||
}
|
||||
for p in parts {
|
||||
for n in &p.mesh.normals {
|
||||
let _ = writeln!(o, "vn {} {} {}", num(n[0]), num(n[1]), num(n[2]));
|
||||
}
|
||||
}
|
||||
let mut base = 1usize;
|
||||
for (k, p) in parts.iter().enumerate() {
|
||||
let _ = writeln!(o, "g Solid_{}", k + 1);
|
||||
let _ = writeln!(o, "usemtl Material_{}", material.get(k).copied().unwrap_or(0) + 1);
|
||||
for t in &p.mesh.triangles {
|
||||
let [a, b, c] = t.map(|i| i as usize + base);
|
||||
let _ = writeln!(o, "f {a}//{a} {b}//{b} {c}//{c}");
|
||||
}
|
||||
base += p.mesh.positions.len();
|
||||
}
|
||||
let mut m = String::new();
|
||||
for (k, c) in colors.iter().enumerate() {
|
||||
let _ = writeln!(m, "newmtl Material_{}", k + 1);
|
||||
let _ = writeln!(m, "Kd {} {} {}", num(f64::from(c[0])), num(f64::from(c[1])), num(f64::from(c[2])));
|
||||
let _ = writeln!(m, "d {}", num(f64::from(c[3])));
|
||||
let _ = writeln!(m, "illum 1");
|
||||
}
|
||||
(o, m)
|
||||
}
|
||||
|
||||
fn gltf(stem: &str, parts: &[SolidMesh]) -> String {
|
||||
let (colors, material) = materials(parts);
|
||||
let mut buffer: Vec<u8> = vec![];
|
||||
let (mut views, mut accessors, mut primitives) = (vec![], vec![], vec![]);
|
||||
let mut view = |buffer: &mut Vec<u8>, bytes: Vec<u8>, target: u32| -> usize {
|
||||
views.push(json!({ "buffer": 0, "byteOffset": buffer.len(), "byteLength": bytes.len(), "target": target }));
|
||||
buffer.extend(bytes);
|
||||
views.len() - 1
|
||||
};
|
||||
for (k, p) in parts.iter().enumerate() {
|
||||
let floats = |v: &[[f64; 3]], scale: f64| v.iter().flatten().flat_map(|x| ((x * scale) as f32).to_le_bytes()).collect::<Vec<u8>>();
|
||||
let (lo, hi) = p.mesh.bounds().unwrap_or_default();
|
||||
let positions = view(&mut buffer, floats(&p.mesh.positions, METERS_PER_POINT), 34962);
|
||||
let normals = view(&mut buffer, floats(&p.mesh.normals, 1.0), 34962);
|
||||
let indices = view(&mut buffer, p.mesh.triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(), 34963);
|
||||
let count = p.mesh.positions.len();
|
||||
let at = accessors.len();
|
||||
accessors.push(json!({
|
||||
"bufferView": positions, "componentType": 5126, "count": count, "type": "VEC3",
|
||||
"min": lo.map(|v| (v * METERS_PER_POINT) as f32), "max": hi.map(|v| (v * METERS_PER_POINT) as f32),
|
||||
}));
|
||||
accessors.push(json!({ "bufferView": normals, "componentType": 5126, "count": count, "type": "VEC3" }));
|
||||
accessors.push(json!({ "bufferView": indices, "componentType": 5125, "count": p.mesh.triangles.len() * 3, "type": "SCALAR" }));
|
||||
primitives.push(json!({
|
||||
"attributes": { "POSITION": at, "NORMAL": at + 1 },
|
||||
"indices": at + 2,
|
||||
"material": material.get(k).copied().unwrap_or(0),
|
||||
}));
|
||||
}
|
||||
let materials: Vec<Value> = colors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, c)| {
|
||||
let mut m = json!({
|
||||
"name": format!("Material_{}", k + 1),
|
||||
"pbrMetallicRoughness": {
|
||||
"baseColorFactor": [linear(c[0]), linear(c[1]), linear(c[2]), c[3]],
|
||||
"metallicFactor": 0.0,
|
||||
"roughnessFactor": 1.0,
|
||||
},
|
||||
"doubleSided": true,
|
||||
});
|
||||
if c[3] < 1.0 {
|
||||
m["alphaMode"] = json!("BLEND");
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
let doc = json!({
|
||||
"asset": { "version": "2.0", "generator": "VectorCraft" },
|
||||
"scene": 0,
|
||||
"scenes": [{ "nodes": [0] }],
|
||||
"nodes": [{ "name": stem, "mesh": 0 }],
|
||||
"meshes": [{ "name": stem, "primitives": primitives }],
|
||||
"materials": materials,
|
||||
"buffers": [{
|
||||
"byteLength": buffer.len(),
|
||||
"uri": format!("data:application/octet-stream;base64,{}", vectorcraft_format::base64_encode(&buffer)),
|
||||
}],
|
||||
"bufferViews": views,
|
||||
"accessors": accessors,
|
||||
});
|
||||
serde_json::to_string_pretty(&doc).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `name` as a USD prim name: letters, digits and `_`, not starting with a digit.
|
||||
fn prim_name(name: &str) -> String {
|
||||
let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect();
|
||||
if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) {
|
||||
out.insert(0, '_');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn usda(stem: &str, parts: &[SolidMesh]) -> String {
|
||||
let (colors, material) = materials(parts);
|
||||
let root = prim_name(stem);
|
||||
let triple = |v: [f64; 3]| format!("({}, {}, {})", num(v[0]), num(v[1]), num(v[2]));
|
||||
let list = |items: Vec<String>| items.join(", ");
|
||||
let mut u = String::new();
|
||||
let _ = writeln!(u, "#usda 1.0");
|
||||
let _ = writeln!(u, "(\n defaultPrim = \"{root}\"\n metersPerUnit = {}\n upAxis = \"Y\"\n)\n", METERS_PER_POINT);
|
||||
let _ = writeln!(u, "def Xform \"{root}\"\n{{");
|
||||
for (k, p) in parts.iter().enumerate() {
|
||||
let (lo, hi) = p.mesh.bounds().unwrap_or_default();
|
||||
let m = material.get(k).copied().unwrap_or(0) + 1;
|
||||
let _ = writeln!(u, " def Mesh \"Solid_{}\" (\n prepend apiSchemas = [\"MaterialBindingAPI\"]\n )\n {{", k + 1);
|
||||
let _ = writeln!(u, " uniform bool doubleSided = 1");
|
||||
let _ = writeln!(u, " float3[] extent = [{}, {}]", triple(lo), triple(hi));
|
||||
let _ = writeln!(u, " int[] faceVertexCounts = [{}]", list(vec!["3".to_string(); p.mesh.triangles.len()]));
|
||||
let _ = writeln!(u, " int[] faceVertexIndices = [{}]", list(p.mesh.triangles.iter().flatten().map(u32::to_string).collect()));
|
||||
let _ = writeln!(u, " rel material:binding = </{root}/Materials/Material_{m}>");
|
||||
let _ = writeln!(
|
||||
u,
|
||||
" normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )",
|
||||
list(p.mesh.normals.iter().map(|n| triple(*n)).collect())
|
||||
);
|
||||
let _ = writeln!(u, " point3f[] points = [{}]", list(p.mesh.positions.iter().map(|v| triple(*v)).collect()));
|
||||
let c = p.color;
|
||||
let _ = writeln!(
|
||||
u,
|
||||
" color3f[] primvars:displayColor = [({}, {}, {})] (\n interpolation = \"constant\"\n )",
|
||||
linear(c[0]),
|
||||
linear(c[1]),
|
||||
linear(c[2])
|
||||
);
|
||||
let _ = writeln!(u, " float[] primvars:displayOpacity = [{}] (\n interpolation = \"constant\"\n )", c[3]);
|
||||
let _ = writeln!(u, " uniform token subdivisionScheme = \"none\"\n }}\n");
|
||||
}
|
||||
let _ = writeln!(u, " def Scope \"Materials\"\n {{");
|
||||
for (k, c) in colors.iter().enumerate() {
|
||||
let m = k + 1;
|
||||
let _ = writeln!(u, " def Material \"Material_{m}\"\n {{");
|
||||
let _ = writeln!(u, " token outputs:surface.connect = </{root}/Materials/Material_{m}/Surface.outputs:surface>\n");
|
||||
let _ = writeln!(u, " def Shader \"Surface\"\n {{");
|
||||
let _ = writeln!(u, " uniform token info:id = \"UsdPreviewSurface\"");
|
||||
let _ = writeln!(u, " color3f inputs:diffuseColor = ({}, {}, {})", linear(c[0]), linear(c[1]), linear(c[2]));
|
||||
let _ = writeln!(u, " float inputs:opacity = {}", c[3]);
|
||||
let _ = writeln!(u, " float inputs:roughness = 1");
|
||||
let _ = writeln!(u, " token outputs:surface\n }}\n }}");
|
||||
}
|
||||
let _ = writeln!(u, " }}\n}}");
|
||||
u
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,130 @@
|
||||
use serde_json::Value;
|
||||
use vectorcraft_render::effects::{Mesh, SolidMesh};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A 10 × 10 square facing the viewer: two triangles, each with corners of its own.
|
||||
fn square(color: [f32; 4]) -> SolidMesh {
|
||||
let [a, b, c, d] = [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]];
|
||||
let mesh = Mesh { positions: vec![a, b, c, a, c, d], normals: vec![[0.0, 0.0, 1.0]; 6], triangles: vec![[0, 1, 2], [3, 4, 5]] };
|
||||
SolidMesh { mesh, color }
|
||||
}
|
||||
|
||||
const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0];
|
||||
const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 0.5];
|
||||
|
||||
fn text(files: &[(String, Vec<u8>)], name: &str) -> String {
|
||||
let (_, bytes) = files.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name}"));
|
||||
String::from_utf8(bytes.clone()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obj_numbers_corners_across_solids_and_names_its_materials() {
|
||||
let files = mesh_files("Box", "obj", &[square(RED), square(BLUE), square(RED)]).unwrap();
|
||||
assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["Box.obj", "Box.mtl"]);
|
||||
let obj = text(&files, "Box.obj");
|
||||
assert!(obj.starts_with("mtllib Box.mtl\no Box\n"));
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 18);
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("vn ")).count(), 18);
|
||||
// The third solid's last triangle uses corners 13 to 18; red solids share Material_1.
|
||||
assert_eq!(obj.lines().rfind(|l| l.starts_with("f ")), Some("f 16//16 17//17 18//18"));
|
||||
assert_eq!(obj.lines().filter(|l| l.starts_with("usemtl ")).collect::<Vec<_>>(), ["usemtl Material_1", "usemtl Material_2", "usemtl Material_1"]);
|
||||
let mtl = text(&files, "Box.mtl");
|
||||
assert_eq!(mtl.matches("newmtl").count(), 2);
|
||||
assert!(mtl.contains("newmtl Material_2\nKd 0 0 1\nd 0.5\n"), "{mtl}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_holds_its_buffer_and_every_accessor_fits_it() {
|
||||
let files = mesh_files("Box", "gltf", &[square(RED), square(BLUE)]).unwrap();
|
||||
let g: Value = serde_json::from_str(&text(&files, "Box.gltf")).unwrap();
|
||||
assert_eq!(g["asset"]["version"], "2.0");
|
||||
let uri = g["buffers"][0]["uri"].as_str().unwrap();
|
||||
let data = vectorcraft_format::base64_decode(uri.strip_prefix("data:application/octet-stream;base64,").unwrap()).unwrap();
|
||||
assert_eq!(g["buffers"][0]["byteLength"].as_u64(), Some(data.len() as u64));
|
||||
for v in g["bufferViews"].as_array().unwrap() {
|
||||
assert!(v["byteOffset"].as_u64().unwrap() + v["byteLength"].as_u64().unwrap() <= data.len() as u64);
|
||||
assert_eq!(v["byteOffset"].as_u64().unwrap() % 4, 0);
|
||||
}
|
||||
let prims = g["meshes"][0]["primitives"].as_array().unwrap();
|
||||
assert_eq!(prims.len(), 2);
|
||||
assert_eq!((prims[0]["material"].as_u64(), prims[1]["material"].as_u64()), (Some(0), Some(1)));
|
||||
// Positions are meters: the 10 pt square is 10/72 inch across.
|
||||
let pos = &g["accessors"][prims[0]["attributes"]["POSITION"].as_u64().unwrap() as usize];
|
||||
let width = pos["max"][0].as_f64().unwrap() - pos["min"][0].as_f64().unwrap();
|
||||
assert!((width - 10.0 * 0.0254 / 72.0).abs() < 1e-7, "{width}");
|
||||
let index = &g["accessors"][prims[0]["indices"].as_u64().unwrap() as usize];
|
||||
assert_eq!((index["componentType"].as_u64(), index["count"].as_u64()), (Some(5125), Some(6)));
|
||||
// Translucent paint blends; colors are linear.
|
||||
assert_eq!(g["materials"][1]["alphaMode"], "BLEND");
|
||||
assert!(g["materials"][0].get("alphaMode").is_none());
|
||||
assert_eq!(g["materials"][0]["doubleSided"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usda_is_y_up_in_points_with_a_material_for_each_color() {
|
||||
let files = mesh_files("My Box", "usda", &[square(RED), square([0.5, 0.5, 0.5, 1.0])]).unwrap();
|
||||
let u = text(&files, "My Box.usda");
|
||||
assert!(u.starts_with("#usda 1.0\n"));
|
||||
assert!(u.contains("defaultPrim = \"My_Box\"") && u.contains("upAxis = \"Y\"") && u.contains("def Xform \"My_Box\""), "{u}");
|
||||
assert!(u.contains(&format!("metersPerUnit = {}", 0.0254 / 72.0)));
|
||||
assert_eq!(u.matches("def Mesh").count(), 2);
|
||||
assert_eq!(u.matches("def Material").count(), 2);
|
||||
assert!(u.contains("int[] faceVertexCounts = [3, 3]"));
|
||||
assert!(u.contains("rel material:binding = </My_Box/Materials/Material_2>"));
|
||||
// sRGB 0.5 is about 0.214 linear.
|
||||
assert!(u.contains("color3f inputs:diffuseColor = (0.21404"), "{u}");
|
||||
assert_eq!(u.matches('{').count(), u.matches('}').count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usdz_stores_the_layer_uncompressed_on_a_64_byte_boundary() {
|
||||
let files = mesh_files("Box", "usdz", &[square(RED)]).unwrap();
|
||||
let (name, zip) = &files[0];
|
||||
assert_eq!(name, "Box.usdz");
|
||||
// The local header: 30 bytes, the name, then the extra field.
|
||||
let u16_at = |at: usize| u16::from_le_bytes([zip[at], zip[at + 1]]) as usize;
|
||||
assert_eq!(&zip[..4], &[0x50, 0x4b, 0x03, 0x04]);
|
||||
assert_eq!(u16_at(8), 0, "stored, not deflated");
|
||||
let start = 30 + u16_at(26) + u16_at(28);
|
||||
assert_eq!(start % 64, 0);
|
||||
let layer = mesh_files("Box", "usda", &[square(RED)]).unwrap().remove(0).1;
|
||||
assert_eq!(&zip[start..start + layer.len()], layer.as_slice());
|
||||
let archive = super::super::zip::Archive::open(zip, 8).unwrap();
|
||||
assert_eq!(archive.read("Box.usda", usize::MAX).unwrap(), Some(layer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_obj_names_its_mtl_file_without_spaces_or_comment_marks() {
|
||||
let files = mesh_files("Box #1", "obj", &[square(RED)]).unwrap();
|
||||
assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>(), ["Box__1.obj", "Box__1.mtl"]);
|
||||
assert!(text(&files, "Box__1.obj").starts_with("mtllib Box__1.mtl\n"));
|
||||
assert_eq!(mesh_file_names("Box #1", "obj").unwrap(), ["Box__1.obj", "Box__1.mtl"]);
|
||||
assert_eq!(mesh_file_names("Box #1", "usda").unwrap(), ["Box #1.usda"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn obj_and_usda_keep_coordinates_a_32_bit_float_would_merge() {
|
||||
let mut part = square(RED);
|
||||
part.mesh.positions[0] = [20_000_000.0, 0.0, 0.0];
|
||||
part.mesh.positions[1] = [20_000_001.0, 0.0, 0.0];
|
||||
let obj = text(&mesh_files("Box", "obj", std::slice::from_ref(&part)).unwrap(), "Box.obj");
|
||||
assert!(obj.contains("v 20000000 0 0\nv 20000001 0 0\n"), "{obj}");
|
||||
let usda = text(&mesh_files("Box", "usda", &[part]).unwrap(), "Box.usda");
|
||||
assert!(usda.contains("(20000001, 0, 0)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_format_is_refused() {
|
||||
assert!(mesh_files("Box", "fbx", &[square(RED)]).is_err());
|
||||
assert!(mesh_file_names("Box", "").is_err());
|
||||
assert_eq!(mesh_file_names("Box", "obj").unwrap(), ["Box.obj", "Box.mtl"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prim_names_are_usd_identifiers() {
|
||||
assert_eq!(prim_name("Logo 2"), "Logo_2");
|
||||
assert_eq!(prim_name("3D"), "_3D");
|
||||
assert_eq!(prim_name(""), "_");
|
||||
assert_eq!(prim_name("é/x"), "__x");
|
||||
}
|
||||
@@ -48,7 +48,7 @@ pub const OPTIONS: &[FormatOption] = &[
|
||||
FormatOption {
|
||||
name: "pdfCompatible",
|
||||
ty: "boolean",
|
||||
default: "false",
|
||||
default: "true",
|
||||
description: "also carry a PDF of every artboard, for apps that read PDF (a larger file)",
|
||||
},
|
||||
];
|
||||
@@ -146,7 +146,7 @@ struct NativeOptions {
|
||||
precision: Option<Value>,
|
||||
include_linked: bool,
|
||||
embed_profiles: Option<bool>,
|
||||
pdf_compatible: bool,
|
||||
pdf_compatible: Option<bool>,
|
||||
}
|
||||
|
||||
/// What [`save_options`] leaves to [`encode`] (rendering and encoding take time).
|
||||
@@ -171,7 +171,9 @@ pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) ->
|
||||
so.profiles = embedded_profiles(doc);
|
||||
}
|
||||
so.check().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
Ok((so, Extras { preview: o.preview, pdf: o.pdf_compatible }))
|
||||
let pdf = o.pdf_compatible.unwrap_or(true);
|
||||
so.pdf_compatible = Some(pdf);
|
||||
Ok((so, Extras { preview: o.preview, pdf }))
|
||||
}
|
||||
|
||||
/// The `precision` option `v`: a whole number of decimal places, 0 to
|
||||
@@ -213,9 +215,11 @@ pub(super) fn encode_to(cmd: &str, f: &Format, doc: &Document, p: &Value, w: imp
|
||||
if extras.preview {
|
||||
so.preview = preview_png(&drawn)?;
|
||||
}
|
||||
if extras.pdf {
|
||||
// Every artboard as PDF pages (no editing data: the file is the native document).
|
||||
so.pdf = Some(super::pdf::encode(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }))?.0);
|
||||
if extras.pdf && !drawn.artboards.is_empty() {
|
||||
// Every artboard as PDF pages (no editing data: the file is the native document), dated by
|
||||
// the document's File Info, so the same document and dates give the same bytes.
|
||||
let at = drawn.metadata.modified.or(drawn.metadata.created);
|
||||
so.pdf = Some(super::pdf::encode_at(cmd, &drawn, &json!({ "range": "all", "preserveEditing": false }), at)?.0);
|
||||
}
|
||||
drop(drawn);
|
||||
let native = match full {
|
||||
|
||||
@@ -240,9 +240,13 @@ pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a,
|
||||
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
|
||||
/// and a document without one gets neither.
|
||||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||||
options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time())
|
||||
}
|
||||
|
||||
/// [`options`] modified at `now` (Unix seconds) instead of the time of writing.
|
||||
fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option<i64>) -> Result<PdfOptions> {
|
||||
let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() };
|
||||
let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?;
|
||||
let now = vectorcraft_doc::metadata::output_time();
|
||||
Ok(PdfOptions {
|
||||
settings: settings(cmd, p)?,
|
||||
artboards,
|
||||
@@ -265,9 +269,14 @@ pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file
|
||||
/// 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>)> {
|
||||
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>)> {
|
||||
// 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(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||||
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))
|
||||
@@ -294,7 +303,20 @@ pub(super) fn encode_carrying(
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
let mut opts = options(cmd, doc, p)?;
|
||||
encode_carrying_at(cmd, doc, p, None, native, private)
|
||||
}
|
||||
|
||||
/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of
|
||||
/// writing.
|
||||
fn encode_carrying_at(
|
||||
cmd: &str,
|
||||
doc: &Document,
|
||||
p: &Value,
|
||||
modified: Option<i64>,
|
||||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||||
) -> Result<(Vec<u8>, Vec<String>)> {
|
||||
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());
|
||||
let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?;
|
||||
|
||||
@@ -270,7 +270,18 @@ pub fn save_plan(s: &Session, mode: SaveMode, p: &Value) -> Result<SavePlan> {
|
||||
}
|
||||
}
|
||||
// None given: the ones the document was last saved with in this format.
|
||||
let options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
|
||||
let mut options = if given.is_empty() && format.id == st.format { remembered_save_options(st) } else { given };
|
||||
// Remember the effective choice, including the encoder's default, so another document's
|
||||
// later save cannot change what this one writes. Invalid non-null values still reach validation.
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
let pdf = match options.get("pdfCompatible") {
|
||||
None | Some(Value::Null) => Some(native_pdf_preference(s, Some(st)).unwrap_or(true)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(pdf) = pdf {
|
||||
options.insert("pdfCompatible".into(), json!(pdf));
|
||||
}
|
||||
}
|
||||
// Save writes the document's own file; the other modes only where they are told to.
|
||||
let path =
|
||||
path.or_else(|| (mode == SaveMode::Save && !st.converted && !st.recovered && format.id == st.format).then(|| st.path.clone()).flatten());
|
||||
@@ -336,6 +347,15 @@ fn same_value(a: &Value, b: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native documents and templates share only their PDF choice. Other source formats use the
|
||||
/// application's last native choice, keeping an AI file's separate PDF option out of it.
|
||||
fn native_pdf_preference(s: &Session, st: Option<&DocState>) -> Option<bool> {
|
||||
st.filter(|d| matches!(d.format, "vectorcraft" | "template"))
|
||||
.and_then(|d| d.save_options.get("pdfCompatible"))
|
||||
.and_then(Value::as_bool)
|
||||
.or_else(|| s.prefs.native_save_options.get("pdfCompatible").and_then(Value::as_bool))
|
||||
}
|
||||
|
||||
/// The options `st` was last saved with, their artboards (`artboard`, `artboards` and `range`)
|
||||
/// following the artboards they named ([`DocState::save_artboards`]): an artboard deleted since is
|
||||
/// left out, and one that moved is named by its new number. When none of the artboards the save
|
||||
@@ -1028,12 +1048,15 @@ 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, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
|
||||
let Loaded { mut doc, native_pdf_compatible, .. } = load(&path, &read_file(&path)?)?.with_grid_prefs(&s.prefs);
|
||||
doc.template = false;
|
||||
// Back to the saved file: its recovery copy has nothing left to recover.
|
||||
let uid = s.doc()?.uid;
|
||||
crate::cmd::recovery::forget(s, uid);
|
||||
s.replace_document(index, doc);
|
||||
if let Some(pdf) = native_pdf_compatible {
|
||||
s.doc_mut()?.save_options.insert("pdfCompatible".into(), json!(pdf));
|
||||
}
|
||||
Ok(json!({ "path": path }))
|
||||
}
|
||||
|
||||
@@ -1053,11 +1076,17 @@ fn format_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.or_else(|| (f.id == "vectorcraft" && !s.prefs.native_save_options.is_empty()).then(|| s.prefs.native_save_options.clone()));
|
||||
// A native save compresses as Use Compression says, unless told otherwise.
|
||||
let prefs = with_compression_pref(&s.prefs, &json!({}));
|
||||
let native_pdf = matches!(f.id, "vectorcraft" | "template").then(|| native_pdf_preference(s, st)).flatten();
|
||||
let mut v = f.to_json();
|
||||
if let Some(options) = v["options"].as_object_mut() {
|
||||
for (name, o) in options.iter_mut() {
|
||||
let default = prefs.get(name).filter(|_| matches!(f.id, "vectorcraft" | "template")).unwrap_or(&o["default"]);
|
||||
o["value"] = saved.as_ref().and_then(|m| m.get(name)).unwrap_or(default).clone();
|
||||
if name == "pdfCompatible"
|
||||
&& let Some(pdf) = native_pdf
|
||||
{
|
||||
o["value"] = json!(pdf);
|
||||
}
|
||||
}
|
||||
}
|
||||
v["saveFormats"] =
|
||||
@@ -1072,7 +1101,7 @@ pub(super) fn specs() -> Vec<CommandSpec> {
|
||||
"Save Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: false for native (also carry a PDF of every artboard) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
"{path?, format?: vectorcraft|template|pdf|svg|svgz|ai|eps (default: the path's extension, else the document's own format), options?: {…the format's options, see file.formatOptions; default: as last saved}, svg?: {…SVG options} (an SVG save keeps hidden layers, display:none, unless hiddenLayers is false), …the SVG and PDF options given flat instead (an option given flat or in svg and in options with different values is an error), compress?: bool (native, also in options: gzip; default: the useCompression preference; warnings say when the format doesn't read compress, version, preview or precision given flat, or a key given in options), version?: 4 (3, 2 or 1: for older VectorCraft versions; 1 and 2 are never compressed), preview?: false (embed a PNG of the first artboard, at most 256 px), precision?: 0..9 (native: round path anchors and handles to this many decimal places, a smaller file; colors, transforms and type stay exact; default: exact, and a later save without it writes exactly again), separateArtboards?: false (native and .ai: also save each artboard of `range` to <name>-<artboard>.<ext> beside the file, holding that artboard and the art touching it; the result's files lists every file written), range?: \"1-3, 5\"|\"all\" (default; with separateArtboards), includeLinked?: false (keep linked files' own pixels, not just their previews; they stay linked), embedProfiles?: true (carry the ICC profiles loaded from files that the document is tagged with; opening the file installs them where missing), pdfCompatible?: true (native: also carry a PDF of every artboard; false omits it) | true for .ai (false: blank pages, only the native document; a .ai file's compress, default true, is its compression.compressText), modified?: Unix seconds|null (the File Info modified date, and created date when there is none, a save to a file stamps; default now, recorded in the journal so a replay matches; null: leave the dates)} → {path, format, bytes, warnings, files?: [path…] (the file and its artboards' files), linked?: [path…] (images an SVG links to)}. Save writes one artboard, except a .ai file: a PDF-compatible file of every artboard carrying the native document (preserveEditing always on; PDF options flat or in options), which document.open restores exactly, on the active artboard (the app's, else the one the document opened on; with editingData: false, the first); separateArtboards writes more files, one per artboard. EPS saves the chosen artboard (first by default), retaining its page margins and the whole editable document; its PostScript appearance flattens transparency, as EPS requires. Without a path it writes the document's own file in its own format: a document opened from or saved as SVG/PDF/EPS saves as that again (warnings name what the format loses). No path known (never saved, converted from an older version, restored by Data Recovery, or another format) → {dataBase64, format, name, folder?, warnings} and the document stays modified",
|
||||
has_doc,
|
||||
|s, p| save(s, SaveMode::Save, p)
|
||||
),
|
||||
|
||||
@@ -21,8 +21,8 @@ use super::encode::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, create_dir, distinct_names_by, encode_with_warnings, merge, unique_file_names,
|
||||
with_black_output, writable, write_file,
|
||||
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,
|
||||
};
|
||||
|
||||
const C: &str = "document.exportForScreens";
|
||||
@@ -1184,7 +1184,7 @@ fn items(
|
||||
// the item folders are (`fold`).
|
||||
let safe = |stems: Vec<String>| distinct_names_by(stems.iter().map(|s| safe_file_name(s)), fold);
|
||||
let named = |pieces: Vec<(String, Arc<Document>, Option<usize>, Rect)>, fallback: &str| -> Vec<Item> {
|
||||
let stems = safe(unique_file_names(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
let stems = safe(unique_file_names_keeping_dots(pieces.iter().map(|(n, ..)| (n.as_str(), fallback.to_string()))));
|
||||
pieces
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
@@ -1210,6 +1210,9 @@ fn items(
|
||||
let mut pieces = vec![];
|
||||
for id in ids {
|
||||
let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?;
|
||||
if a.format_3d.is_some() {
|
||||
return Err(bad(C, format!("asset {id} is a 3D asset: assets.export3D writes it")));
|
||||
}
|
||||
let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?;
|
||||
pieces.push((a.name.clone(), d, Some(0), r));
|
||||
}
|
||||
@@ -1258,7 +1261,7 @@ fn items(
|
||||
if boards.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
let stems = safe(artboard_file_names(doc, &boards));
|
||||
let stems = safe(artboard_file_names_keeping_dots(doc, &boards));
|
||||
Ok(boards
|
||||
.into_iter()
|
||||
.zip(stems)
|
||||
|
||||
@@ -177,7 +177,7 @@ pub(super) fn encode(doc: &Document, p: &Value, compressed: bool) -> Result<Enco
|
||||
chosen.then(|| boards.iter().flatten().filter_map(|b| doc.artboards.get(*b)).map(|a| a.rect).collect());
|
||||
let overflow = crate::cmd::fonts::export_overflow_warning(doc, &doc.layers, regions.as_deref());
|
||||
// Render effects SVG cannot represent faithfully, including source-model Darkness shadows.
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects(doc);
|
||||
let flat = crate::cmd::rasterfx::flatten_pixel_effects_for(doc, opts.profile != vectorcraft_svg::Profile::Tiny12);
|
||||
let doc = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded { warnings, ..Encoded::default() };
|
||||
for artboard in boards {
|
||||
|
||||
@@ -680,7 +680,9 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
|
||||
for (name, compress) in [("a.vectorcraft", false), ("b.vectorcraft", true), ("c.vctemplate", true)] {
|
||||
let path = d.join(name).to_string_lossy().to_string();
|
||||
let p = json!({"path": path, "compress": compress});
|
||||
// A PDF-compatible file's PDF is dated to the second it is written, so the byte comparison
|
||||
// uses files without one.
|
||||
let p = json!({"path": path, "compress": compress, "pdfCompatible": false});
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
let written = std::fs::read(&path).unwrap();
|
||||
assert_eq!(r["bytes"], written.len(), "{r}");
|
||||
@@ -690,6 +692,17 @@ fn a_native_save_streams_the_bytes_the_encoder_makes() {
|
||||
assert_eq!(written, job.encode().unwrap().files[0].1, "{name}");
|
||||
assert_eq!(vectorcraft_format::load(&written).unwrap().template, name.ends_with("vctemplate"));
|
||||
}
|
||||
// A save with the default options streams a file that carries its PDF.
|
||||
let path = d.join("d.vectorcraft").to_string_lossy().to_string();
|
||||
let p = json!({"path": path});
|
||||
let r = s.execute("file.saveCopy", &p).unwrap();
|
||||
let written = std::fs::read(&path).unwrap();
|
||||
assert_eq!(r["bytes"], written.len(), "{r}");
|
||||
assert!(vectorcraft_format::pdf_content(&written).is_some());
|
||||
let plan = save_plan(&s, SaveMode::Copy, &p).unwrap();
|
||||
let job = save_job(&mut s, plan).unwrap();
|
||||
assert!(job.streams());
|
||||
assert!(vectorcraft_format::pdf_content(&job.encode().unwrap().files[0].1).is_some());
|
||||
// A writer that fails: the save fails with its error.
|
||||
let plan = save_plan(&s, SaveMode::Copy, &json!({"path": "/x/y.vectorcraft"})).unwrap();
|
||||
let job = save_job(&mut s, plan).unwrap();
|
||||
|
||||
@@ -63,6 +63,32 @@ fn export_writes_dxf_of_the_chosen_version() {
|
||||
assert_eq!(header(&text(&ser), "$INSUNITS").as_deref(), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cnc_preset_sets_the_options_that_are_not_given() {
|
||||
let mut s = two_boards();
|
||||
let cnc = text(&s.execute("document.exportDxf", &json!({"dxfPreset": "cnc"})).unwrap());
|
||||
assert_eq!(header(&cnc, "$ACADVER").as_deref(), Some("AC1015"));
|
||||
assert_eq!(header(&cnc, "$INSUNITS").as_deref(), Some("4"));
|
||||
// document.export takes it too, beside a PDF preset's name.
|
||||
let r = s.execute("document.export", &json!({"format": "dxf", "dxfPreset": "cnc"})).unwrap();
|
||||
assert_eq!(header(&text(&r), "$ACADVER").as_deref(), Some("AC1015"));
|
||||
let o = dxf::options(&json!({"dxfPreset": "CNC", "unit": "in"})).unwrap();
|
||||
assert_eq!(
|
||||
(o.version, o.unit, o.colors, o.preserve, o.alter_paths, o.outline_text),
|
||||
(
|
||||
vectorcraft_cad::DxfVersion::R2000,
|
||||
vectorcraft_doc::Unit::Inches,
|
||||
vectorcraft_cad::ColorDepth::Aci256,
|
||||
vectorcraft_cad::Preserve::Editability,
|
||||
false,
|
||||
true
|
||||
)
|
||||
);
|
||||
assert_eq!(dxf::options(&json!({})).unwrap().version, vectorcraft_cad::DxfVersion::R2018, "custom by default");
|
||||
let e = s.execute("document.exportDxf", &json!({"dxfPreset": "laser"})).unwrap_err().to_string();
|
||||
assert!(e.contains("custom or cnc"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_artboards_writes_one_drawing_per_artboard() {
|
||||
let mut s = two_boards();
|
||||
|
||||
@@ -148,6 +148,19 @@ fn device_names_and_trailing_periods_follow_windows_naming_rules() {
|
||||
assert_eq!(r["name"], "nul-.zip");
|
||||
}
|
||||
|
||||
/// A dot in an artboard's name stays a dot (`example.artboard.png`, #6143); a name of only dots, or
|
||||
/// ending in one, still can't name another folder or end in a period, a leading dot is a dash (no
|
||||
/// hidden `.draft.png`), and `/` and `:` are dashes.
|
||||
#[test]
|
||||
fn an_artboard_name_keeps_its_dots() {
|
||||
let mut s = session(7);
|
||||
for (index, name) in ["example.artboard", "v1.2.final", "..", "a/b:c", "trail.", ".draft", "..x.y"].iter().enumerate() {
|
||||
s.execute("artboard.setProps", &json!({"index": index, "name": name})).unwrap();
|
||||
}
|
||||
let r = export(&mut s, json!({"formats": [{"format": "png"}]}));
|
||||
assert_eq!(names(&r), ["example.artboard.png", "v1.2.final.png", "Artboard-3.png", "a-b-c.png", "trail.png", "-draft.png", "--x.y.png"]);
|
||||
}
|
||||
|
||||
/// `first_file` names the file the export writes first, for the same params: the item's file name,
|
||||
/// a prefix and a suffix with characters Windows does not allow, a range, a Windows device name,
|
||||
/// every kind of folder, presets, size suffixes, a name pattern with its counter and date, an
|
||||
|
||||
@@ -224,7 +224,11 @@ fn save_writes_svg_and_remembers_its_options() {
|
||||
let native = dir.join("doc.vectorcraft").to_string_lossy().to_string();
|
||||
s.execute("document.save", &json!({"path": native})).unwrap();
|
||||
assert!(vectorcraft_format::sniff(&std::fs::read(&native).unwrap()));
|
||||
assert!(s.doc().unwrap().save_options.is_empty(), "a native save remembers no options");
|
||||
assert_eq!(
|
||||
Value::Object(s.doc().unwrap().save_options.clone()),
|
||||
json!({"pdfCompatible":true}),
|
||||
"a native save retains its PDF choice without SVG options"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,13 @@ pub const DEFLATE_CHUNK: usize = 1 << 20;
|
||||
/// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without
|
||||
/// compression. Fails past the limits of a ZIP without its 64-bit extension (65534 files, 4 GB; 65535 is the ZIP64 marker).
|
||||
pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>, String> {
|
||||
store_aligned(files, 1)
|
||||
}
|
||||
|
||||
/// [`store`], with each file's data starting at a multiple of `align` bytes from the start of the
|
||||
/// archive (USDZ asks for 64): a padding block in the local header's extra field (header id
|
||||
/// 0x1986) moves it there.
|
||||
pub fn store_aligned<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)], align: usize) -> Result<Vec<u8>, String> {
|
||||
let too_big = || "too much to zip: at most 65534 files and 4 GB".to_string();
|
||||
let count = u16::try_from(files.len()).ok().filter(|&c| c != u16::MAX).ok_or_else(too_big)?;
|
||||
let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::<usize>() + 22);
|
||||
@@ -47,8 +54,21 @@ pub fn store<N: AsRef<str>, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result<Vec<u8>,
|
||||
out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
|
||||
out.extend_from_slice(&VERSION.to_le_bytes());
|
||||
out.extend_from_slice(&shared);
|
||||
out.extend_from_slice(&0u16.to_le_bytes()); // extra field length
|
||||
// The data would start after the header (30 bytes) and the name; a padding block of at
|
||||
// least its own 4-byte header moves it to the boundary.
|
||||
let start = out.len() + 2 + name.len();
|
||||
let mut pad = if align > 1 { (align - start % align) % align } else { 0 };
|
||||
if pad > 0 && pad < 4 {
|
||||
pad += align;
|
||||
}
|
||||
let pad_len = u16::try_from(pad).map_err(|_| format!("cannot align zip entries to {align} bytes"))?;
|
||||
out.extend_from_slice(&pad_len.to_le_bytes()); // extra field length
|
||||
out.extend_from_slice(name);
|
||||
if pad > 0 {
|
||||
out.extend_from_slice(&0x1986u16.to_le_bytes());
|
||||
out.extend_from_slice(&(pad_len - 4).to_le_bytes());
|
||||
out.resize(out.len() + pad - 4, 0);
|
||||
}
|
||||
out.extend_from_slice(bytes);
|
||||
// Central directory record.
|
||||
central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
|
||||
|
||||
@@ -10,8 +10,9 @@ use vectorcraft_color::Paint;
|
||||
use vectorcraft_doc::{AppearanceItem, Document, Node, NodeId, NodeKind, TextKind, TextObject, TextRun};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use super::select::on_artboard;
|
||||
use super::typecmd::refresh_bounds;
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||||
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, str_param};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -38,7 +39,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Text by Font",
|
||||
[],
|
||||
None,
|
||||
"{family, style?} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard → {count}",
|
||||
"{family, style?, scope?: \"document\" (default) | {artboards: [index, …] (0-based)} | {layers: [id, …] (layers or sublayers)}} select the text objects using the font, and the instances of the symbols whose art uses it, that are not hidden or locked, in a hidden or locked group or layer, or on a locked artboard; with scope, only those on one of the artboards (as All on Active Artboard takes them: their bounds overlap it or their center is on it) or in one of the layers → {count}",
|
||||
has_doc,
|
||||
select_font
|
||||
),
|
||||
@@ -689,7 +690,6 @@ fn replace(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
edit_symbol_texts(d, symbols.as_ref(), &|t, open| open && changes(t), &mut replace_in);
|
||||
drop(replace_in);
|
||||
Ok(n)
|
||||
})?
|
||||
};
|
||||
@@ -714,6 +714,7 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let style = str_param(p, "style").map(str::to_string);
|
||||
let ids = scope(s, false, None)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let within = FontScope::read(d, p)?;
|
||||
// Only text the Selection tool can reach: not hidden or locked, in a hidden or locked group
|
||||
// or layer, or on a locked artboard.
|
||||
let editable = d.editable();
|
||||
@@ -759,11 +760,65 @@ fn select_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
});
|
||||
}
|
||||
drop(editable);
|
||||
let symbols = d.symbol_boxes();
|
||||
hits.retain(|id| within.holds(d, &symbols, *id));
|
||||
drop(symbols);
|
||||
let n = hits.len();
|
||||
s.select(|_, sel| sel.set(hits))?;
|
||||
Ok(json!({ "count": n }))
|
||||
}
|
||||
|
||||
/// Where Select Text by Font looks: its `scope`.
|
||||
enum FontScope {
|
||||
Document,
|
||||
/// The artboards' rectangles.
|
||||
Artboards(Vec<Rect>),
|
||||
/// The layers and sublayers, with what they hold.
|
||||
Layers(BTreeSet<NodeId>),
|
||||
}
|
||||
|
||||
impl FontScope {
|
||||
fn read(d: &Document, p: &Value) -> Result<Self> {
|
||||
const C: &str = "select.font";
|
||||
let expected = || bad(C, "`scope` must be \"document\", {artboards: [index, …]} or {layers: [id, …]}");
|
||||
let scope = match p.get("scope") {
|
||||
None | Some(Value::Null) => return Ok(Self::Document),
|
||||
Some(Value::String(v)) if v == "document" => return Ok(Self::Document),
|
||||
Some(Value::Object(m)) if m.len() == 1 => m,
|
||||
Some(_) => return Err(expected()),
|
||||
};
|
||||
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
|
||||
match scope.get(key) {
|
||||
None => Ok(None),
|
||||
Some(Value::Array(a)) if !a.is_empty() => Ok(Some(a)),
|
||||
Some(_) => Err(bad(C, format!("`scope.{key}` must be a non-empty array"))),
|
||||
}
|
||||
};
|
||||
if let Some(a) = list("artboards")? {
|
||||
let rect = |v: &Value| v.as_u64().and_then(|i| d.artboards.get(index(i))).map(|b| b.rect);
|
||||
return a.iter().map(|v| rect(v).ok_or_else(|| bad(C, format!("no such artboard {v}")))).collect::<Result<_>>().map(Self::Artboards);
|
||||
}
|
||||
if let Some(a) = list("layers")? {
|
||||
let layer = |v: &Value| v.as_u64().map(NodeId).filter(|id| d.node(*id).is_some_and(Node::is_layer));
|
||||
return a
|
||||
.iter()
|
||||
.map(|v| layer(v).ok_or_else(|| bad(C, format!("{v} names no layer or sublayer"))))
|
||||
.collect::<Result<_>>()
|
||||
.map(Self::Layers);
|
||||
}
|
||||
Err(expected())
|
||||
}
|
||||
|
||||
/// Is object `id` in the scope.
|
||||
fn holds(&self, d: &Document, symbols: &vectorcraft_doc::SymbolBoxes, id: NodeId) -> bool {
|
||||
match self {
|
||||
Self::Document => true,
|
||||
Self::Artboards(rects) => d.node(id).is_some_and(|n| rects.iter().any(|r| on_artboard(n, symbols, *r))),
|
||||
Self::Layers(layers) => d.ancestry(id).is_some_and(|a| a.iter().any(|i| layers.contains(i))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn font_list(_: &mut Session, p: &Value) -> Result<Value> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
match str_param(p, "family") {
|
||||
@@ -912,6 +967,95 @@ mod tests {
|
||||
assert!(s.doc().unwrap().selection.objects.is_empty());
|
||||
}
|
||||
|
||||
/// Select Text by Font with an artboard scope takes the text on the artboards, as All on
|
||||
/// Active Artboard does: overlapping one, or centered on it, a straddling one included.
|
||||
#[test]
|
||||
fn select_font_in_artboards() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 600, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, x: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": x, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first = text(&mut s, 10);
|
||||
let straddling = text(&mut s, 180);
|
||||
let second = text(&mut s, 350);
|
||||
let third = text(&mut s, 650);
|
||||
let _off = text(&mut s, 1000);
|
||||
let select = |s: &mut Session, scope: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": scope})).unwrap();
|
||||
s.doc().unwrap().selection.objects.clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, json!({"artboards": [0]})), [first, straddling]);
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second, third]);
|
||||
assert_eq!(select(&mut s, json!("document")).len(), 5);
|
||||
// The same objects All on Active Artboard takes.
|
||||
s.execute("select.allOnArtboard", &json!({"artboard": 0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [first, straddling]);
|
||||
// Hidden text stays out.
|
||||
s.execute("select.set", &json!({"ids": [third.0]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
assert_eq!(select(&mut s, json!({"artboards": [1, 2]})), [second]);
|
||||
for bad in [json!({"artboards": [3]}), json!({"artboards": []}), json!({"artboards": ["x"]}), json!({"pages": [0]}), json!("all"), json!(7)] {
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": bad})).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Select Text by Font with a layer scope takes the text in the layers, their sublayers
|
||||
/// included, and still leaves out hidden and locked text.
|
||||
#[test]
|
||||
fn select_font_in_layers() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
let text = |s: &mut Session, y: i32| {
|
||||
NodeId(s.execute("text.create", &json!({"x": 10, "y": y, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].as_u64().unwrap())
|
||||
};
|
||||
let first_layer = s.doc().unwrap().doc.layers[0].id;
|
||||
let a = text(&mut s, 20);
|
||||
let second_layer = s.execute("layer.new", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = text(&mut s, 40);
|
||||
let sub = s.execute("layer.newSublayer", &json!({"parent": second_layer})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("layer.setCurrent", &json!({"id": sub})).unwrap();
|
||||
let in_sub = text(&mut s, 60);
|
||||
let locked = text(&mut s, 80);
|
||||
s.execute("select.set", &json!({"ids": [locked.0]})).unwrap();
|
||||
s.execute("object.lock", &json!({})).unwrap();
|
||||
let select = |s: &mut Session, layers: Value| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": layers}})).unwrap();
|
||||
let mut v = s.doc().unwrap().selection.objects.clone();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert_eq!(d.ancestry(in_sub).unwrap()[1], NodeId(sub), "the text is in the sublayer");
|
||||
assert_eq!(select(&mut s, json!([first_layer.0])), [a]);
|
||||
assert_eq!(select(&mut s, json!([second_layer])), [b, in_sub]);
|
||||
assert_eq!(select(&mut s, json!([sub])), [in_sub]);
|
||||
assert_eq!(select(&mut s, json!([first_layer.0, sub])), [a, in_sub]);
|
||||
// Not a layer: an error, the selection as it was.
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [a.0]}})).is_err());
|
||||
assert!(s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"layers": [u64::MAX]}})).is_err());
|
||||
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
|
||||
}
|
||||
|
||||
/// A symbol instance whose art uses the font is selected when the instance is in scope.
|
||||
#[test]
|
||||
fn select_font_scope_takes_symbol_instances_in_it() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap();
|
||||
s.execute("artboard.new", &json!({"x": 300, "y": 0, "width": 200, "height": 200})).unwrap();
|
||||
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "Serif", "font": "Source Serif 4"})).unwrap()["id"].clone();
|
||||
s.execute("symbol.new", &json!({"name": "Mark", "ids": [id]})).unwrap();
|
||||
let instance = s.doc().unwrap().selection.objects[0];
|
||||
let select = |s: &mut Session, i: u64| {
|
||||
s.execute("select.font", &json!({"family": "Source Serif 4", "scope": {"artboards": [i]}})).unwrap()["count"].clone()
|
||||
};
|
||||
assert_eq!(select(&mut s, 0), 1);
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [instance]);
|
||||
assert_eq!(select(&mut s, 1), 0);
|
||||
}
|
||||
|
||||
/// Replace Font leaves hidden and locked text in its font, and text inside a hidden or locked
|
||||
/// group or layer, and says so. Text on a visible, unlocked template layer is changed.
|
||||
#[test]
|
||||
|
||||
@@ -92,8 +92,12 @@ impl Session {
|
||||
|
||||
/// The freeform gradient behind the Fill (`stroke` false) or Stroke proxy, its points in
|
||||
/// document coordinates, and the length its spreads are fractions of there (see
|
||||
/// [`Node::proxy_freeform`]).
|
||||
/// [`Node::proxy_freeform`]). `None` while an object of a symbol instance is selected
|
||||
/// ([`vectorcraft_doc::Selection::symbol_part`]): its points are in the symbol's coordinates.
|
||||
pub fn proxy_freeform(&self, stroke: bool) -> Option<(Freeform, f64)> {
|
||||
if self.active()?.selection.symbol_part.is_some() {
|
||||
return None;
|
||||
}
|
||||
let (n, item) = self.proxy_source(stroke)?;
|
||||
n.proxy_freeform(stroke, item)
|
||||
}
|
||||
|
||||
@@ -354,8 +354,10 @@ fn close(a: f64, b: f64) -> bool {
|
||||
}
|
||||
|
||||
/// The members a Global Edit started on the one selected object would take, with `o`: that
|
||||
/// object first, then the others in paint order.
|
||||
/// object first, then the others in paint order. Refused while an object of a dynamic symbol
|
||||
/// instance's art is selected.
|
||||
fn matches(s: &Session, o: &Options) -> Result<Vec<(NodeId, Affine)>> {
|
||||
super::edit::refuse_symbol_part(s)?;
|
||||
let st = s.doc()?;
|
||||
let d = &st.doc;
|
||||
let [id] = st.selection.objects.as_slice() else { return Err(bad(C, "select one object")) };
|
||||
@@ -423,6 +425,7 @@ fn can_toggle(s: &Session) -> std::result::Result<(), String> {
|
||||
if st.global_edit.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
super::edit::no_symbol_part(s)?;
|
||||
let [id] = st.selection.objects.as_slice() else {
|
||||
return Err(if st.selection.is_empty() { "nothing selected" } else { "select one object" }.into());
|
||||
};
|
||||
@@ -765,10 +768,12 @@ pub(crate) fn propagate(before: &Document, d: &mut Document, ge: &GlobalEdit, se
|
||||
let mut placed = model.clone();
|
||||
placed.transform(*xf * back, Scaling::default());
|
||||
give(was, &model, placed, &mut n, d);
|
||||
super::object::keep_pixel_aligned(&mut n, Scaling::default());
|
||||
if let Some(slot) = d.node_mut(*id) {
|
||||
*slot = n;
|
||||
edited.push(*id);
|
||||
}
|
||||
super::object::keep_compound_aligned(d, *id, Scaling::default());
|
||||
}
|
||||
edited
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Select Gradient Stop",
|
||||
[],
|
||||
None,
|
||||
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on → {index}",
|
||||
"{index: stop index (0 = the start) | null to clear} select a stop of the gradient behind the active proxy (the first selected object's, else the default paint): the stop the Gradient tool's annotator, the Gradient and Color panels and Delete/arrow keys act on; refused while an object of a symbol instance's art is selected → {index}",
|
||||
always,
|
||||
select_stop
|
||||
),
|
||||
@@ -59,6 +59,10 @@ pub(crate) fn active_gradient(s: &Session) -> Option<GradientPaint> {
|
||||
|
||||
fn select_stop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "gradient.selectStop";
|
||||
// No document is needed for the default paint's gradient, so this does not use `refuse_symbol_part`.
|
||||
if s.active().is_some_and(|st| st.selection.symbol_part.is_some()) {
|
||||
return Err(super::brushsym::part_only());
|
||||
}
|
||||
let index = match p.get("index") {
|
||||
None => return Err(bad(C, "missing `index` (a stop index, or null to clear)")),
|
||||
Some(Value::Null) => None,
|
||||
|
||||
@@ -58,12 +58,16 @@ pub(super) fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// The graph `id` names (or the graph containing it), else the selected graph (or the graph
|
||||
/// containing the selection).
|
||||
/// containing the selection). Refused while an object of a dynamic symbol instance's art is
|
||||
/// selected.
|
||||
pub(super) fn target(s: &Session, p: &Value, cmd: &str) -> Result<NodeId> {
|
||||
let st = s.doc()?;
|
||||
let start = match named_id(p)? {
|
||||
Some(id) => vec![id],
|
||||
None => st.selection.objects.clone(),
|
||||
None => {
|
||||
crate::cmd::edit::refuse_symbol_part(s)?;
|
||||
st.selection.objects.clone()
|
||||
}
|
||||
};
|
||||
for id in start {
|
||||
let mut cur = Some(id);
|
||||
|
||||
@@ -1316,6 +1316,195 @@ fn a_paint_on_a_design_copy_or_the_other_kind_of_row_is_not_kept() {
|
||||
assert_eq!(spec(&s, id), kept);
|
||||
}
|
||||
|
||||
/// A graphic style `name` made from a new rectangle with a blue fill at 50% opacity, and without a stroke row unless
|
||||
/// `stroke`: the rectangle and the blue paint.
|
||||
fn blue_style(s: &mut Session, name: &str, stroke: bool) -> (NodeId, Paint) {
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 600, "y": 600, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("appearance.setItem", &json!({"ids": [r.0], "index": top_row(s, r, true), "color": "#0000ff", "opacity": 50})).unwrap();
|
||||
if !stroke {
|
||||
s.execute("appearance.removeItem", &json!({"ids": [r.0], "index": top_row(s, r, false)})).unwrap();
|
||||
}
|
||||
s.execute("graphicStyle.new", &json!({"id": r.0, "name": name})).unwrap();
|
||||
let blue = s.doc().unwrap().doc.node(r).unwrap().appearance.fill_paint();
|
||||
(r, blue)
|
||||
}
|
||||
|
||||
/// Series 0's marks with a red 2 pt stroke stored from `marks[1]` and a 40% fill opacity stored from `marks[2]`.
|
||||
fn stored_red_stroke() -> (Session, NodeId, Vec<NodeId>) {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let stroke = top_row(&s, marks[1], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[2].0], "index": top_row(&s, marks[2], true), "opacity": 40})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.stroke_width, kept.fill_opacity), (Some(2.0), Some(0.4)));
|
||||
(s, id, marks)
|
||||
}
|
||||
|
||||
/// After the graph is drawn again, every mark of series 0 has `fill` at `opacity` and `stroke` at `weight`; two undos (the
|
||||
/// data edit, then the command) bring back the paints the graph stored in `was`.
|
||||
fn series_0_is(s: &mut Session, id: NodeId, (fill, opacity, stroke, weight): (&Paint, f32, &Paint, f64), was: &[vectorcraft_doc::SeriesPaint]) {
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let n = s.doc().unwrap().doc.node(id).unwrap().clone();
|
||||
for m in series(&n, 0).children().unwrap() {
|
||||
let f = m.appearance.fill().unwrap();
|
||||
assert_eq!((&f.paint, f.opacity), (fill, opacity));
|
||||
assert_eq!((&m.appearance.stroke_paint(), m.appearance.stroke_width()), (stroke, weight));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// A graphic style applied to a series mark is stored on the graph, its fill opacity included. A style without a stroke,
|
||||
/// applied to a mark that shows one, leaves the series without a stroke.
|
||||
#[test]
|
||||
fn a_graphic_style_applied_to_a_series_mark_is_kept() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let (_, blue) = blue_style(&mut s, "Blue", false);
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity, kept.stroke), (Some(blue.clone()), Some(0.5), Some(Paint::None)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the style and what the graph stored");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
series_0_is(&mut s, id, (&blue, 0.5, &Paint::None, 0.0), &was);
|
||||
}
|
||||
|
||||
/// A graphic style added on top of a series mark's appearance (Alt-click) stores the kinds of row the style shows: a style
|
||||
/// with only a fill leaves the stroke the series stores.
|
||||
#[test]
|
||||
fn a_graphic_style_added_to_a_series_mark_keeps_only_its_own_kinds() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
let red = spec(&s, id).series_paints[0].stroke.clone().unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let (_, blue) = blue_style(&mut s, "Blue", false);
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0], "add": true})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
|
||||
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0)));
|
||||
series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was);
|
||||
}
|
||||
|
||||
/// A graphic style whose stroke row is hidden, added on top of a series mark (Alt-click), stores its fill and leaves the
|
||||
/// stroke the series stores, although the mark shows a stroke of its own.
|
||||
#[test]
|
||||
fn a_graphic_style_with_a_hidden_stroke_added_to_a_series_mark_keeps_the_stored_stroke() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
let red = spec(&s, id).series_paints[0].stroke.clone().unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": stroke, "color": "#ff0000", "weight": 2})).unwrap();
|
||||
let (rect, blue) = blue_style(&mut s, "Blue", true);
|
||||
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, false), "visible": false})).unwrap();
|
||||
s.execute("graphicStyle.new", &json!({"id": rect.0, "name": "Hidden stroke"})).unwrap();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Hidden stroke", "ids": [marks[0].0], "add": true})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(blue.clone()), Some(0.5)));
|
||||
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(red.clone()), Some(2.0)));
|
||||
series_0_is(&mut s, id, (&blue, 0.5, &red, 2.0), &was);
|
||||
}
|
||||
|
||||
/// Redefine Graphic Style restyles the series marks still linked to the style, and the graph stores the new look.
|
||||
#[test]
|
||||
fn redefining_a_graphic_style_updates_a_linked_series_mark() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
let (rect, _) = blue_style(&mut s, "Blue", true);
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[0].0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [rect.0], "index": top_row(&s, rect, true), "color": "#00ff00", "opacity": 100})).unwrap();
|
||||
let green = s.doc().unwrap().doc.node(rect).unwrap().appearance.fill_paint();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": rect.0})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint(), green, "the mark follows the style");
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill, kept.fill_opacity), (Some(green.clone()), None));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was, "one undo takes back the redefinition and what the graph stored");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let (stroke, weight) = {
|
||||
let r = s.doc().unwrap().doc.node(rect).unwrap().clone();
|
||||
(r.appearance.stroke_paint(), r.appearance.stroke_width())
|
||||
};
|
||||
series_0_is(&mut s, id, (&green, 1.0, &stroke, weight), &was);
|
||||
}
|
||||
|
||||
/// Redefine Graphic Style made from a series mark does not edit that mark, so the graph keeps the paint another mark of the
|
||||
/// series stored after it: the graph drawn again shows the same marks with or without the redefinition, and undo brings
|
||||
/// back the stored paint.
|
||||
#[test]
|
||||
fn redefining_a_graphic_style_from_a_series_mark_keeps_the_paint_another_mark_stored() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
blue_style(&mut s, "Blue", true);
|
||||
s.execute("graphicStyle.apply", &json!({"name": "Blue", "ids": [marks[1].0]})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#00ff00"})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": top_row(&s, marks[0], true), "color": "#ff0000"})).unwrap();
|
||||
let red = s.doc().unwrap().doc.node(marks[0]).unwrap().appearance.fill_paint();
|
||||
let was = spec(&s, id).series_paints;
|
||||
assert_eq!(was[0].fill, Some(red.clone()));
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
let without = marks_of(&s, id, 0);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("graphicStyle.redefine", &json!({"name": "Blue", "id": marks[1].0})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was, "the source mark was not edited");
|
||||
s.execute("graph.setData", &json!({"id": id.0, "rows": [[1, 2], [3, 4]]})).unwrap();
|
||||
assert_eq!(marks_of(&s, id, 0), without);
|
||||
assert!(without.iter().all(|m| m.0 == red));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints, was);
|
||||
}
|
||||
|
||||
/// Default Fill and Stroke on a series mark stores a white fill and a 1 pt black stroke, both opaque, on the graph.
|
||||
#[test]
|
||||
fn default_fill_and_stroke_on_a_series_mark_is_kept() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("paint.default", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (white, black) = (Paint::solid(vectorcraft_color::Color::WHITE), Paint::solid(vectorcraft_color::Color::BLACK));
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(white.clone()), None));
|
||||
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(black.clone()), Some(1.0)));
|
||||
series_0_is(&mut s, id, (&white, 1.0, &black, 1.0), &was);
|
||||
}
|
||||
|
||||
/// Swap Fill and Stroke on a series mark stores the swapped paints and the stroke weight, and leaves the opacity the series
|
||||
/// stores.
|
||||
#[test]
|
||||
fn swapping_a_series_marks_fill_and_stroke_is_kept() {
|
||||
let (mut s, id, marks) = stored_red_stroke();
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
let mark = s.doc().unwrap().doc.node(marks[0]).unwrap().clone();
|
||||
let (gray, green) = (mark.appearance.fill_paint(), mark.appearance.stroke_paint());
|
||||
let was = spec(&s, id).series_paints;
|
||||
s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let kept = spec(&s, id).series_paints[0].clone();
|
||||
assert_eq!((kept.fill.clone(), kept.fill_opacity), (Some(green.clone()), Some(0.4)));
|
||||
assert_eq!((kept.stroke.clone(), kept.stroke_width), (Some(gray.clone()), Some(3.0)));
|
||||
series_0_is(&mut s, id, (&green, 0.4, &gray, 3.0), &was);
|
||||
}
|
||||
|
||||
/// Swap Fill and Stroke writes the topmost fill even when it is hidden: the visible fill under it does not replace the
|
||||
/// fill the series stores.
|
||||
#[test]
|
||||
fn swapping_onto_a_hidden_top_fill_leaves_the_stored_fill() {
|
||||
let (mut s, id, _, marks) = column_marks();
|
||||
s.execute("appearance.addFill", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
let (_, upper) = fill_rows(&s, marks[0]);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": upper, "visible": false})).unwrap();
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[1].0], "index": top_row(&s, marks[1], true), "color": "#ff0000"})).unwrap();
|
||||
let red = spec(&s, id).series_paints[0].fill.clone();
|
||||
assert_eq!(red, Some(s.doc().unwrap().doc.node(marks[1]).unwrap().appearance.fill_paint()));
|
||||
let stroke = top_row(&s, marks[0], false);
|
||||
s.execute("appearance.setItem", &json!({"ids": [marks[0].0], "index": stroke, "color": "#00ff00", "weight": 3})).unwrap();
|
||||
s.execute("paint.swap", &json!({"ids": [marks[0].0]})).unwrap();
|
||||
assert_eq!(spec(&s, id).series_paints[0].fill, red);
|
||||
}
|
||||
|
||||
/// A kept opacity read from a file outside 0..1 is held to that range, and one that is not a number is left out.
|
||||
#[test]
|
||||
fn a_kept_series_opacity_from_a_file_is_held_to_its_range() {
|
||||
|
||||
@@ -76,8 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// ---------- predicates ----------
|
||||
|
||||
/// The Intertwine groups of the selection: selected ones, and those around a selected object.
|
||||
/// None while an object of a dynamic symbol instance's art is selected.
|
||||
fn selected_groups(s: &Session) -> Vec<NodeId> {
|
||||
let Some(st) = s.active() else { return vec![] };
|
||||
let Some(st) = s.active().filter(|st| st.selection.symbol_part.is_none()) else { return vec![] };
|
||||
let mut out = vec![];
|
||||
for id in &st.selection.objects {
|
||||
let group = st.doc.ancestry(*id).unwrap_or_default().into_iter().rev().find(|a| st.doc.node(*a).is_some_and(is_intertwine));
|
||||
@@ -92,6 +93,7 @@ fn selected_groups(s: &Session) -> Vec<NodeId> {
|
||||
|
||||
fn has_intertwine(s: &Session) -> std::result::Result<(), String> {
|
||||
has_selection(s)?;
|
||||
super::edit::no_symbol_part(s)?;
|
||||
if selected_groups(s).is_empty() { Err("select an Intertwine group".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
@@ -378,11 +380,14 @@ fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
id
|
||||
}
|
||||
None => match selected_groups(s).as_slice() {
|
||||
[g] => *g,
|
||||
[] => return Err(bad(C, "select an Intertwine group")),
|
||||
_ => return Err(bad(C, "select one Intertwine group")),
|
||||
},
|
||||
None => {
|
||||
super::edit::refuse_symbol_part(s)?;
|
||||
match selected_groups(s).as_slice() {
|
||||
[g] => *g,
|
||||
[] => return Err(bad(C, "select an Intertwine group")),
|
||||
_ => return Err(bad(C, "select one Intertwine group")),
|
||||
}
|
||||
}
|
||||
};
|
||||
let at = match p.get("at") {
|
||||
Some(_) => Some(point_param(p, "at").filter(|q| q.x.is_finite() && q.y.is_finite()).ok_or_else(|| bad(C, "at must be [x, y]"))?),
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Artboard, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_doc::{Artboard, ArtboardSide, Document, LAYER_COLORS, LayerColor, Node, NodeId, NodeKind, Scaling};
|
||||
use vectorcraft_geom::{Affine, Rect};
|
||||
|
||||
use super::{
|
||||
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New Artboard",
|
||||
["Window", "Artboards"],
|
||||
None,
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1)} (default place: right of the last)",
|
||||
"{x?, y?, width?, height?, name?, color?: color (its background color, as artboard.setProps takes it, #rrggbbaa and #rgba refused; default: none), showCenterMark?: bool, showCrossHairs?: bool, showVideoSafeAreas?: bool (as artboard.setProps takes them; default false), videoRulerPixelAspectRatio?: number 0.1–10 (as artboard.setProps takes it; default 1), side?: \"left\"|\"right\"|\"top\"|\"bottom\" (the size of artboard `beside`, 20 pt from that side of it; refused with x, y, width or height), beside?: n (the artboard `side` places it by; default the last)} (default place: right of the last)",
|
||||
has_doc,
|
||||
artboard_new
|
||||
),
|
||||
@@ -869,6 +869,17 @@ fn artboard_str<'a>(p: &'a Value, key: &str, cmd: &str) -> Result<Option<&'a str
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's `side` param: None when absent or null, else the side it names. Any other
|
||||
/// value is an error that names the four sides.
|
||||
pub(crate) fn artboard_side(p: &Value, cmd: &str) -> Result<Option<ArtboardSide>> {
|
||||
let bad_side = || bad(cmd, "`side` must be \"left\", \"right\", \"top\" or \"bottom\"");
|
||||
match p.get("side") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::String(v)) => ArtboardSide::parse(v).map(Some).ok_or_else(bad_side),
|
||||
Some(_) => Err(bad_side()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard command's `videoRulerPixelAspectRatio` param: None when absent, else a number within
|
||||
/// [`vectorcraft_doc::VIDEO_PIXEL_ASPECT_RANGE`].
|
||||
fn video_pixel_aspect(p: &Value, cmd: &str) -> Result<Option<f64>> {
|
||||
@@ -886,10 +897,24 @@ fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d0 = &s.doc()?.doc;
|
||||
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
|
||||
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
|
||||
let r = rect_from(p, def);
|
||||
let color = artboard_color(p, "artboard.new")?.flatten();
|
||||
check_switches(p, &["showCenterMark", "showCrossHairs", "showVideoSafeAreas"], "artboard.new")?;
|
||||
check_numbers(p, &["x", "y", "width", "height"], "artboard.new")?;
|
||||
let r = match artboard_side(p, "artboard.new")? {
|
||||
Some(side) => {
|
||||
if ["x", "y", "width", "height"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
|
||||
return Err(bad("artboard.new", "`side` places the artboard: leave out x, y, width and height"));
|
||||
}
|
||||
let beside = match p.get("beside") {
|
||||
None | Some(Value::Null) => d0.artboards.len().saturating_sub(1),
|
||||
Some(_) => super::appearance::index_param(p, "beside", "artboard.new")?,
|
||||
};
|
||||
let src = d0.artboards.get(beside).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
|
||||
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),
|
||||
};
|
||||
let given = artboard_str(p, "name", "artboard.new")?.map(str::trim).filter(|n| !n.is_empty());
|
||||
let (show_center_mark, show_cross_hairs) = (bool_or(p, "showCenterMark", false), bool_or(p, "showCrossHairs", false));
|
||||
let show_video_safe_areas = bool_or(p, "showVideoSafeAreas", false);
|
||||
@@ -1100,6 +1125,7 @@ fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for id in &e.art {
|
||||
if let Some(n) = d.node_mut(*id) {
|
||||
n.transform(xf, sc);
|
||||
super::object::keep_pixel_aligned(n, sc);
|
||||
}
|
||||
}
|
||||
} else if !e.resized {
|
||||
|
||||
@@ -849,9 +849,10 @@ impl Resolved {
|
||||
|
||||
/// The linked files of document `d`, just read from `doc_path`: those found unchanged are read
|
||||
/// again (their images had the preview), modified ones are read again when `update`, links found
|
||||
/// away from their path take the new path. A link nothing is known of whose image shows a preview
|
||||
/// (a file another app wrote kept only that) was never read: its file is read now, into the
|
||||
/// image's bounds, which isn't a change to report. Nothing here is an undo step.
|
||||
/// away from their path take the new path. A link nothing is known of whose image, or placed
|
||||
/// document, shows a preview (a file another app wrote kept only that) was never read: its file is
|
||||
/// read now, into the object's bounds, which isn't a change to report. A placed document whose
|
||||
/// file's bytes are gone isn't showing a preview. Nothing here is an undo step.
|
||||
pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_proxy_eps: bool) -> Resolved {
|
||||
let mut out = Resolved::default();
|
||||
// The image (with its preview) and hash of each file read for a preview here, by path: images
|
||||
@@ -863,7 +864,11 @@ pub fn resolve(d: &mut Document, doc_path: Option<&str>, update: bool, low_res_p
|
||||
let mut moved: Vec<(String, String, bool, LinkInfo)> = vec![];
|
||||
for g in groups(d, None) {
|
||||
let row = rows(std::iter::once(&g), doc_path);
|
||||
let unread = g.document.is_none() && g.link.hash.is_none() && g.link.size.is_none() && previewing(d, &g.key);
|
||||
let shows_preview = match g.document {
|
||||
None => previewing(d, &g.key),
|
||||
Some(_) => d.images.get(&g.key).is_some_and(ImageBlob::is_proxy),
|
||||
};
|
||||
let unread = g.link.hash.is_none() && g.link.size.is_none() && shows_preview;
|
||||
let eps_preview = keep_eps_preview(low_res_proxy_eps, &g, d);
|
||||
match probe(&g, doc_path) {
|
||||
(Status::Missing, _) => out.missing.extend(row),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user