Merge main into feat/styles-textures-library

This commit is contained in:
dotCooCoo
2026-11-11 18:55:43 -08:00
93 changed files with 4085 additions and 268 deletions
Generated
+31 -30
View File
@@ -6313,7 +6313,7 @@ dependencies = [
[[package]]
name = "vectorcraft"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"arboard",
"block2 0.6.2",
@@ -6360,7 +6360,7 @@ dependencies = [
[[package]]
name = "vectorcraft-affinity"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"crc32fast",
"flate2",
@@ -6371,14 +6371,14 @@ dependencies = [
[[package]]
name = "vectorcraft-apischema"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"serde_json",
]
[[package]]
name = "vectorcraft-brush"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"kurbo 0.13.1",
"serde",
@@ -6390,7 +6390,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cad"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6406,7 +6406,7 @@ dependencies = [
[[package]]
name = "vectorcraft-cli"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"log",
"objc2-core-foundation",
@@ -6429,7 +6429,7 @@ dependencies = [
[[package]]
name = "vectorcraft-color"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"kurbo 0.13.1",
"moxcms",
@@ -6441,7 +6441,7 @@ dependencies = [
[[package]]
name = "vectorcraft-doc"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"chrono",
"hayro-ccitt",
@@ -6460,7 +6460,7 @@ dependencies = [
[[package]]
name = "vectorcraft-effects"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6478,7 +6478,7 @@ dependencies = [
[[package]]
name = "vectorcraft-engine"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"cfb",
"chrono",
@@ -6499,6 +6499,7 @@ dependencies = [
"roxmltree 0.21.2",
"rustix 1.1.6",
"ruzstd",
"same-file",
"serde",
"serde_json",
"skrifa 0.47.0",
@@ -6531,7 +6532,7 @@ dependencies = [
[[package]]
name = "vectorcraft-eps"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"craft-pdf",
"encoding_rs",
@@ -6559,7 +6560,7 @@ dependencies = [
[[package]]
name = "vectorcraft-format"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"craft-vectorcraft-format",
"flate2",
@@ -6575,7 +6576,7 @@ dependencies = [
[[package]]
name = "vectorcraft-geom"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"kurbo 0.13.1",
"proptest",
@@ -6587,7 +6588,7 @@ dependencies = [
[[package]]
name = "vectorcraft-js"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"chrono",
"getrandom 0.4.3",
@@ -6597,7 +6598,7 @@ dependencies = [
[[package]]
name = "vectorcraft-mcp"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"log",
@@ -6616,7 +6617,7 @@ dependencies = [
[[package]]
name = "vectorcraft-metafile"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6630,7 +6631,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pathops"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"kurbo 0.13.1",
"linesweeper",
@@ -6642,7 +6643,7 @@ dependencies = [
[[package]]
name = "vectorcraft-pdf"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"aes",
"chrono",
@@ -6676,7 +6677,7 @@ dependencies = [
[[package]]
name = "vectorcraft-plugins"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"craft-vectorcraft-format",
"image",
@@ -6695,7 +6696,7 @@ dependencies = [
[[package]]
name = "vectorcraft-render"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"flate2",
"gif 0.14.2",
@@ -6725,7 +6726,7 @@ dependencies = [
[[package]]
name = "vectorcraft-scriptcheck"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"proptest",
"unicode-properties",
@@ -6733,7 +6734,7 @@ dependencies = [
[[package]]
name = "vectorcraft-svg"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"craft-docpath",
"flate2",
@@ -6760,7 +6761,7 @@ dependencies = [
[[package]]
name = "vectorcraft-testkit"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"fax",
"image",
@@ -6778,7 +6779,7 @@ dependencies = [
[[package]]
name = "vectorcraft-text"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"harfrust 0.13.3",
"icu_segmenter",
@@ -6799,7 +6800,7 @@ dependencies = [
[[package]]
name = "vectorcraft-three-d"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"vectorcraft-geom",
"vectorcraft-pathops",
@@ -6807,7 +6808,7 @@ dependencies = [
[[package]]
name = "vectorcraft-tools"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6824,7 +6825,7 @@ dependencies = [
[[package]]
name = "vectorcraft-trace"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"image",
"kurbo 0.13.1",
@@ -6836,7 +6837,7 @@ dependencies = [
[[package]]
name = "vectorcraft-ui-egui"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"bytemuck",
"craft-keymap",
@@ -6874,7 +6875,7 @@ dependencies = [
[[package]]
name = "vectorcraft-web"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"craft-web-api",
"eframe",
@@ -8006,7 +8007,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.24.0"
version = "0.24.1"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/vectorcraft", "apps/vectorcraft-cli", "xtask"]
[workspace.package]
version = "0.24.0"
version = "0.24.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+39 -34
View File
@@ -3,8 +3,6 @@ use std::process::{Command, Stdio};
use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-test-<pid>` in the temp folder. The first call removes
/// that folder and creates it again empty.
fn tmp(name: &str) -> std::path::PathBuf {
@@ -27,7 +25,12 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
std::fs::write(&steps, json!([{"command": "plugin.install", "params": {"dataBase64": module, key: value}}]).to_string()).unwrap();
let config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-{key}-{}", std::process::id())));
let run = |extra: &[&str]| {
Command::new(BIN).env("VECTORCRAFT_CONFIG_DIR", &config).args(["run", "--commands", steps.to_str().unwrap()]).args(extra).output().unwrap()
Command::new(crate::bin())
.env("VECTORCRAFT_CONFIG_DIR", &config)
.args(["run", "--commands", steps.to_str().unwrap()])
.args(extra)
.output()
.unwrap()
};
let out = run(&[]);
assert!(!out.status.success());
@@ -38,7 +41,7 @@ fn approval_param_needs_the_start_up_flag(key: &str, value: Value) {
let mcp_config = empty_dir(std::env::temp_dir().join(format!("vectorcraft-cli-test-mcp-{key}-{}", std::process::id())));
let mcp_install = |extra: &[&str]| -> Value {
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.env("VECTORCRAFT_CONFIG_DIR", &mcp_config)
.args(["mcp", "--headless"])
.args(extra)
@@ -77,22 +80,23 @@ fn plugin_grants_need_the_start_up_flag() {
approval_param_needs_the_start_up_flag("grant", json!(["doc.read"]));
approval_param_needs_the_start_up_flag("allowHeavy", json!(true));
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
let out = Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", "--connect", "127.0.0.1:1"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants applies to the headless server"));
let root = std::env::temp_dir();
for flag in ["--automation-read-root", "--automation-write-root"] {
let out = Command::new(BIN).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
let out =
Command::new(crate::bin()).args(["mcp", "--allow-plugin-grants", flag, root.to_str().unwrap()]).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{flag}");
assert!(String::from_utf8_lossy(&out.stderr).contains("--allow-plugin-grants can't be used with"), "{flag}");
}
let usage = Command::new(BIN).arg("help").output().unwrap();
let usage = Command::new(crate::bin()).arg("help").output().unwrap();
assert!(String::from_utf8_lossy(&usage.stdout).contains("--allow-plugin-grants lets plugin.install's grant and allowHeavy take effect"));
}
#[test]
fn commands_prints_catalogue() {
let out = Command::new(BIN).arg("commands").output().unwrap();
let out = Command::new(crate::bin()).arg("commands").output().unwrap();
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
@@ -104,7 +108,7 @@ fn run_batch_exports() {
let svg = tmp("batch.svg");
let png = tmp("batch.png");
let dc = tmp("batch.vectorcraft");
let out = Command::new(BIN)
let out = Command::new(crate::bin())
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#])
.args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#])
.args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##])
@@ -121,7 +125,7 @@ fn run_batch_exports() {
assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400);
// Re-open the native file and inspect.
let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap();
let out = Command::new(crate::bin()).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap();
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
@@ -137,7 +141,7 @@ fn scale_sizes_raster_exports_only() {
let svg = svg.to_str().unwrap();
let (plain, scaled, png) = (tmp("plain.dxf"), tmp("scaled.dxf"), tmp("scaled.png"));
let ok = |args: &[&str]| {
let out = Command::new(BIN).args(args).output().unwrap();
let out = Command::new(crate::bin()).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
};
ok(&["convert", svg, plain.to_str().unwrap()]);
@@ -151,11 +155,11 @@ fn scale_sizes_raster_exports_only() {
#[test]
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
let out = Command::new(crate::bin()).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
let out = Command::new(crate::bin()).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
@@ -184,7 +188,7 @@ fn run_reads_steps_from_a_commands_file() {
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
]);
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let out = Command::new(BIN)
let out = Command::new(crate::bin())
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
.output()
@@ -204,7 +208,7 @@ fn run_reads_steps_from_a_commands_file() {
#[test]
fn run_reads_json_lines_from_standard_input() {
let svg = tmp("stdin.svg");
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -227,7 +231,7 @@ fn run_reads_json_lines_from_standard_input() {
#[test]
fn run_refuses_malformed_commands_naming_the_step() {
let fails = |args: &[&str]| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{args:?}");
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
};
@@ -275,11 +279,11 @@ fn run_refuses_malformed_commands_naming_the_step() {
/// `--help` and `-h` right after a subcommand print the usage that `vectorcraft-cli help` prints.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
let usage = Command::new(BIN).arg("help").output().unwrap();
let usage = Command::new(crate::bin()).arg("help").output().unwrap();
assert!(usage.status.success() && String::from_utf8_lossy(&usage.stdout).contains("USAGE:"));
for sub in ["mcp", "run", "commands", "convert", "info", "bench", "perf"] {
for flag in ["--help", "-h"] {
let out = Command::new(BIN).args([sub, flag]).stdin(Stdio::null()).output().unwrap();
let out = Command::new(crate::bin()).args([sub, flag]).stdin(Stdio::null()).output().unwrap();
assert!(out.status.success(), "{sub} {flag}: {}", String::from_utf8_lossy(&out.stderr));
assert!(out.stdout == usage.stdout, "{sub} {flag}: {}", String::from_utf8_lossy(&out.stdout));
}
@@ -290,7 +294,7 @@ fn help_after_a_subcommand_prints_the_usage() {
/// with them.
#[test]
fn help_later_in_the_line_prints_the_usage() {
let usage = Command::new(BIN).arg("help").output().unwrap();
let usage = Command::new(crate::bin()).arg("help").output().unwrap();
let mut wrong = vec![];
for args in [
&["mcp", "--headless", "--help"][..],
@@ -311,7 +315,7 @@ fn help_later_in_the_line_prints_the_usage() {
&["perf", "--paths", "many", "-h"],
&["convert", "--decimals", "many", "--help"],
] {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap();
if !out.status.success() || out.stdout != usage.stdout {
wrong.push(format!("{args:?}: {}{}", String::from_utf8_lossy(&out.stderr), String::from_utf8_lossy(&out.stdout)));
}
@@ -322,17 +326,18 @@ fn help_later_in_the_line_prints_the_usage() {
/// Further along the line, `-h` is the value of the option before it.
#[test]
fn help_as_an_option_value_goes_to_the_option() {
let out = Command::new(BIN).args(["run", "--cmd", "-h"]).output().unwrap();
let out = Command::new(crate::bin()).args(["run", "--cmd", "-h"]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains("vectorcraft-cli: step 0 (-h):") && out.stdout.is_empty(), "{err}");
let out = Command::new(BIN).args(["convert", "in.svg", "--range", "-h"]).output().unwrap();
let out = Command::new(crate::bin()).args(["convert", "in.svg", "--range", "-h"]).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains("convert needs IN and OUT") && out.stdout.is_empty(), "{err}");
}
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let mut child =
Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
{
let mut stdin = child.stdin.take().unwrap();
for m in [
@@ -359,13 +364,13 @@ fn mcp_headless_over_stdio() {
/// 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 out = Command::new(crate::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 out = Command::new(crate::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}");
}
@@ -402,7 +407,7 @@ fn mcp_automation_roots_take_several_folders() {
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let file = |d: &str, f: &str| base.join(d).join(f).to_string_lossy().into_owned();
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-read-root={}", dir("in2"))])
.args(["--automation-write-root", &dir("out"), "--automation-write-root", &dir("out2")])
.stdin(Stdio::piped())
@@ -457,7 +462,7 @@ fn automation_roots(base: &std::path::Path) {
}
let dir = |d: &str| base.join(d).to_string_lossy().into_owned();
let fails = |args: &[&str], why: &str| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
let out = Command::new(crate::bin()).args(args).stdin(Stdio::null()).output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(!out.status.success() && err.contains(why), "{args:?}: {err}");
};
@@ -469,7 +474,7 @@ fn automation_roots(base: &std::path::Path) {
"a running app enforces its own roots: start it with `vectorcraft --control <port>",
);
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(["mcp", "--automation-read-root", &dir("in"), &format!("--automation-write-root={}", dir("out"))])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
@@ -530,7 +535,7 @@ fn automation_roots(base: &std::path::Path) {
fn mcp_protocol_over_stdio() {
use std::io::{BufRead, BufReader};
let mut child =
Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
Command::new(crate::bin()).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut lines = BufReader::new(child.stdout.take().unwrap()).lines();
let mut notes = vec![];
@@ -711,7 +716,7 @@ fn run_command(id: u64, command: &str, params: Value) -> Value {
#[test]
fn closed_stdout_ends_quietly() {
use std::io::Read;
let mut child = Command::new(BIN).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut child = Command::new(crate::bin()).arg("commands").stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut first = [0u8; 16];
child.stdout.as_mut().unwrap().read_exact(&mut first).unwrap();
drop(child.stdout.take());
@@ -728,13 +733,13 @@ fn run_with_a_closed_stdout_ends_quietly_and_finishes_the_batch() {
let _ = std::fs::remove_file(&svg);
let rect = r#"{"x":1,"y":1,"width":5,"height":5}"#;
let args = ["run", "--cmd", "document.json", "--cmd", "shape.rectangle", "--params", rect, "--export", svg.to_str().unwrap()];
let mut child = Command::new(BIN).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
let mut child = Command::new(crate::bin()).args(args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
drop(child.stdout.take());
let out = child.wait_with_output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
assert!(out.status.success() && err.is_empty(), "{:?}: {err}", out.status);
assert!(svg.is_file(), "the export after the closed reader still ran");
let out = Command::new(BIN).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
let out = Command::new(crate::bin()).args(["run", "--cmd", "not-a-command"]).stdout(Stdio::null()).output().unwrap();
assert!(!out.status.success());
}
@@ -749,7 +754,7 @@ fn config_dir(name: &str) -> std::path::PathBuf {
/// One `vectorcraft-cli run` with `steps` ((command, params)) against the settings folder `config`;
/// the result of each step.
fn run_in(config: &std::path::Path, steps: &[(&str, Value)]) -> Vec<Value> {
let mut cmd = Command::new(BIN);
let mut cmd = Command::new(crate::bin());
cmd.arg("run").env("VECTORCRAFT_CONFIG_DIR", config).env_remove("VECTORCRAFT_NO_PREFS");
for (id, params) in steps {
cmd.args(["--cmd", id, "--params", &params.to_string()]);
@@ -820,7 +825,7 @@ fn saved_pdf_presets_and_loaded_profiles_survive_a_restart() {
#[test]
fn runs_without_preferences_remember_nothing() {
let config = config_dir("noprefs");
let out = Command::new(BIN)
let out = Command::new(crate::bin())
.args(["run", "--cmd", "pdf.preset.save", "--params", r#"{"name":"Press"}"#])
.env("VECTORCRAFT_CONFIG_DIR", &config)
.env("VECTORCRAFT_NO_PREFS", "1")
+4 -6
View File
@@ -4,8 +4,6 @@ use std::process::Command;
use serde_json::Value;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// Returns the path of `name` in `vectorcraft-cli-fileio-<pid>` in the temp folder. The first call
/// removes that folder and creates it again empty.
fn tmp(name: &str) -> String {
@@ -20,7 +18,7 @@ fn tmp(name: &str) -> String {
}
fn ok(args: &[&str]) -> String {
let out = Command::new(BIN).args(args).output().unwrap();
let out = Command::new(crate::bin()).args(args).output().unwrap();
assert!(out.status.success(), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8(out.stdout).unwrap()
}
@@ -130,7 +128,7 @@ fn raster_exports_use_document_resolution_unless_overridden() {
fn raster_export_rejects_invalid_ppi() {
for ppi in ["0", "-150", "NaN", "inf", "high"] {
for args in [vec!["run", "--export", "unused.png", "--ppi", ppi], vec!["convert", "unused.svg", "unused.png", "--ppi", ppi]] {
let out = Command::new(BIN).args(&args).output().unwrap();
let out = Command::new(crate::bin()).args(&args).output().unwrap();
assert!(!out.status.success(), "{args:?}");
assert!(String::from_utf8_lossy(&out.stderr).contains("positive finite number"), "{args:?}: {out:?}");
}
@@ -228,7 +226,7 @@ fn convert_writes_legacy_ai_with_its_options() {
assert!(r["warnings"].to_string().contains("hidden objects are left out"), "{r}");
assert!(std::fs::read(&one).unwrap().starts_with(b"%!PS-Adobe-3.0"));
// A value the engine doesn't take is an error.
let bad = Command::new(BIN).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
let bad = Command::new(crate::bin()).args(["convert", &src, &tmp("bad.ai"), "--ai-version", "7", "--hidden-objects", "behind"]).output().unwrap();
assert!(!bad.status.success());
assert!(String::from_utf8_lossy(&bad.stderr).contains("hiddenObjects"), "{}", String::from_utf8_lossy(&bad.stderr));
}
@@ -253,7 +251,7 @@ fn convert_and_run_write_ait_templates() {
assert_eq!(info(ait)["artboards"].as_array().unwrap().len(), 2, "{ait}");
}
// A template holds every artboard: one can't be picked.
let one = Command::new(BIN).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
let one = Command::new(crate::bin()).args(["convert", &pdf, &tmp("one.ait"), "--artboard", "0"]).output().unwrap();
assert!(!one.status.success());
assert!(String::from_utf8_lossy(&one.stderr).contains("every artboard"), "{}", String::from_utf8_lossy(&one.stderr));
}
+8
View File
@@ -6,3 +6,11 @@ mod cli;
mod fileio;
mod mcp_e2e;
mod script;
/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner.
fn bin() -> std::path::PathBuf {
std::env::var_os("NEXTEST_BIN_EXE_vectorcraft_cli")
.or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft-cli"))
.map(std::path::PathBuf::from)
.unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft-cli").into())
}
+1 -3
View File
@@ -6,8 +6,6 @@ use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
use vectorcraft_testkit::raster::Image;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
struct Mcp {
child: Child,
stdin: Option<ChildStdin>,
@@ -17,7 +15,7 @@ struct Mcp {
impl Mcp {
fn spawn() -> Self {
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(["mcp", "--headless"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
+1 -3
View File
@@ -2,11 +2,9 @@ use std::process::Command;
use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
/// `run` with `script.run` after `file.new`, with `params`.
fn run_script(params: &Value) -> std::process::Output {
Command::new(BIN).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", &params.to_string()]).output().unwrap()
Command::new(crate::bin()).args(["run", "--cmd", "file.new", "--cmd", "script.run", "--params", &params.to_string()]).output().unwrap()
}
/// `run` runs a script headless: the reply holds the report, with what the script logged and the
+5 -6
View File
@@ -1,8 +1,6 @@
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Invalid roots must exit before desktop initialization on every platform, including a CI
/// machine with no display. A regression must fail rather than hang with a window open.
fn rejects(args: &[&str], message: &str) {
@@ -10,7 +8,7 @@ fn rejects(args: &[&str], message: &str) {
}
fn rejects_with_env(args: &[&str], env: &[(&str, &str)], message: &str) {
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(args)
.env_remove("VECTORCRAFT_CONTROL_PORT")
.env_remove("VECTORCRAFT_AUTOMATION_READ_ROOT")
@@ -62,7 +60,7 @@ fn missing_empty_or_option_operands_fail_before_startup() {
#[test]
fn repeated_roots_are_accepted_for_both_spellings() {
for flag in ["--automation-read-root", "--automation-write-root"] {
let out = Command::new(BIN).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
let out = Command::new(crate::bin()).args([flag, ".", format!("{flag}=.").as_str(), flag, "./", "--version"]).output().unwrap();
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
@@ -73,7 +71,8 @@ fn repeated_roots_are_accepted_for_both_spellings() {
#[test]
fn explicit_dash_folder_does_not_consume_version() {
let out = Command::new(BIN).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
let out =
Command::new(crate::bin()).args(["--automation-read-root=-folder", "--automation-write-root", "./-folder", "--version"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
assert!(out.stderr.is_empty());
@@ -95,7 +94,7 @@ fn control_without_a_valid_port_fails_before_startup() {
#[test]
fn version_wins_over_a_bad_control_port_variable() {
let out = Command::new(BIN).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
let out = Command::new(crate::bin()).arg("--version").env("VECTORCRAFT_CONTROL_PORT", "abc").output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert_eq!(String::from_utf8(out.stdout).unwrap(), format!("vectorcraft {}\n", env!("CARGO_PKG_VERSION")));
}
+1 -3
View File
@@ -6,12 +6,10 @@ use std::process::{Command, Output, Stdio};
use std::sync::mpsc;
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft");
/// Runs the desktop binary without a display and with a control port it cannot parse, so a
/// regression exits with that error instead of opening a window.
fn run(args: &[&str], env: &[(&str, &str)]) -> Output {
let mut child = Command::new(BIN)
let mut child = Command::new(crate::bin())
.args(args)
.env_remove("DISPLAY")
.env_remove("WAYLAND_DISPLAY")
+8
View File
@@ -4,3 +4,11 @@
mod automation_args;
mod help;
/// Nextest relocates archived binaries; Cargo's compile-time path still names the build runner.
fn bin() -> std::path::PathBuf {
std::env::var_os("NEXTEST_BIN_EXE_vectorcraft")
.or_else(|| std::env::var_os("CARGO_BIN_EXE_vectorcraft"))
.map(std::path::PathBuf::from)
.unwrap_or_else(|| env!("CARGO_BIN_EXE_vectorcraft").into())
}
+36 -8
View File
@@ -46,7 +46,7 @@ impl Document {
return (0, 0);
}
let own = size_of::<Document>() + ALLOC + ARC;
let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own] };
let mut w = Walk { docs: [self, other], seen: [None, None], bytes: [own, own], appearances: Default::default() };
w.list(&self.layers, &other.layers);
for i in 0..self.symbols.len().max(other.symbols.len()) {
match (self.symbols.get(i), other.symbols.get(i)) {
@@ -134,6 +134,9 @@ struct Walk<'a> {
seen: [Option<HashSet<usize>>; 2],
/// For each side, what it keeps that the other doesn't.
bytes: [usize; 2],
/// For each side, the appearances counted so far (by address): objects that share one (a
/// graphic style applied to them all) count it once.
appearances: [HashSet<usize>; 2],
}
impl Walk<'_> {
@@ -174,8 +177,9 @@ impl Walk<'_> {
self.alone(1, b);
return;
}
self.bytes[0] += node_bytes(a, Some(b));
self.bytes[1] += node_bytes(b, Some(a));
let [ca, cb] = &mut self.appearances;
self.bytes[0] += node_bytes(a, Some(b), ca);
self.bytes[1] += node_bytes(b, Some(a), cb);
match (a.children(), b.children()) {
(Some(x), Some(y)) => self.list(x, y),
(x, y) => {
@@ -198,8 +202,8 @@ impl Walk<'_> {
if self.other_holds(side, n) {
return;
}
if let Some(b) = self.bytes.get_mut(side) {
*b += node_bytes(n, None);
if let (Some(b), Some(counted)) = (self.bytes.get_mut(side), self.appearances.get_mut(side)) {
*b += node_bytes(n, None, counted);
}
for c in n.children().into_iter().flatten() {
self.alone(side, c);
@@ -242,14 +246,15 @@ impl Walk<'_> {
}
/// One node's own heap: the node, and what it owns besides its children and mask art (counted
/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it.
fn node_bytes(n: &Node, other: Option<&Node>) -> usize {
/// as nodes of their own). Its appearance counts unless `other`, its other version, shares it, or
/// another node of the same side shares it and was counted already (its address is in `counted`).
fn node_bytes(n: &Node, other: Option<&Node>, counted: &mut HashSet<usize>) -> usize {
fn boxed<T>(b: &Option<Box<T>>) -> usize {
b.as_ref().map_or(0, |_| ALLOC + size_of::<T>())
}
let mut b = ALLOC + ARC + size_of::<Node>();
b += n.name.as_ref().map_or(0, String::capacity);
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) {
if !other.is_some_and(|m| Shared::ptr_eq(&m.appearance, &n.appearance)) && counted.insert(&*n.appearance as *const Appearance as usize) {
b += ALLOC + ARC + appearance_heap_bytes(&n.appearance);
}
if let Some(e) = n.extras_box() {
@@ -447,6 +452,29 @@ mod tests {
assert!(added > kept + 3 * 300, "{added} for three new rectangles");
}
/// Objects given one shared appearance (a graphic style applied to them all) count it once.
#[test]
fn a_shared_appearance_counts_once() {
let n = 100;
let before = doc_with_rects(n);
let ids: Vec<_> = before.layers[0].children().unwrap().iter().map(|c| c.id).collect();
let ap = Appearance::basic(Paint::None, Paint::solid(vectorcraft_color::Color::BLACK), 2.0);
let restyled = |each: bool| {
let mut d = before.clone();
let one = Shared::new(ap.clone());
for id in &ids {
d.node_mut(*id).unwrap().appearance = if each { Shared::new(ap.clone()) } else { one.clone() };
}
d
};
let (shared, copies) = (restyled(false), restyled(true));
let (kept, added) = before.retained_bytes_each(&shared);
assert_eq!(shared.retained_bytes_each(&before), (added, kept));
let (kept_copies, added_copies) = before.retained_bytes_each(&copies);
assert_eq!(kept, kept_copies, "the old version keeps the same either way");
assert_eq!(added_copies - added, (n - 1) * (ALLOC + ARC + appearance_heap_bytes(&ap)));
}
#[test]
fn a_resource_counts_only_when_changed() {
let before = doc_with_rects(1);
+8 -8
View File
@@ -176,29 +176,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), segments: integer (8..128, 64), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}),
),
g(
CLASSIC_EXTRUDE,
+78 -7
View File
@@ -112,7 +112,9 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let b = union_bounds(&shapes).ok_or(EMPTY)?;
let options = shared_options(&shapes, revolve_options(p))?;
let mut copies = 0;
return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies));
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
return check_shadow(p, casting.map(|s| &s.path), b, options);
}
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
@@ -121,7 +123,18 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?;
check_shadow(p, [&path], b, revolve_options(p))
}
/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast
/// from, about the axis of `b`, are past the limits together.
fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b: Rect, options: Revolve) -> Result<(), String> {
if crate::solids::shadow::options(p).is_none() {
return Ok(());
}
let mut caster = vectorcraft_three_d::ShadowCaster::new();
paths.into_iter().try_for_each(|path| caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options).map(|_| ()))
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
@@ -144,14 +157,16 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
let options = match at.and_then(|at| effects.get(at)) {
_ if i == target => revolve_options(p),
Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)),
let params = match at.and_then(|at| effects.get(at)) {
_ if i == target => p.clone(),
Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params),
_ => continue,
};
let options = revolve_options(&params);
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
check_shadow(&params, [&path], b, options)?;
}
Ok(())
}
@@ -189,9 +204,10 @@ fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> {
items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
/// Check Revolve's parameter values `p` alone: its lights, shadow, `edge` and `cap`.
pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
crate::solids::shadow::validate(p)?;
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
@@ -205,6 +221,43 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of
/// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with
/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when
/// the surface it is cast from is past the limits.
fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) };
let (_, opacity, blend) = paint_of(item);
if opacity <= 0.0 {
return Ok(None);
}
let mut caster = vectorcraft_three_d::ShadowCaster::new();
caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?;
Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend }))
}
/// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one,
/// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque,
/// in its paint's blending mode, and its opacity mask and blending mode apply to that part
/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the
/// limits.
fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) };
let mut caster = vectorcraft_three_d::ShadowCaster::new();
let mut cast = vec![];
for s in shapes {
let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue };
// A member whose surface is fully transparent casts nothing.
let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend };
if ink.opacity <= 0.0 {
continue;
}
let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?;
cast.push((s.scopes.as_slice(), ink, faces));
}
Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes)))
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// the bytes of the paint copies of every surface of the object.
#[allow(clippy::too_many_arguments)]
@@ -445,7 +498,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let paints = solid_paints(n);
let item = surface_item(&paints)?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?));
let surface = object_surface(n, &path, fx, item, expand, &mut copies)?;
if let Some(b) = path.bounds()
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
{
children.push(Arc::new(shadow));
}
children.push(Arc::new(surface));
// Later geometry acts on the projected art as a whole through container evaluation.
remaining = n
.appearance
@@ -458,6 +517,8 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining.retain(|e| !is_geometry(&e.id));
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = item.effects().get(index)?;
@@ -465,11 +526,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
if let Some(b) = p.bounds()
&& let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
{
shadow.appearance.effects = art.appearance.effects.clone();
shadows.push(Arc::new(shadow));
}
children.push(Arc::new(art));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
children.splice(0..0, shadows);
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
@@ -704,6 +772,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
scoped(n.id, runs.collect(), 0)
}
};
// One shadow for every member's surface, behind them all.
let mut children = children;
children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?);
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
+124 -13
View File
@@ -20,6 +20,8 @@ pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, clas
mod source;
pub(crate) use source::{resolve, resolve_for};
pub(crate) mod shadow;
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
@@ -192,6 +194,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
classic::validate(p)?;
}
crate::lighting::validate(p)?;
shadow::validate(p)?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
@@ -214,17 +217,31 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_for(doc, n, id, p, None)
}
/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item
/// `item`, whose shadow is cast by that item's solid alone.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = label_of(id);
let n = &*source::resolve_for(doc, n, label, id == ROTATE)?;
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
let (path, rule) = object_profile(n, label)?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p).map(|_| ())
facets(id, &path, rule, b, p)?;
// With its shadow on, the shadow of the object's solids (or of the item's) must fit too.
let items = match item.and_then(|i| n.appearance.items.get(i)) {
Some(item) => vec![item.clone()],
None => solid_paints(n),
};
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
cast_shadow(id, p, &shapes).map(|_| ())
}
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
@@ -297,7 +314,7 @@ fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
}
/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal?
fn has_masks(shapes: &[Profile]) -> bool {
pub(crate) fn has_masks(shapes: &[Profile]) -> bool {
shapes.iter().any(|s| s.scopes.iter().any(composites))
}
@@ -420,7 +437,8 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
_ => validate_three_d_in(doc, n, id, p),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
}
}
@@ -551,6 +569,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
}
}
/// A 3D effect's shadow and its surfaces ([`art_for`]).
type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// 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,
@@ -559,8 +580,87 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]).
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
/// off), to be drawn behind every surface of the object, and the surfaces.
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurfaces> {
let art = surfaces_for(n, fx, shapes)?;
// A shadow past the limits draws the source path, as a solid past them does; commands report
// the error ([`validate_solid_in`]).
let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? {
Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)),
None => vec![],
};
Some((shadow, art))
}
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past
/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error.
/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to
/// the part of the shadow they cast too ([`shadow::scoped_art`]).
fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> {
if id == CLASSIC_EXTRUDE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
let view = view_options(p);
let mut caster = vectorcraft_three_d::ShadowCaster::new();
// The faces each solid of visible art added, with its member's scopes and its ink (opacity
// and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing
// and does not place the plane.
let mut cast: Vec<shadow::Cast> = vec![];
let ink = |item: &AppearanceItem, member: f32| {
let (_, opacity, blend) = paint_of(item, member);
shadow::Ink { opacity, blend }
};
if id == ROTATE {
for s in shapes {
for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) {
let (area, rule) = region(&s.path, s.rule, item);
let faces = caster.plane(&area, rule, b, view)?;
cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces));
}
}
} else {
let (parts, bounds) = solid_parts(shapes, b);
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
// Inflate's eye is placed for every shape together, as the solids are.
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
} else {
let mut added = vec![];
for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) {
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
}
added
};
for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) {
if ink.opacity <= 0.0 {
continue;
}
// A stroke whose own solid is past the limits is left out; past the shadow's limit for
// all the solids together, the shadow is refused.
let faces = match faces {
Ok(faces) => faces,
Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue,
Err(e) => return Err(e),
};
cast.push((*scopes, ink, faces));
}
}
Ok(Some((caster, settings, cast)))
}
/// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with
/// its member's scopes and ink ([`cast_shadow`]).
type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec<shadow::Cast<'a>>);
/// [`art_for`] without the shadow: the solids' surfaces.
fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE;
@@ -676,7 +776,8 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?);
let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?;
children.extend(shadow.into_iter().chain(surfaces));
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
@@ -689,18 +790,24 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
}
let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
let with_later = |mut art: Arc<Node>| {
Arc::make_mut(&mut art).appearance.effects = later.clone();
art
};
shadows.extend(shadow.into_iter().map(with_later));
children.extend(surfaces.into_iter().map(with_later));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
children.splice(0..0, shadows);
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
@@ -716,7 +823,8 @@ fn group_solid_art(n: &Node) -> Option<Node> {
let (index, fx) = solid(&n.appearance.effects)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let children = art_for(n, fx, &shapes)?;
let (shadow, surfaces) = art_for(n, fx, &shapes)?;
let children = shadow.into_iter().chain(surfaces).collect();
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
@@ -906,3 +1014,6 @@ mod tests_mesh;
#[cfg(test)]
mod tests_sources;
#[cfg(test)]
mod tests_shadow;
+101
View File
@@ -0,0 +1,101 @@
//! The shadow of the 3D and Materials effects (Extrude & Bevel, Inflate, Rotate and Revolve): the
//! `shadow` switch with `shadowPosition`, `shadowDistance` and `shadowBounds`, and the shape it
//! draws behind the solids.
use std::ops::Range;
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId};
use vectorcraft_three_d::{Shadow, ShadowCaster, ShadowPlane, ShadowPosition};
use super::Scope;
use crate::util::{flag, num, text};
/// The shadow's paint: black at this opacity. VectorCraft's own values.
const SHADOW_OPACITY: f32 = 0.5;
/// The shadow `p` asks for, or `None` with the switch off or when the first light lights nothing
/// (Shading is off or its intensity is 0), so that no shadow is built.
pub(crate) fn options(p: &Value) -> Option<Shadow> {
let intensity = crate::lighting::lights(p).and_then(|l| l.iter().next().map(|l| l.intensity)).unwrap_or_else(|| num(p, "lightIntensity", 80.0));
let lit = flag(p, "shade", true) && intensity > 0.0;
(lit && flag(p, "shadow", false)).then(|| Shadow {
position: ShadowPosition::parse(text(p, "shadowPosition", "behind")).unwrap_or_default(),
distance: num(p, "shadowDistance", 0.0) / 100.0,
bounds: num(p, "shadowBounds", 100.0) / 100.0,
})
}
/// Check the shadow settings in `p`: an error for a value out of its range or of the wrong kind.
pub(crate) fn validate(p: &Value) -> Result<(), String> {
if p.get("shadow").is_some_and(|v| !v.is_boolean()) {
return Err("shadow must be true or false".into());
}
if p.get("shadowPosition").is_some_and(|v| v.as_str().and_then(ShadowPosition::parse).is_none()) {
return Err("shadowPosition must be behind or below".into());
}
for (key, min, max) in [("shadowDistance", 0.0, 100.0), ("shadowBounds", 10.0, 400.0)] {
if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(|n| n.is_finite() && (min..=max).contains(&n))) {
return Err(format!("{key} must be a number from {min} to {max}"));
}
}
Ok(())
}
/// The opacity and blending mode of the fill or stroke (and member) that casts part of a shadow.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Ink {
pub(crate) opacity: f32,
pub(crate) blend: BlendMode,
}
/// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled
/// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule;
/// `None` when it casts nothing.
pub(crate) fn art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, ink: Ink) -> Option<Node> {
let all = 0..caster.len();
let all = std::slice::from_ref(&all);
piece(id, caster, &caster.shadow_plane(shadow, all)?, all, ink)
}
/// [`art`] of the faces in the ranges `faces` of `caster` only, on `plane`.
fn piece(id: NodeId, caster: &ShadowCaster, plane: &ShadowPlane, faces: &[Range<usize>], ink: Ink) -> Option<Node> {
let path = caster.path_on(plane, faces)?;
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
fill.opacity = SHADOW_OPACITY * ink.opacity.clamp(0.0, 1.0);
fill.blend = ink.blend;
Some(Node::path(id, path, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }))
}
/// Whether `a` and `b` are the same objects' scopes (each member holds its own copies).
fn same_scopes(a: &[Scope], b: &[Scope]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.key == b.key)
}
/// The faces one solid added to a caster, with the scopes of its member and the ink of its art.
pub(crate) type Cast<'a> = (&'a [Scope], Ink, Range<usize>);
/// The faces of the solids that cast one piece of a shadow, with their scopes and ink.
type Run<'a> = (&'a [Scope], Ink, Vec<Range<usize>>);
/// The shadow `cast` casts on one plane: the faces of `caster` that the solids of visible art
/// added, each range with the scopes of the member that cast it and the ink of the art, in order.
/// Each run of solids in the same ink casts a piece in that ink ([`art`]); with `masks` (a member's
/// opacity mask or blending mode applies to its solids) the solids of a run also share their scopes,
/// and each piece is drawn in their opacity masks and blending modes ([`super::masked`]).
pub(crate) fn scoped_art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, cast: Vec<Cast<'_>>, masks: bool) -> Vec<Arc<Node>> {
let placing: Vec<Range<usize>> = cast.iter().map(|(.., faces)| faces.clone()).collect();
let Some(plane) = caster.shadow_plane(shadow, &placing) else { return vec![] };
// Consecutive solids in the same ink (and with masks, the same scopes) share a piece; the pieces
// keep the solids' order.
let mut runs: Vec<Run> = vec![];
for (scopes, ink, faces) in cast {
match runs.last_mut() {
Some(run) if run.1 == ink && (!masks || same_scopes(run.0, scopes)) => run.2.push(faces),
_ => runs.push((scopes, ink, vec![faces])),
}
}
let pieces = runs.into_iter().filter_map(|(scopes, ink, faces)| Some((scopes, Arc::new(piece(id, caster, &plane, &faces, ink)?))));
if masks { super::masked(id, pieces.collect()) } else { pieces.map(|(_, piece)| piece).collect() }
}
+225
View File
@@ -0,0 +1,225 @@
use serde_json::json;
use vectorcraft_doc::color::Color;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use super::*;
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
/// The 3D and Materials effects that cast shadows.
const CASTING: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, crate::REVOLVE];
fn square_node(id: u64, dx: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0 + dx, 100.0));
b.line_to((200.0 + dx, 100.0));
b.line_to((200.0 + dx, 200.0));
b.line_to((100.0 + dx, 200.0));
b.close_path();
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0))
}
fn art(n: &Node, id: &str) -> Option<Node> {
if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) }
}
/// The opacity of shadow `n`: a path filled black and nothing else; `None` for any other node.
fn shadow_opacity(n: &Node) -> Option<f32> {
let [AppearanceItem::Fill(fill)] = n.appearance.items.as_slice() else { return None };
(matches!(n.kind, NodeKind::Path { .. }) && fill.paint.color() == Some(Color::BLACK)).then_some(fill.opacity)
}
/// Whether `n` is the shadow of opaque art: a path filled black at 50 %, nothing else.
fn is_shadow(n: &Node) -> bool {
shadow_opacity(n) == Some(0.5)
}
fn shadows(n: &Node) -> usize {
let mut count = 0;
n.walk(&mut |c| count += usize::from(is_shadow(c)));
count
}
#[test]
fn each_3d_effect_draws_its_shadow_behind_its_solid_when_shadows_are_on() {
for id in CASTING {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &json!({})).unwrap());
let plain = art(&n, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&plain), 0, "{id}: off by default");
n.appearance.effects[0].params = json!({"shadow": true, "shadowDistance": 50});
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
let children = lit.children().unwrap();
assert!(is_shadow(&children[0]), "{id}: the shadow is drawn first");
assert_eq!(shadows(&lit), 1, "{id}");
// The default light is up and to the left: the shadow on a plane away from the object
// reaches past the solid down and right.
let shadow = children[0].visual_bounds().unwrap();
let solid = children[1..].iter().filter_map(|c| c.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(shadow.x1 > solid.x1 && shadow.y1 > solid.y1, "{id}: {shadow:?} {solid:?}");
// The art's bounds take the shadow in.
assert!(lit.visual_bounds().unwrap().contains_rect(shadow), "{id}");
}
}
#[test]
fn the_shadow_moves_with_its_settings() {
for id in CASTING {
let bounds = |params: Value| {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &params).unwrap());
let lit = art(&n, id).unwrap();
let shadow = &lit.children().unwrap()[0];
assert!(is_shadow(shadow), "{id} {params}");
shadow.visual_bounds().unwrap()
};
let near = bounds(json!({"shadow": true}));
let far = bounds(json!({"shadow": true, "shadowDistance": 100}));
assert!(far.x1 > near.x1 + 1.0, "{id}: {near:?} {far:?}");
// With the light straight in front, the outline falls about the plane's center; at 10 %
// the plane cuts it to a square 0.4 times the object's size across.
let tight = bounds(json!({"shadow": true, "shadowBounds": 10, "lightHeight": 90}));
let wide = bounds(json!({"shadow": true, "shadowBounds": 400, "lightHeight": 90}));
assert!(wide.width() > tight.width() * 2.0, "{id}: {tight:?} {wide:?}");
let below = bounds(json!({"shadow": true, "shadowPosition": "below", "rotationX": -30}));
assert_ne!(below, near, "{id}");
}
}
#[test]
fn shadow_settings_out_of_range_or_of_the_wrong_kind_are_refused() {
for id in CASTING {
for (params, error) in [
(json!({"shadow": "yes"}), "shadow must be true or false"),
(json!({"shadowPosition": "under"}), "shadowPosition must be behind or below"),
(json!({"shadowPosition": 1}), "shadowPosition must be behind or below"),
(json!({"shadowDistance": 101}), "shadowDistance must be a number from 0 to 100"),
(json!({"shadowDistance": -1}), "shadowDistance must be a number from 0 to 100"),
(json!({"shadowBounds": 5}), "shadowBounds must be a number from 10 to 400"),
(json!({"shadowBounds": "wide"}), "shadowBounds must be a number from 10 to 400"),
] {
let err = validate_three_d_params(id, &params).unwrap_err();
assert!(err.contains(error), "{id} {params}: {err}");
}
let ok = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 100, "shadowBounds": 10});
assert!(validate_three_d_params(id, &ok).is_ok(), "{id}");
}
}
#[test]
fn a_group_casts_one_shadow_for_all_its_members() {
for id in CASTING {
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 200.0))]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&lit), 1, "{id}");
let shadow = lit.children().unwrap()[0].clone();
assert!(is_shadow(&shadow), "{id}");
// With the light straight in front, the shadow behind spans both members.
let b = shadow.visual_bounds().unwrap();
assert!(b.x0 < 200.0 && b.x1 > 300.0, "{id}: {b:?}");
}
}
/// A member's opacity mask applies to the part of the shadow it casts, as to its solid: that part
/// is drawn in the mask, the other member's outside it, both on one plane.
#[test]
fn a_masked_member_casts_its_part_of_the_shadow_in_its_mask() {
for id in CASTING {
let mut masked = square_node(2, 200.0);
let mut cover = square_node(3, 200.0);
cover.appearance.items = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0).items;
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(cover, true)));
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(masked)]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&lit), 2, "{id}: one piece for each member");
let mut in_mask = 0;
lit.walk(&mut |c| {
if c.extras().mask.is_some() {
c.walk(&mut |s| in_mask += usize::from(is_shadow(s)));
}
});
assert_eq!(in_mask, 1, "{id}");
}
}
#[test]
fn transparent_art_and_a_light_that_lights_nothing_cast_no_shadow() {
for id in CASTING {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "shade": false})).unwrap());
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: Shading off");
n.appearance.effects[0].params = json!({"shadow": true, "lightIntensity": 0});
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: no intensity");
// A member at 0 % opacity casts nothing; the other member still casts its shadow.
let mut clear = square_node(2, 200.0);
clear.opacity = 0.0;
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(clear)]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
let shadow = lit.children().unwrap()[0].clone();
assert!(is_shadow(&shadow), "{id}");
let b = shadow.visual_bounds().unwrap();
assert!(b.x1 < 300.0, "{id}: only the visible member casts: {b:?}");
// Art at 50 % casts a shadow half as dark.
let mut half = square_node(1, 0.0);
for item in &mut half.appearance.items {
if let AppearanceItem::Fill(fill) = item {
fill.opacity = 0.5;
}
}
half.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let lit = art(&half, id).unwrap();
assert_eq!(shadow_opacity(&lit.children().unwrap()[0]), Some(0.25), "{id}");
// Art in a blending mode casts its shadow in that mode.
for item in &mut half.appearance.items {
if let AppearanceItem::Fill(fill) = item {
fill.blend = BlendMode::Screen;
}
}
let lit = art(&half, id).unwrap();
let AppearanceItem::Fill(fill) = &lit.children().unwrap()[0].appearance.items[0] else { panic!("{id}: a fill") };
assert_eq!(fill.blend, BlendMode::Screen, "{id}");
}
}
/// With a 3D effect on each of two fills, both fills' shadows are drawn behind both fills' solids.
#[test]
fn the_shadows_of_every_fill_go_behind_every_fill_s_solid() {
for id in CASTING {
let mut n = square_node(1, 0.0);
let fill = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap().clone();
n.appearance.items = vec![fill.clone(), fill];
for item in &mut n.appearance.items {
item.effects_mut().push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
}
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
let children = lit.children().unwrap();
assert!(children.len() >= 4, "{id}");
assert!(is_shadow(&children[0]) && is_shadow(&children[1]), "{id}: both shadows first");
assert!(children[2..].iter().all(|c| !is_shadow(c)), "{id}");
}
}
#[test]
fn expand_appearance_keeps_the_shadow_as_a_path() {
for id in CASTING {
let mut d = Document::new(400.0, 400.0);
let mut n = square_node(1, 0.0);
n.id = d.alloc_id();
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&expanded), 1, "{id}");
let mut effects = 0;
expanded.walk(&mut |c| effects += c.appearance.effects.len());
assert_eq!(effects, 0, "{id}");
// It moves with the object, as the solid does.
let before = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
n.transform(Affine::translate((40.0, 0.0)), false);
let after = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}");
}
}
+5
View File
@@ -67,6 +67,11 @@ log = { workspace = true }
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["std", "process"] }
# Whether a recovery area's lock file is still the one that was opened (`FolderStore::still_named`).
# Already in Cargo.lock (walkdir), so this adds no new crate.
[target.'cfg(windows)'.dependencies]
same-file = "1.0.6"
# The tiles of a HEIC photo decode in parallel on native; wasm stays single-threaded.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
heic-rs = { version = "=0.1.4", default-features = false, features = ["std", "parallel"], optional = true }
+53
View File
@@ -881,3 +881,56 @@ fn move_takes_several_effects_that_land_together_in_order() {
assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err());
assert_eq!(node(&s, id).appearance, before);
}
/// A 3D effect's shadow is set with `effect.setParams` in one undo step, saved in the native file,
/// drawn in exports and kept as a path by Expand Appearance; settings out of range are refused
/// without an undo step.
#[test]
fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (params, why) in [
(json!({"shadowBounds": 401}), "shadowBounds must be a number from 10 to 400"),
(json!({"shadowPosition": "left"}), "shadowPosition must be behind or below"),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap_err().to_string();
assert!(e.contains(why), "{params}: {e}");
assert_eq!(s.doc().unwrap().doc, before);
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let params = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 20, "shadowBounds": 150});
s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let art = effects::solid_art(&node(&s, id)).unwrap();
let shadow = art.children().unwrap()[0].clone();
assert!(matches!(shadow.kind, NodeKind::Path { .. }));
// Saved and opened again, the effect keeps its shadow settings and draws the same shadow.
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
let fx = &restored.node(id).unwrap().appearance.effects[0];
for (key, value) in params.as_object().unwrap() {
assert_eq!(fx.params.get(key), Some(value), "{key}");
}
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art);
// SVG export draws it: one more shape than without the shadow.
let shapes = |s: &mut Session| {
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
vectorcraft_testkit::svg_shapes(&svg)
};
let with = shapes(&mut s);
s.execute("edit.undo", &json!({})).unwrap();
let without = shapes(&mut s);
assert_eq!(with, without + 1);
s.execute("edit.redo", &json!({})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
let mut shadows = 0;
s.doc().unwrap().doc.walk(|n| {
if n.path_data().is_some() && n.path_data() == shadow.path_data() && n.appearance.items == shadow.appearance.items {
shadows += 1;
}
});
assert_eq!(shadows, 1);
}
+42 -10
View File
@@ -1,8 +1,8 @@
//! EPS export for every EPS path (`document.export {format: eps}`, `document.exportEps`, Export As
//! → EPS Options): the options parsed from the params, transparency flattened with a flattener
//! preset, one file of the art's bounds or one per artboard (with a central file of the whole
//! document when there are several and art shows), the preview and thumbnail rendered,
//! and the native document embedded for reopening.
//! document when there are several and art shows), the preview rendered and the thumbnail made
//! from it, and the native document embedded for reopening.
use std::borrow::Cow;
@@ -11,6 +11,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_eps::{EpsOptions, Level, Overprint, Preview, Raster};
use vectorcraft_geom::{Point, Rect};
use vectorcraft_render::Rendered;
use super::super::flatten::{FlattenOptions, expand_transparent_tile_images, flatten_expanded};
use super::super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, has_doc, str_param};
@@ -231,14 +232,38 @@ fn pages(doc: &Document, set: &Settings) -> Result<Vec<(Option<usize>, Rect, Poi
}
}
/// `rect` of `doc` drawn at a pixel a point (fewer when larger than `max` a side), straight RGBA,
/// transparent or on white.
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Raster> {
/// Pixels a point for `rect`: one, or fewer when it is larger than `max` a side.
fn fit(rect: Rect, max: f64) -> f64 {
let long = rect.width().max(rect.height());
let scale = if long > max { max / long } else { 1.0 };
if long > max { max / long } else { 1.0 }
}
/// `rect` of `doc` drawn at [`fit`]'s scale for `max`, transparent or on white.
fn render(r: &mut vectorcraft_render::Renderer, doc: &Document, rect: Rect, max: f64, transparent: bool) -> Result<Rendered> {
let scale = fit(rect, max);
vectorcraft_render::raster_size(rect, scale).map_err(|e| bad(C, e))?;
let img = r.render_region(doc, rect, scale, !transparent);
Ok(Raster { width: img.width, height: img.height, rgba: img.to_straight() })
Ok(r.render_region(doc, rect, scale, !transparent))
}
/// `img` as the writer takes it: straight RGBA.
fn straight(img: &Rendered) -> Raster {
Raster { width: img.width, height: img.height, rgba: img.to_straight() }
}
/// The thumbnail of `rect` made from `preview`, the same rect rendered larger: at the size a
/// thumbnail render of `rect` has, each pixel the average of the preview's pixels under it, on white.
fn thumbnail_of(preview: &Rendered, rect: Rect) -> Result<Raster> {
let (width, height) = vectorcraft_render::raster_size(rect, fit(rect, THUMBNAIL)).map_err(|e| bad(C, e))?;
let mut rgba = preview.area_resized(width, height).map_err(|e| bad(C, e))?.pixels;
// Premultiplied over white: each channel gains the white the pixel's alpha leaves showing.
for px in rgba.as_chunks_mut::<4>().0 {
let clear = 255 - px[3];
for c in &mut px[..3] {
*c = c.saturating_add(clear);
}
px[3] = 255;
}
Ok(Raster { width, height, rgba })
}
/// What the file carries to reopen: `doc` (with only artboard `board` when the file is one of
@@ -318,11 +343,18 @@ fn encode_document(doc: &Document, p: &Value, saving: bool) -> Result<Encoded> {
let mut renderer = vectorcraft_render::Renderer::new();
for (board, region, origin) in pages {
let rect = vectorcraft_eps::preview_rect(region, origin);
let preview = match set.eps.preview {
let shown = match set.eps.preview {
Preview::None => None,
kind => Some(render(&mut renderer, doc, rect, preview_limit, set.eps.transparent_preview && kind == Preview::TiffColor)?),
};
let thumbnail = set.thumbnails.then(|| render(&mut renderer, doc, rect, THUMBNAIL, false)).transpose()?;
// The thumbnail is made from the preview; only a file without one renders it.
let thumbnail = match (set.thumbnails, &shown) {
(false, _) => None,
(true, Some(img)) => Some(thumbnail_of(img, rect)?),
(true, None) => Some(straight(&render(&mut renderer, doc, rect, THUMBNAIL, false)?)),
};
let preview = shown.as_ref().map(straight);
drop(shown);
let o = EpsOptions {
region,
origin,
+100
View File
@@ -218,6 +218,106 @@ fn placed_images_are_written_and_previews_are_black_and_white_on_request() {
assert!(l3.contains("/FlateDecode filter >> image"));
}
/// The PNG thumbnail of an EPS file, decoded.
fn thumbnail_png(bytes: &[u8]) -> image::RgbaImage {
let png = vectorcraft_eps::thumbnail(bytes).expect("a thumbnail");
image::load_from_memory_with_format(&png, image::ImageFormat::Png).unwrap().to_rgba8()
}
/// The TIFF preview of an EPS file, decoded.
fn preview_tiff(bytes: &[u8]) -> image::RgbaImage {
let tiff = vectorcraft_eps::sections(bytes).unwrap().1.expect("a preview");
image::load_from_memory_with_format(tiff, image::ImageFormat::Tiff).unwrap().to_rgba8()
}
/// The size of a thumbnail render of the rect an EPS file's preview shows (its bounding box).
fn thumbnail_size(bytes: &[u8]) -> (u32, u32) {
let b: Vec<f64> = dsc(&postscript(bytes), "BoundingBox").unwrap().split(' ').map(|v| v.parse().unwrap()).collect();
let rect = vectorcraft_geom::Rect::new(b[0], b[1], b[2], b[3]);
let long = rect.width().max(rect.height());
vectorcraft_render::raster_size(rect, if long > 128.0 { 128.0 / long } else { 1.0 }).unwrap()
}
/// The thumbnail is the color preview made smaller, the area average of its pixels at the size a
/// 128 px render has, not a second render of the document; it shows the same picture as one.
#[test]
fn the_thumbnail_is_made_from_the_preview() {
let mut s = session(1000.0, 700.0, 1);
s.execute("paint.setFill", &json!({"color": "#2060c0"})).unwrap();
s.execute("shape.ellipse", &json!({"x": 37.3, "y": 41.9, "width": 811.7, "height": 563.1})).unwrap();
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": "tiffColor", "transparentPreview": false})).unwrap();
let bytes = b64(&r);
let preview = preview_tiff(&bytes);
assert_eq!(preview.dimensions(), (1000, 700));
let thumb = thumbnail_png(&bytes);
assert_eq!(thumb.dimensions(), (128, 90));
// An opaque preview's straight pixels are its premultiplied ones.
let shown = vectorcraft_render::Rendered { width: 1000, height: 700, pixels: preview.into_raw() };
assert!(thumb.as_raw() == &shown.area_resized(128, 90).unwrap().pixels, "the thumbnail isn't the preview made smaller");
let doc = &s.doc().unwrap().doc;
let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 1000.0, 700.0), 0.128, true);
let mut diffs: Vec<u8> = thumb.as_raw().iter().zip(&fresh.pixels).map(|(a, b)| a.abs_diff(*b)).collect();
diffs.sort_unstable();
let mean = diffs.iter().map(|d| f64::from(*d)).sum::<f64>() / diffs.len() as f64;
let p95 = diffs[diffs.len() * 95 / 100];
assert!(mean <= 8.0 && p95 <= 32, "mean {mean}, 95th percentile {p95}");
}
/// A thumbnail is opaque color on white whatever the preview: a transparent color preview's, and a
/// black-and-white preview's, which keeps the art's colors.
#[test]
fn thumbnails_of_transparent_and_black_and_white_previews_are_opaque_color() {
let mut s = session(400.0, 200.0, 1);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap();
let doc = &s.doc().unwrap().doc;
let fresh = vectorcraft_render::Renderer::new().render_region(doc, vectorcraft_geom::Rect::new(0.0, 0.0, 400.0, 200.0), 0.32, true);
let red: [u8; 4] = fresh.pixels[(32 * 128 + 10) * 4..][..4].try_into().unwrap();
for preview in ["tiffColor", "tiffBw"] {
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap();
let thumb = thumbnail_png(&b64(&r));
assert_eq!(thumb.dimensions(), (128, 64), "{preview}");
assert_eq!(thumb.get_pixel(10, 32).0, red, "{preview}");
assert_eq!(thumb.get_pixel(120, 32).0, [255, 255, 255, 255], "{preview}");
assert!(thumb.pixels().all(|p| p[3] == 255), "{preview}");
}
}
/// A page that fits in a thumbnail gets its preview's own pixels, unresampled.
#[test]
fn a_small_page_s_thumbnail_is_its_preview() {
let mut s = session(100.0, 60.0, 1);
s.execute("shape.ellipse", &json!({"x": 10.5, "y": 7.25, "width": 70, "height": 41})).unwrap();
let bytes = b64(&s.execute("document.exportEps", &json!({"useArtboards": true, "transparentPreview": false})).unwrap());
let thumb = thumbnail_png(&bytes);
assert_eq!(thumb.dimensions(), (100, 60));
assert!(thumb == preview_tiff(&bytes), "the thumbnail isn't the preview");
}
/// Every file has a thumbnail at the size a thumbnail render has, with any page shape, on Save
/// too and without a preview; with thumbnails off there is none.
#[test]
fn every_file_s_thumbnail_has_the_thumbnail_size() {
let mut s = session(333.3, 77.7, 2);
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("shape.rectangle", &json!({"x": 3.7, "y": 5.1, "width": 300, "height": 61.3})).unwrap();
s.execute("shape.ellipse", &json!({"x": second + 1.0, "y": 2.0, "width": 20, "height": 70})).unwrap();
for preview in ["tiffColor", "tiffBw", "none"] {
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "previewFormat": preview})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 3);
for f in files {
let bytes = b64(f);
assert_eq!(thumbnail_png(&bytes).dimensions(), thumbnail_size(&bytes), "{preview} {}", f["name"]);
}
}
assert_eq!(thumbnail_size(&b64(&s.execute("document.exportEps", &json!({"range": "1"})).unwrap())), (128, 30));
let saved = b64(&s.execute("document.save", &json!({"format": "eps", "previewFormat": "tiffColor"})).unwrap());
assert_eq!(thumbnail_png(&saved).dimensions(), thumbnail_size(&saved));
let r = s.execute("document.exportEps", &json!({"useArtboards": true, "thumbnails": false})).unwrap();
assert!(r["files"].as_array().unwrap().iter().all(|f| vectorcraft_eps::thumbnail(&b64(f)).is_none()));
}
/// Two placed images whose pixel data can't be decoded are left out of the EPS, and `warnings`
/// holds the note raster exports give, once, without naming either image's key.
#[test]
+90 -19
View File
@@ -111,8 +111,13 @@ pub trait RecoveryStore: Send + Sync {
fn announced(&self, _area: &str) -> Option<bool> {
None
}
/// Tidy away area `area` once it holds no copies and nobody holds it (its lock file and folder).
/// Tidy away area `area` once it holds no copies (its lock file and folder). The caller holds
/// the area until this returns.
fn remove_area(&self, _area: &str) {}
/// Remove area `area`'s folder if it is empty, after [`RecoveryStore::remove_area`] and after the
/// caller let the area go. On some file systems a file that is open is removed only when it is
/// closed, so `remove_area` leaves the folder there while the area is held.
fn remove_empty_area(&self, _area: &str) {}
/// The clock heartbeats are judged by (Unix seconds; `None`: none, so no area counts as gone).
fn now(&self) -> Option<i64> {
vectorcraft_doc::metadata::now_unix()
@@ -295,6 +300,73 @@ impl FolderStore {
.filter(|n| Self::plain(n))
.collect())
}
/// Lock area folder `dir`, which was just made. Another app tidies an empty area away
/// (`tidy`) by removing its lock file and folder while it holds the lock. So when the folder
/// or the lock file is gone by now, or the lock taken is on a lock file the folder no longer
/// has, that app had the area: it is [`Lock::Busy`], and the caller tries another one.
pub(crate) fn lock_area(dir: &std::path::Path) -> std::result::Result<Lock, String> {
if let Err(e) = Self::own_area(dir) {
let gone = std::fs::symlink_metadata(dir).is_err_and(|e| e.kind() == std::io::ErrorKind::NotFound);
return if gone { Ok(Lock::Busy) } else { Err(e) };
}
let path = dir.join(LOCK_FILE);
match std::fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(&path) {
Ok(file) => Self::take(file, &path),
Err(e) => Self::open_failed(e, &path),
}
}
/// Opening lock file `path` failed with `e`. When the folder is gone by now, another app had the
/// area: it is [`Lock::Busy`]. So it is when the lock file is there but can't be opened: some
/// file systems keep a removed file that another app still has open until that app closes it,
/// and refuse to open it meanwhile. A lock file that can't be made is an error.
pub(crate) fn open_failed(e: std::io::Error, path: &std::path::Path) -> std::result::Result<Lock, String> {
match e.kind() {
std::io::ErrorKind::NotFound => Ok(Lock::Busy),
std::io::ErrorKind::PermissionDenied if std::fs::symlink_metadata(path).is_ok() => Ok(Lock::Busy),
_ => Err(format!("{}: {e}", path.display())),
}
}
/// Lock `file`, opened as lock file `path`. The lock holds the area only while `path` still
/// names that file.
pub(crate) fn take(file: std::fs::File, path: &std::path::Path) -> std::result::Result<Lock, String> {
match file.try_lock() {
Ok(()) if Self::still_named(&file, path) => Ok(Lock::Held(Box::new(file))),
Ok(()) | Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
// The lock file was removed meanwhile, and locking it failed instead.
Err(std::fs::TryLockError::Error(_)) if !Self::still_named(&file, path) => Ok(Lock::Busy),
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
}
}
/// Does `path` still name `file`? On Unix the two must be the same file (device and inode); on
/// Windows they must be the same file (volume serial number and file index); elsewhere `path`
/// must exist. When either file can't be read, it does not.
fn still_named(file: &std::fs::File, path: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt as _;
match (file.metadata(), std::fs::symlink_metadata(path)) {
(Ok(a), Ok(b)) => a.dev() == b.dev() && a.ino() == b.ino(),
_ => false,
}
}
#[cfg(windows)]
{
let opened = file.try_clone().and_then(same_file::Handle::from_file);
match (opened, same_file::Handle::from_path(path)) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
#[cfg(not(any(unix, windows)))]
{
let _ = file;
std::fs::symlink_metadata(path).is_ok()
}
}
}
#[cfg(not(target_arch = "wasm32"))]
@@ -342,29 +414,23 @@ impl RecoveryStore for FolderStore {
fn lock(&self, area: &str) -> std::result::Result<Lock, String> {
let dir = self.area(area)?;
Self::create_area(&dir)?;
Self::own_area(&dir)?;
let path = dir.join(LOCK_FILE);
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.map_err(|e| format!("{}: {e}", path.display()))?;
match file.try_lock() {
Ok(()) => Ok(Lock::Held(Box::new(file))),
Err(std::fs::TryLockError::WouldBlock) => Ok(Lock::Busy),
Err(std::fs::TryLockError::Error(e)) => Err(format!("{}: {e}", path.display())),
}
Self::lock_area(&dir)
}
fn remove_area(&self, area: &str) {
// Best effort: whatever is left is tidied at a later launch. A folder that isn't empty
// (copies came back meanwhile) stays.
// (copies came back meanwhile) stays. The caller still holds the lock here.
if let Ok(dir) = self.area(area) {
let _ = std::fs::remove_file(dir.join(LOCK_FILE));
let _ = std::fs::remove_dir(&dir);
}
}
fn remove_empty_area(&self, area: &str) {
// Best effort, as above. An app that made the folder again meanwhile and has not locked it
// yet finds it gone and takes another area (`lock_area`); one that has keeps it.
if let Ok(dir) = self.area(area) {
let _ = std::fs::remove_dir(&dir);
}
}
}
/// The area this session keeps its copies in, while it holds it.
@@ -527,8 +593,10 @@ pub fn release(s: &mut Session) {
for name in store.list().unwrap_or_default().iter().filter(|n| mine(n)) {
let _ = store.remove(name);
}
drop(hold);
// Held until it is gone, as in `tidy`.
store.remove_area(&area);
drop(hold);
store.remove_empty_area(&area);
}
/// Let go of this session's area but leave its copies, as a crash would, so the next app started
@@ -967,7 +1035,9 @@ fn owner(s: &mut Session, store: &dyn RecoveryStore, area: &str, hold: &mut Vec<
}
}
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`).
/// Tidy away a gone app's area `area` once no copies are left in it, then let it go (`hold`). The
/// area is removed while it is still held, so an app taking a new area of that name meanwhile
/// finds it held or gone and takes the next name.
fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec<Hold>) {
let names = store.list().unwrap_or_default();
let mine: Vec<&String> = names.iter().filter(|n| n.split_once('/').is_some_and(|(a, _)| a == area)).collect();
@@ -977,8 +1047,9 @@ fn tidy(store: &dyn RecoveryStore, area: &str, hold: Vec<Hold>) {
for n in mine {
let _ = store.remove(n);
}
drop(hold);
store.remove_area(area);
drop(hold);
store.remove_empty_area(area);
}
/// The copies of documents open now in this session.
+17 -9
View File
@@ -13,11 +13,12 @@
//! to loses its characters' own fill (stroke) when the style has fills (strokes), so the style's
//! paint shows instead of the character colour under it.
use std::borrow::Cow;
use std::collections::HashMap;
use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind};
use vectorcraft_doc::{Appearance, DEFAULT_GRAPHIC_STYLE, Document, GraphicStyle, Node, NodeId, NodeKind, Shared};
use vectorcraft_geom::Rect;
use super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, index, object_param, ok, str_param, unique_name};
@@ -260,9 +261,14 @@ fn override_chars(n: &mut Node, ap: &Appearance) {
}
/// Give `n` style `g`: its appearance (a group or layer too: its own, above its contents; type its
/// characters' colour too with `chars`, Override Character Color) and transparency.
fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) {
n.appearance = g.appearance_on(n).into_owned().into();
/// characters' colour too with `chars`, Override Character Color) and transparency. Objects that
/// take `g`'s appearance as it is share one copy of it, made in `shared` the first time; an object
/// whose placed gradients land on its own bounds gets its own.
fn style_node(n: &mut Node, g: &GraphicStyle, shared: &mut Option<Shared<Appearance>>, chars: bool) {
n.appearance = match g.appearance_on(n) {
Cow::Borrowed(ap) => shared.get_or_insert_with(|| Shared::new(ap.clone())).clone(),
Cow::Owned(ap) => ap.into(),
};
if chars {
override_chars(n, &g.appearance);
}
@@ -270,9 +276,9 @@ fn style_node(n: &mut Node, g: &GraphicStyle, chars: bool) {
}
/// Give `id` style `g` ([`style_node`]) and link it.
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, chars: bool) {
fn apply_style(d: &mut Document, id: NodeId, g: &GraphicStyle, shared: &mut Option<Shared<Appearance>>, chars: bool) {
if let Some(n) = d.node_mut(id) {
style_node(n, g, chars);
style_node(n, g, shared, chars);
n.extras_mut().graphic_style = Some(g.id);
super::object::snap_if_aligned(n);
}
@@ -313,7 +319,7 @@ impl Session {
/// Color as set): the Graphic Styles panel's previews.
pub fn styled(&self, n: &Node, g: &GraphicStyle) -> Node {
let mut n = n.clone();
style_node(&mut n, g, self.prefs.override_char_color);
style_node(&mut n, g, &mut None, self.prefs.override_char_color);
n
}
}
@@ -354,8 +360,9 @@ fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
} else {
d.graphic_style_id(i);
let g = d.graphic_styles[i].clone();
let mut shared = None;
for id in &ids {
apply_style(d, *id, &g, chars);
apply_style(d, *id, &g, &mut shared, chars);
}
super::appearance::capture_series_writes(&before, d, &ids, (true, true), (true, true), false, true);
}
@@ -474,8 +481,9 @@ fn style_redefine(s: &mut Session, p: &Value) -> Result<Value> {
let (follow, edited): (Vec<NodeId>, Vec<NodeId>) = linked.into_iter().partition(|nid| d.node(*nid).is_some_and(|n| in_sync(n, &old)));
set_link(d, &edited, None);
let g = d.graphic_styles[i].clone();
let mut shared = None;
for nid in &follow {
apply_style(d, *nid, &g, chars);
apply_style(d, *nid, &g, &mut shared, chars);
}
super::appearance::capture_series_writes(&before, d, &follow, (true, true), (true, true), false, true);
set_link(d, &[src], Some(id));
+38
View File
@@ -132,6 +132,44 @@ fn graphic_styles_keep_gradients_relative_to_the_bounds() {
assert!(close(fill_geom(&s, dst).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored");
}
/// Applying a style with a placed gradient to objects of different sizes gives each its own
/// appearance with the gradient on its own bounds, through undo and redo; a style from an older
/// file applies the same stored gradient to each, as one shared appearance.
#[test]
fn a_style_places_its_gradient_on_each_object() {
let (mut s, src, dst) = source_and_target();
let other = rect(&mut s, 100.0, 300.0, 50.0, 200.0);
s.execute("select.set", &json!({"ids": [src.0]})).unwrap();
s.execute("graphicStyle.new", &json!({"name": "Glow"})).unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Glow", "ids": [dst.0, other.0]})).unwrap();
let placed = |s: &Session| {
let (g, h) = (fill_geom(s, dst).unwrap(), fill_geom(s, other).unwrap());
close(g.start, Point::new(540.0, 60.0))
&& close(g.end, Point::new(660.0, 60.0))
&& close(h.start, Point::new(110.0, 400.0))
&& close(h.end, Point::new(140.0, 400.0))
};
let shared = |s: &Session| {
let d = &s.doc().unwrap().doc;
vectorcraft_doc::Shared::ptr_eq(&d.node(dst).unwrap().appearance, &d.node(other).unwrap().appearance)
};
assert!(placed(&s) && !shared(&s));
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.redo", &json!({})).unwrap();
assert!(placed(&s) && !shared(&s));
let glow = s.doc().unwrap().doc.graphic_styles.iter().find(|g| g.name == "Glow").cloned().unwrap();
let legacy = vectorcraft_doc::GraphicStyle { name: "Old".into(), id: 0, unit_box: false, ..glow };
s.edit("Add", |d, _| {
d.graphic_styles.push(legacy);
Ok(())
})
.unwrap();
s.execute("graphicStyle.apply", &json!({"name": "Old", "ids": [dst.0, other.0]})).unwrap();
assert!(shared(&s));
assert!(close(fill_geom(&s, other).unwrap().start, Point::new(0.2, 0.5)), "legacy styles are applied as stored");
}
#[test]
fn eyedropper_options_choose_what_copy_from_takes() {
let mut s = session();
+199
View File
@@ -483,6 +483,205 @@ fn the_folder_store_reads_no_linked_entries() {
let _ = std::fs::remove_dir_all(&base);
}
/// A store in memory that notes each removal of an area (its lock file and folder, then its empty
/// folder) and whether the area was still held then.
#[derive(Default)]
struct Tidying {
inner: MemoryStore,
removals: std::sync::Mutex<Vec<(&'static str, bool)>>,
}
impl Tidying {
fn note(&self, what: &'static str, area: &str) {
let held = matches!(self.inner.lock(area), Ok(cmd::recovery::Lock::Busy));
self.removals.lock().unwrap().push((what, held));
}
}
impl RecoveryStore for Tidying {
fn list(&self) -> std::result::Result<Vec<String>, String> {
self.inner.list()
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> {
self.inner.read(name)
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
self.inner.write(name, bytes)
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
self.inner.remove(name)
}
fn location(&self) -> String {
self.inner.location()
}
fn lock(&self, area: &str) -> std::result::Result<cmd::recovery::Lock, String> {
self.inner.lock(area)
}
fn remove_area(&self, area: &str) {
self.note("area", area);
}
fn remove_empty_area(&self, area: &str) {
self.note("empty folder", area);
}
fn now(&self) -> Option<i64> {
self.inner.now()
}
}
/// An app tidying an area away (a crashed app's, or its own when it quits) removes it while it
/// still holds it, so another app taking a new area of that name can't lock it in between. Once it
/// let the area go, it removes the folder again if that is empty.
#[test]
fn areas_are_removed_while_still_held() {
let store = Arc::new(Tidying::default());
let app = || {
let mut s = Session::new();
s.recovery.set_store(store.clone());
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s
};
let mut a = app();
a.execute("file.recovery.save", &json!({})).unwrap();
drop(a);
let mut b = app();
assert_eq!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().len(), 1);
b.execute("file.recovery.save", &json!({})).unwrap();
b.execute("file.close", &json!({})).unwrap();
cmd::recovery::forget_all(&mut b);
let tidy = [("area", true), ("empty folder", false)];
assert_eq!(*store.removals.lock().unwrap(), [tidy, tidy].concat(), "discard's tidy, then quitting");
}
/// Another app tidied an empty area away between this app making it and locking it: the area is
/// busy, not held and not an error.
#[test]
fn an_area_tidied_away_before_it_is_locked_is_busy() {
use cmd::recovery::Lock;
let base = std::env::temp_dir().join(format!("vc-recovery-tidied-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let store = FolderStore::new(&base);
drop(store.lock("5-1").unwrap());
let area = base.join("5-1");
let path = area.join(".lock");
let open = |create: bool| std::fs::OpenOptions::new().read(true).write(true).create(create).truncate(false).open(&path).unwrap();
// The lock file was opened, then removed before it was locked.
let opened = open(false);
std::fs::remove_file(&path).unwrap();
assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy)));
// The folder went before it was checked or its lock file opened.
std::fs::remove_dir(&area).unwrap();
assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy)));
// A lock file made again under the same name is not the one opened, also where it keeps the
// removed one's creation time (NTFS does within 15 seconds).
#[cfg(any(unix, windows))]
{
std::fs::create_dir_all(&area).unwrap();
let opened = open(true);
std::fs::remove_file(&path).unwrap();
std::fs::write(&path, b"").unwrap();
assert!(matches!(FolderStore::take(opened, &path), Ok(Lock::Busy)));
assert!(matches!(FolderStore::take(open(false), &path), Ok(Lock::Held(_))));
}
let _ = std::fs::remove_dir_all(&base);
}
/// Opening an area's lock file failed. When the folder went after it was checked, or the lock file
/// is there but can't be opened (a removed lock file that another app still has open, on file
/// systems that keep it until it is closed), the area is busy. A lock file that can't be made is an
/// error.
#[test]
fn a_lock_file_that_is_gone_or_cannot_be_opened_is_busy() {
use cmd::recovery::Lock;
use std::io::ErrorKind::{NotFound, PermissionDenied};
let base = std::env::temp_dir().join(format!("vc-recovery-unopened-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(&base).unwrap();
let path = base.join(".lock");
let failed = |kind: std::io::ErrorKind| FolderStore::open_failed(kind.into(), &path);
assert!(matches!(failed(NotFound), Ok(Lock::Busy)));
assert!(failed(PermissionDenied).is_err());
std::fs::write(&path, b"").unwrap();
assert!(matches!(failed(PermissionDenied), Ok(Lock::Busy)));
#[cfg(unix)]
if !rustix::process::geteuid().is_root() {
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &std::path::Path, m: u32| std::fs::set_permissions(p, std::fs::Permissions::from_mode(m)).unwrap();
// A lock file that is there but can't be opened.
let area = base.join("5-1");
std::fs::create_dir(&area).unwrap();
std::fs::write(area.join(".lock"), b"").unwrap();
mode(&area.join(".lock"), 0o400);
assert!(matches!(FolderStore::lock_area(&area), Ok(Lock::Busy)));
// A folder in which no lock file can be made.
let area = base.join("5-2");
std::fs::create_dir(&area).unwrap();
mode(&area, 0o500);
let r = FolderStore::lock_area(&area);
mode(&area, 0o700);
assert!(r.is_err());
}
let _ = std::fs::remove_dir_all(&base);
}
/// A folder store in which another app tidies the first area away as soon as it is made.
struct TidiedFirst {
inner: FolderStore,
dir: std::path::PathBuf,
first: std::sync::atomic::AtomicBool,
}
impl RecoveryStore for TidiedFirst {
fn list(&self) -> std::result::Result<Vec<String>, String> {
self.inner.list()
}
fn read(&self, name: &str) -> std::result::Result<Vec<u8>, String> {
self.inner.read(name)
}
fn write(&self, name: &str, bytes: &[u8]) -> std::result::Result<(), String> {
self.inner.write(name, bytes)
}
fn remove(&self, name: &str) -> std::result::Result<(), String> {
self.inner.remove(name)
}
fn location(&self) -> String {
self.inner.location()
}
fn areas(&self) -> std::result::Result<Vec<String>, String> {
self.inner.areas()
}
fn lock(&self, area: &str) -> std::result::Result<cmd::recovery::Lock, String> {
if !self.first.swap(false, std::sync::atomic::Ordering::SeqCst) {
return self.inner.lock(area);
}
let dir = self.dir.join(area);
std::fs::create_dir_all(&dir).unwrap();
std::fs::remove_dir(&dir).unwrap();
FolderStore::lock_area(&dir)
}
fn remove_area(&self, area: &str) {
self.inner.remove_area(area);
}
}
#[test]
fn a_recovery_save_takes_the_next_area_when_the_first_is_tidied_away() {
let dir = std::env::temp_dir().join(format!("vc-recovery-next-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let store = Arc::new(TidiedFirst { inner: FolderStore::new(&dir), dir: dir.clone(), first: true.into() });
let mut s = Session::new();
s.recovery.set_store(store.clone());
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
let r = s.execute("file.recovery.save", &json!({})).unwrap();
let area = s.recovery.own_area().unwrap().to_string();
assert!(area.ends_with("-2"), "{area}");
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1").as_str(), "{r}");
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
drop(s);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_reverting_or_closing_removes_the_copy() {
let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id()));
+72 -1
View File
@@ -3,7 +3,7 @@
use serde_json::{Value, json};
use vectorcraft_color::{BlendMode, Color, Paint};
use vectorcraft_doc::{GraphicStyle, Knockout, Node};
use vectorcraft_doc::{GraphicStyle, Knockout, Node, Shared};
use crate::*;
@@ -202,6 +202,77 @@ fn redefine_leaves_unlinked_and_edited_objects_alone() {
assert!(s.execute("graphicStyle.redefine", &json!({"id": c.0})).is_err());
}
/// Applying a style to several objects gives them one shared copy of its appearance; editing one
/// object gives that one its own, and the saved file is the same as with a copy for each.
#[test]
fn applying_a_style_shares_one_appearance() {
let mut s = session();
let a = rect(&mut s, 10.0);
run(&mut s, "appearance.clear", json!({}));
run(&mut s, "paint.setFill", json!({"color": "#00ff00"}));
run(&mut s, "paint.setStroke", json!({"color": "#0000ff"}));
run(&mut s, "effect.apply", json!({"effect": "distort.roughen"}));
run(&mut s, "graphicStyle.new", json!({"name": "Rough"}));
let rough = style(&s, "Rough");
let ids = [rect(&mut s, 100.0), rect(&mut s, 200.0), rect(&mut s, 300.0)];
let [b, c, d] = ids;
run(&mut s, "graphicStyle.apply", json!({"name": "Rough", "ids": [b.0, c.0, d.0]}));
let shared = |s: &Session, x: NodeId, y: NodeId| Shared::ptr_eq(&node(s, x).appearance, &node(s, y).appearance);
assert!(shared(&s, b, c) && shared(&s, c, d), "one appearance for every object");
for id in ids {
assert_eq!(node(&s, id).appearance, rough.appearance);
assert_eq!(node(&s, id).extras().graphic_style, Some(rough.id));
}
// The saved file is the same as one where each object has its own copy.
let doc = s.doc().unwrap().doc.clone();
let mut copies = (*doc).clone();
for id in ids {
let n = copies.node_mut(id).unwrap();
n.appearance = Shared::new((*n.appearance).clone());
}
assert!(!Shared::ptr_eq(&copies.node(b).unwrap().appearance, &copies.node(c).unwrap().appearance));
for pretty in [false, true] {
assert_eq!(vectorcraft_format::save(&doc, pretty), vectorcraft_format::save(&copies, pretty));
}
// Editing one object leaves the others and the style as they were.
set_fill(&mut s, b, "#ff0000");
assert!(!shared(&s, b, c) && shared(&s, c, d));
assert_eq!(fill(&s, b), Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!(node(&s, c).appearance, rough.appearance);
assert_eq!(style(&s, "Rough"), rough);
assert_eq!(linked(&mut s, "Rough"), vec![a.0, c.0, d.0]);
// Undo and redo restore the values, still shared.
run(&mut s, "edit.undo", json!({}));
assert!(shared(&s, b, c) && node(&s, b).appearance == rough.appearance);
run(&mut s, "edit.undo", json!({}));
for id in ids {
assert_ne!(node(&s, id).appearance, rough.appearance);
assert_eq!(node(&s, id).extras().graphic_style, None);
}
run(&mut s, "edit.redo", json!({}));
assert!(shared(&s, b, c) && shared(&s, c, d) && node(&s, d).appearance == rough.appearance);
}
/// Redefine Graphic Style gives the objects still linked one shared copy of the new appearance.
#[test]
fn redefine_shares_one_appearance() {
let mut s = session();
let (a, b, c) = linked_three(&mut s);
let d = rect(&mut s, 300.0);
run(&mut s, "graphicStyle.apply", json!({"name": "S"}));
set_fill(&mut s, d, "#00ff00");
set_fill(&mut s, a, "#0000ff");
run(&mut s, "graphicStyle.redefine", json!({"name": "S", "id": a.0}));
let (nb, nc) = (node(&s, b), node(&s, c));
assert!(Shared::ptr_eq(&nb.appearance, &nc.appearance));
assert_eq!(nb.appearance, style(&s, "S").appearance);
// The edited object keeps its own look.
assert_eq!(fill(&s, d), Paint::solid(Color::from_hex("#00ff00").unwrap()));
assert!(!Shared::ptr_eq(&node(&s, d).appearance, &nb.appearance));
}
#[test]
fn deleting_a_style_unlinks_and_renaming_keeps_links() {
let mut s = session();
+7 -1
View File
@@ -1395,7 +1395,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"ui.action.insertStop",
"Insert Stop",
"",
"{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
"{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
"ui.action.insertMenuItem",
"Insert Menu Item",
"",
"{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
"ui.action.continue",
+62
View File
@@ -264,6 +264,25 @@ pub(crate) fn downsample(src: &[u8], w: usize, h: usize, k: usize) -> Vec<u8> {
out
}
/// For each of `to` pixels spread over `from` (both above 0), the `from` pixels under it and the
/// share of it each covers (summing to 1).
fn area_spans(from: usize, to: usize) -> Vec<Vec<(usize, f64)>> {
let step = from as f64 / to as f64;
(0..to)
.map(|i| {
let (start, end) = ((i * from) as f64 / to as f64, ((i + 1) * from) as f64 / to as f64);
let first = start.floor() as usize;
let last = (end.ceil() as usize).max(first + 1).min(from);
(first..last)
.filter_map(|j| {
let cover = end.min((j + 1) as f64) - start.max(j as f64);
(cover > 0.0).then_some((j, cover / step))
})
.collect()
})
.collect()
}
/// A rendered image (premultiplied RGBA8, row-major).
pub struct Rendered {
pub width: u32,
@@ -285,6 +304,49 @@ impl Rendered {
}
out
}
/// This image at `width`×`height` pixels: each new pixel is the average of the area of this one
/// under it, the pixels at its edges weighted by how much of them it covers. Colors are averaged
/// premultiplied, so transparent pixels add no color. At the same size it is a copy. An error
/// when a side is 0 or the pixel buffer doesn't match the image size.
pub fn area_resized(&self, width: u32, height: u32) -> Result<Rendered, String> {
let (sw, sh) = (self.width as usize, self.height as usize);
if sw.checked_mul(sh).and_then(|n| n.checked_mul(4)) != Some(self.pixels.len()) {
return Err("the pixel buffer doesn't match the image size".into());
}
if sw == 0 || sh == 0 || width == 0 || height == 0 {
return Err(format!("a {sw} × {sh} image can't be resized to {width} × {height} pixels"));
}
if (width, height) == (self.width, self.height) {
return Ok(Rendered { width, height, pixels: self.pixels.clone() });
}
let (dw, dh) = (width as usize, height as usize);
let len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| format!("a {width} × {height} image is too large"))?;
let mut pixels = Vec::new();
pixels.try_reserve_exact(len).map_err(|e| format!("a {width} × {height} image: {e}"))?;
let (cols, rows) = (area_spans(sw, dw), area_spans(sh, dh));
let src: Vec<&[[u8; 4]]> = self.pixels.as_chunks::<4>().0.chunks_exact(sw).collect();
let mut acc = vec![[0f64; 4]; dw];
for row in &rows {
acc.fill([0.0; 4]);
for &(y, wy) in row {
let Some(line) = src.get(y) else { continue };
for (a, col) in acc.iter_mut().zip(&cols) {
for &(x, wx) in col {
if let Some(px) = line.get(x) {
let w = wy * wx;
for (s, v) in a.iter_mut().zip(px) {
*s += w * f64::from(*v);
}
}
}
}
}
for a in &acc {
pixels.extend(a.map(|v| v.round().clamp(0.0, 255.0) as u8));
}
}
Ok(Rendered { width, height, pixels })
}
/// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use
/// [`encode`]).
pub fn to_png(&self) -> Result<Vec<u8>, String> {
+1
View File
@@ -5,6 +5,7 @@ use vectorcraft_geom::shapes;
mod adjust;
mod alternate;
mod arearesize;
mod bitmapglyphs;
mod blend;
mod changed;
+66
View File
@@ -0,0 +1,66 @@
//! `Rendered::area_resized`: area averages of premultiplied pixels at any size, edge pixels by
//! their share, a copy at the same size, and errors instead of malformed images.
use crate::*;
fn image(width: u32, height: u32, pixels: &[[u8; 4]]) -> Rendered {
Rendered { width, height, pixels: pixels.concat() }
}
#[test]
fn whole_blocks_average_into_one_pixel() {
let a = [200, 100, 0, 255];
let b = [0, 100, 200, 255];
let img = image(4, 2, &[a, b, a, a, a, b, a, b]);
let out = img.area_resized(2, 1).unwrap();
assert_eq!((out.width, out.height), (2, 1));
assert_eq!(out.pixels, [100, 100, 100, 255, 150, 100, 50, 255]);
}
/// Three pixels into two: each new pixel takes one whole old pixel and half of the middle one.
#[test]
fn edge_pixels_count_by_the_share_they_cover() {
let img = image(3, 1, &[[0, 0, 0, 255], [255, 255, 255, 255], [90, 90, 90, 255]]);
let out = img.area_resized(2, 1).unwrap();
// (0 + 255 / 2) / 1.5 = 85, (255 / 2 + 90) / 1.5 = 145.
assert_eq!(out.pixels, [85, 85, 85, 255, 145, 145, 145, 255]);
}
/// A transparent pixel carries no color into its opaque neighbor: the average is half red, half
/// clear, still pure red once un-premultiplied.
#[test]
fn transparent_pixels_add_no_color() {
let img = image(2, 1, &[[0, 0, 0, 0], [255, 0, 0, 255]]);
let out = img.area_resized(1, 1).unwrap();
assert_eq!(out.pixels, [128, 0, 0, 128]);
assert_eq!(out.to_straight(), [255, 0, 0, 128]);
}
#[test]
fn the_same_size_is_a_copy() {
let px: Vec<[u8; 4]> = (0..12u8).map(|i| [i * 20, 255 - i * 20, i, 255]).collect();
let img = image(4, 3, &px);
let out = img.area_resized(4, 3).unwrap();
assert_eq!(out.pixels, img.pixels);
}
/// A solid image stays exactly its color at any smaller size, with no rounding drift at
/// fractional spans.
#[test]
fn a_solid_color_stays_exact() {
let px = vec![[12, 34, 56, 255]; 1000 * 7];
let out = image(1000, 7, &px).area_resized(128, 3).unwrap();
assert_eq!((out.width, out.height), (128, 3));
assert!(out.pixels.as_chunks::<4>().0.iter().all(|p| *p == [12, 34, 56, 255]));
}
#[test]
fn bad_sizes_are_errors() {
let img = image(2, 2, &[[0, 0, 0, 255]; 4]);
assert!(img.area_resized(0, 1).is_err());
assert!(img.area_resized(1, 0).is_err());
let short = Rendered { width: 2, height: 2, pixels: vec![0; 12] };
assert!(short.area_resized(1, 1).err().is_some_and(|e| e.contains("doesn't match")));
let empty = Rendered { width: 0, height: 5, pixels: Vec::new() };
assert!(empty.area_resized(1, 1).is_err());
}
+13 -2
View File
@@ -116,7 +116,9 @@ fn adjustment_ratio(natural: f64, width: f64, stretch: f64, shrink: f64, last: b
/// Compose the paragraph `g` into lines. `width(k)` is the available width of line `k`;
/// `start_credit(i)` is the width a wrapped line starting at glyph `i` gives back (Mojikumi sets
/// an opening bracket there flush with the line's start); `cands` are the break opportunities
/// an opening bracket there flush with the line's start); `end_credit(t)` is the width a line whose
/// last character is glyph `t - 1` gives back (Mojikumi sets a closing mark there half width), at
/// most that glyph's advance, and nothing at a hyphenation point; `cands` are the break opportunities
/// in increasing order (the paragraph end is added); `flex` holds how far each glyph may stretch
/// and shrink in a justified line.
/// Returns `(end, hyphen)` per line, or `None` if no set of breaks fits within the tolerance
@@ -125,6 +127,7 @@ pub(crate) fn compose(
g: &[SGlyph],
width: &dyn Fn(usize) -> f64,
start_credit: &dyn Fn(usize) -> f64,
end_credit: &dyn Fn(usize) -> f64,
cands: &[Breakpoint],
flex: &[Flex],
p: &Params,
@@ -153,6 +156,11 @@ pub(crate) fn compose(
}
b
};
let line_end_credit = |t: usize| {
let credit = end_credit(t);
let most = t.checked_sub(1).and_then(|k| g.get(k)).map_or(0.0, |gl| gl.adv.max(0.0));
if credit.is_finite() { credit.clamp(0.0, most) } else { 0.0 }
};
let mut all: Vec<Breakpoint> = cands.iter().copied().filter(|c| c.end > 0 && c.end < n).collect();
all.push(Breakpoint { end: n, hyphen: 0.0 });
let (adj_demerits, hyphen_penalty) =
@@ -170,7 +178,10 @@ pub(crate) fn compose(
let Some(a) = nodes.get(ai) else { continue };
let t = trim(bp.end, a.pos);
let credit = if a.pos > 0 { start_credit(a.pos) } else { 0.0 };
let natural = w[t] - w[a.pos] - credit + bp.hyphen;
// A line that ends with a hyphen ends with it, not with its last character. At most the
// last glyph's advance, so a longer line from the same node is never narrower.
let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) };
let natural = w[t] - w[a.pos] - credit - end + bp.hyphen;
let lw = width(a.line);
// Adjustment ratio, badness and whether the line ends the active node's reach.
let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue };
+74 -17
View File
@@ -253,29 +253,52 @@ pub(super) fn leader_outline(db: &FontDb, g: &SGlyph, leader: &str, origin: f64,
out
}
/// Width of the word starting at glyph `i`: its advances up to the first break opportunity (spaces
/// left out, the hyphen of a soft hyphen in), the least room a line needs not to break inside it.
/// A list marker at `i` (glyphs of length 0) counts whole, with the word after it.
pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// Width of the word starting at glyph `i`: its advances up to the first place a line may break
/// (spaces left out, the hyphen of a soft hyphen in), the least room a line needs not to break
/// inside it. As in [`break_line`], kinsoku keeps a character that may not end a line with the one
/// after it, a No Break run (its spaces inside it included) is one word up to a forced line break,
/// and a cluster is never split. A soft hyphen just before a forced line break adds no hyphen. A list marker at `i` (glyphs of length 0) counts whole, with the word after it. A word
/// that ends where a line may end is measured as the line is set: without the spaces at its end,
/// and with its last glyph as narrow as `line_end` sets it; one that ends at a soft hyphen or past
/// the measured glyphs is not.
pub(super) fn word_width(g: &[SGlyph], i: usize, line_end: &[f64], kinsoku: Kinsoku) -> f64 {
/// Glyphs measured at most: a word wrapped over many lines is measured again from each line's
/// start, and one this long is broken inside whatever cell it meets.
const MAX_GLYPHS: usize = 1024;
let rest = g.get(i..).unwrap_or_default();
let marker = rest.iter().take_while(|gl| gl.len == 0).count();
let mut w: f64 = rest.iter().take(marker).map(|gl| gl.adv).sum();
for gl in rest.iter().skip(marker).take(MAX_GLYPHS) {
if gl.is_space() {
break;
let start = i + marker;
let mut end = start;
// The spaces at the end of what is measured so far (inside a No Break run), and the end of its
// last glyph that is not a space.
let (mut trailing, mut ink) = (0.0, start);
let set = |w: f64, trailing: f64, ink: usize| w - trailing - line_end_credit(g, line_end, ink);
while let Some(gl) = g.get(end).filter(|_| end - start < MAX_GLYPHS) {
// A forced line break ends the line, inside a No Break run too, as in [`break_line`].
if gl.ch == '\u{2028}' {
return set(w, trailing, ink);
}
if gl.is_soft_hyphen() {
if gl.is_space() && (end == start || !splits_no_break(g, end)) {
return set(w, trailing, ink);
}
let forced_next = g.get(end + 1).is_some_and(|n| n.ch == '\u{2028}');
if gl.is_soft_hyphen() && !forced_next && kinsoku_allows(g, end, kinsoku) && !splits_no_break(g, end + 1) {
return w + hyphen_glyph(gl).adv;
}
w += gl.adv;
if gl.break_after() {
break;
end += 1;
if gl.is_space() {
trailing += gl.adv;
} else {
(trailing, ink) = (0.0, end);
}
let cluster_ends = g.get(end).is_none_or(|next| next.byte != gl.byte);
if gl.break_after() && cluster_ends && kinsoku_allows(g, end - 1, kinsoku) && !splits_no_break(g, end) {
return set(w, trailing, ink);
}
}
w
if end >= g.len() { set(w, trailing, ink) } else { w }
}
/// Greedy break: returns (end glyph index, hyphenated) for a line starting at `i` of `width`.
@@ -287,6 +310,8 @@ pub(super) fn word_width(g: &[SGlyph], i: usize) -> f64 {
/// keeps the line from breaking before the character that overflows, `kinsoku_type` decides
/// whether the line pushes characters in ([`push_in`]) or out; `hanging` is true when Optical
/// Margin Alignment or Roman Hanging Punctuation is on, and `vertical` in vertical type.
/// `line_end[k]` is how much narrower a line ending with glyph `k` is set (Mojikumi); a cluster
/// that fits only that narrower stays on the line when the line may end after it (empty: none).
#[allow(clippy::too_many_arguments)]
pub(super) fn break_line(
text: &str,
@@ -301,6 +326,7 @@ pub(super) fn break_line(
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> (usize, bool) {
if !width.is_finite() {
if let Some(pos) = g[i..].iter().position(|gl| gl.ch == '\u{2028}') {
@@ -342,7 +368,11 @@ pub(super) fn break_line(
if gl.ch == '\u{2028}' {
return (cluster_end, false);
}
if j > first && !gl.is_space() && x + cluster_width > width + EPS {
// Measured as the line is set when it ends with this cluster: only the cluster's last glyph
// is narrowed, and only where the line may end.
let end_credit =
Some(line_end_credit(g, line_end, cluster_end)).filter(|&c| c > 0.0 && may_end_after(g, cluster_end, kinsoku)).unwrap_or(0.0);
if j > first && !gl.is_space() && x + cluster_width - end_credit > width + EPS {
// It ends the line with the spaces after it.
if burasagari != Burasagari::None && hangs(gl) && kinsoku_allows(g, cluster_end - 1, kinsoku) {
let mut end = cluster_end;
@@ -351,8 +381,8 @@ pub(super) fn break_line(
}
return (end, false);
}
if let Some(end) =
push_in(g, i, j, cluster_end, x + cluster_width, width, last_break.map(|b| b.0), kinsoku, kinsoku_type, hanging, vertical)
let pushed_out = last_break.map(|b| b.0);
if let Some(end) = push_in(g, i, j, cluster_end, x + cluster_width, width, pushed_out, kinsoku, kinsoku_type, hanging, vertical, line_end)
{
return (end, false);
}
@@ -495,6 +525,30 @@ pub(super) fn optical_hangs_rtl(line: &[(&SGlyph, f64)], share: fn(&SGlyph) -> f
(left, right, left_glyphs, right_glyphs)
}
/// How much narrower a line whose last character is glyph `end - 1` is set: `line_end` of that
/// glyph ([`break_line`]), at most its advance, and 0 when it is not finite.
fn line_end_credit(g: &[SGlyph], line_end: &[f64], end: usize) -> f64 {
let Some(k) = end.checked_sub(1) else { return 0.0 };
match (line_end.get(k), g.get(k)) {
(Some(&credit), Some(gl)) if credit.is_finite() && credit > 0.0 => credit.min(gl.adv.max(0.0)),
_ => 0.0,
}
}
/// May [`break_line`] end a line after glyph `end - 1` (and the spaces after it)? At the paragraph's
/// end or a forced line break, and where it records a break without a hyphen.
fn may_end_after(g: &[SGlyph], end: usize, kinsoku: Kinsoku) -> bool {
let mut e = end;
while g.get(e).is_some_and(SGlyph::is_space) {
e += 1;
}
if e >= g.len() || g[e].ch == '\u{2028}' {
return true;
}
let Some(last) = e.checked_sub(1).and_then(|k| g.get(k)) else { return false };
last.break_after() && last.len > 0 && !last.is_soft_hyphen() && kinsoku_allows(g, e - 1, kinsoku) && !splits_no_break(g, e)
}
/// Would a line ending at glyph index `end` split a No Break run?
fn splits_no_break(g: &[SGlyph], end: usize) -> bool {
end > 0 && g.get(end - 1).is_some_and(|a| a.no_break) && g.get(end).is_some_and(|b| b.no_break)
@@ -539,7 +593,8 @@ const PUSH_IN_RUN: usize = 16;
/// neither does a line holding a tab or right-to-left text, nor one where the characters taken in
/// reach a space after a character that may not end a line. With `hanging` (Optical Margin
/// Alignment or Roman Hanging Punctuation), the gaps that hanging punctuation keeps open are not
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]).
/// counted. `vertical`: the line is a column of vertical type ([`squeeze_em`]). Both lines are
/// measured with their last character set as narrow as `line_end` sets it ([`break_line`]).
#[allow(clippy::too_many_arguments)]
fn push_in(
g: &[SGlyph],
@@ -553,6 +608,7 @@ fn push_in(
kinsoku_type: KinsokuShoriType,
hanging: bool,
vertical: bool,
line_end: &[f64],
) -> Option<usize> {
if kinsoku == Kinsoku::None || kinsoku_type == KinsokuShoriType::PushOutOnly {
return None;
@@ -604,7 +660,7 @@ fn push_in(
|| (a + 3 == last && punct(last - 2) && quote(last - 1)))
};
let room: f64 = (i..end - 1).filter(|&a| closes(g, a) && !may_hang(a)).map(|a| PUSH_IN_SQUEEZE * squeeze_em(&g[a], vertical)).sum();
if x - width > room + EPS {
if x - line_end_credit(g, line_end, last) - width > room + EPS {
return None;
}
if kinsoku_type == KinsokuShoriType::PushOutFirst {
@@ -612,7 +668,8 @@ fn push_in(
while kept > i && g[kept - 1].is_space() {
kept -= 1;
}
let short = width - g[i..kept].iter().map(|h| h.adv).sum::<f64>();
let set = if kept > i { line_end_credit(g, line_end, kept) } else { 0.0 };
let short = width - (g[i..kept].iter().map(|h| h.adv).sum::<f64>() - set);
if short <= squeeze_em(before, vertical) + EPS {
return None;
}
+84 -23
View File
@@ -23,11 +23,14 @@ const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged,
/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the
/// greedy single-line composer. With `continued`, the paragraph continues from the frame before,
/// and its first composed line is not its first line.
/// and its first composed line is not its first line. `line_end` is how much narrower a line
/// ending with each glyph is set ([`ParagraphData::line_end`]).
#[allow(clippy::too_many_arguments)]
fn compose_para(
cx: &Ctx<'_>,
sg: &[SGlyph],
flex: &[Flex],
line_end: &[f64],
para: &ParaStyle,
pen: &Pen<'_>,
rtl: bool,
@@ -68,6 +71,12 @@ fn compose_para(
return None;
}
let total: f64 = sg.iter().map(|g| g.adv).sum();
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
let mut sim = pen.clone();
let mut widths = vec![];
let mut acc = 0.0;
@@ -77,8 +86,9 @@ fn compose_para(
let first = widths.is_empty();
let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 };
// Only the first line's first word is known here. Later lines are placed as the composed
// lines need: each fits its width, so its first word does too.
let word = if first { word_width(sg, 0) } else { 0.0 };
// lines need: each fits its width, so its first word does too. It is measured as the line
// loop measures it, with Mojikumi's narrower start and end.
let word = if first { word_width(sg, 0, line_end, para.kinsoku) - start_credit(0) } else { 0.0 };
let Some((b, x0, x1, _)) = sim.place(m, ind_l, para.right_indent, word) else { break };
let w = x1 - x0 - ind_l - para.right_indent;
widths.push(w);
@@ -103,13 +113,10 @@ fn compose_para(
// stop at four times its stretch and fall back to the single-line composer; the documented
// default range (word spacing up to 133%) is narrow enough that most paragraphs would.
let (strict, loose) = (1.0, f64::INFINITY);
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
// the space before it.
let start_credit = |i: usize| {
let flush = !rtl && para.mojikumi == Mojikumi::LineEndHalf;
sg.get(i).filter(|g| flush && g.lead <= 0.0).and_then(|g| punct_half(g, Punct::Opening)).unwrap_or(0.0)
};
compose(sg, &width, &start_credit, &cands, flex, &params(strict)).or_else(|| compose(sg, &width, &start_credit, &cands, flex, &params(loose)))
// A line's last character is set narrower, as the line loop sets it.
let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p);
pass(&params(strict)).or_else(|| pass(&params(loose)))
}
/// The list marker of each of the paragraphs `paras` of `t` (None for none): bullets, and numbers
@@ -184,6 +191,10 @@ struct ParagraphData<'a, 'text> {
sg: Vec<SGlyph>,
compressed: Vec<bool>,
wakan: Vec<f64>,
/// How much narrower a line ending with each glyph is set ([`line_end_trim`]), for measuring
/// where lines break; empty when the paragraph's lines are measured at full width (Mojikumi
/// None, or right-to-left text).
line_end: Vec<f64>,
flex: Vec<Flex>,
marker: usize,
pm: Metrics,
@@ -255,7 +266,13 @@ fn prepare_paragraph<'a, 'text>(
if pi > 0 {
pen.pending += para.space_before;
}
let composed = compose_para(cx, &sg, &flex, para, pen, rtl, continued);
// A line fits when it fits as it is set: with its last character narrowed by Mojikumi.
let line_end = if para.mojikumi == Mojikumi::LineEndHalf && !rtl {
(0..sg.len()).map(|k| line_end_trim(&sg, &compressed, &wakan, k)).collect()
} else {
vec![]
};
let composed = compose_para(cx, &sg, &flex, &line_end, para, pen, rtl, continued);
// Hyphenation options: the Hyphenation Zone applies with the Single-line Composer, and the
// Hyphen Limit counts the lines in a row that end with a hyphen.
let zone = if cx.opts.composer.unwrap_or(para.composer) == Composer::SingleLine { para.hyphenation.zone } else { 0.0 };
@@ -271,6 +288,7 @@ fn prepare_paragraph<'a, 'text>(
sg,
compressed,
wakan,
line_end,
flex,
marker,
pm,
@@ -286,6 +304,8 @@ struct PlacementContext<'a> {
para: &'a ParaStyle,
pr: &'a Range<usize>,
sg: &'a mut [SGlyph],
/// [`ParagraphData::line_end`].
line_end: &'a [f64],
continued: bool,
rtl: bool,
mirror: bool,
@@ -314,14 +334,27 @@ struct LinePosition {
}
fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementContext<'_>) -> Option<LinePosition> {
let PlacementContext { para, pr, sg, continued, rtl, mirror, pm, composed, hyphen_run, hyphen_limit, zone, index: i, para_line, hanging } =
context;
let PlacementContext {
para,
pr,
sg,
line_end,
continued,
rtl,
mirror,
pm,
composed,
hyphen_run,
hyphen_limit,
zone,
index: i,
para_line,
hanging,
} = context;
let n = sg.len();
let est = if *i < n { Metrics::of(&sg[*i]) } else { *pm };
let first_line = *para_line == 0 && !*continued;
let (ind_l, ind_r) = line_indents(para, first_line, *mirror);
// Measured before Mojikumi narrows an opening bracket, as the composer measures it.
let word = word_width(sg, *i);
// Mojikumi: an opening bracket starting a line is set flush with the line's start (the
// space before it goes), and the line has that much more room. At a paragraph's start
// that is the first-line indent: JIS X 4051's principle (JLREQ 3.1.5, Figure 71 ①).
@@ -334,6 +367,8 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
glyph.adv -= half;
glyph.lead += half;
}
// The line's first word, as narrow as the line sets it at both ends.
let word = word_width(sg, *i, line_end, para.kinsoku);
// Place, break, then settle the baseline on the line's real metrics (moving on to the
// next row/column if it no longer fits).
loop {
@@ -347,8 +382,23 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph, false),
_ if *i < n => {
let line =
|ty| break_line(cx.text, sg, *i, width, &para.tabs, ind_l, hyphenate, para.burasagari, para.kinsoku, ty, *hanging, cx.vertical);
let line = |ty| {
break_line(
cx.text,
sg,
*i,
width,
&para.tabs,
ind_l,
hyphenate,
para.burasagari,
para.kinsoku,
ty,
*hanging,
cx.vertical,
line_end,
)
};
let (end, hyph) = line(para.kinsoku_type);
// The line pushed characters in where it ends after the line that pushes out.
let pushed_in = pushes_in(para) && end > line(KinsokuShoriType::PushOutOnly).0;
@@ -441,6 +491,7 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
para: paragraph.para,
pr: &paragraph.pr,
sg: &mut paragraph.sg,
line_end: &paragraph.line_end,
continued: paragraph.continued,
rtl: paragraph.rtl,
mirror: paragraph.mirror,
@@ -475,6 +526,18 @@ fn run_paragraph_lines(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, t: &TextObject, mut
true
}
/// Mojikumi (Line-end Punctuation Half Width): how much narrower a line ending with glyph `k` of
/// `sg` is set. A closing bracket, comma or full stop ending the line is set half width, unless it
/// already lost that half to the punctuation after it (`compressed`), and no Japanese–Latin space
/// (`wakan`) is left after the line's last character. Never more than the glyph's advance, and 0
/// for an advance or a space that is not finite.
pub(super) fn line_end_trim(sg: &[SGlyph], compressed: &[bool], wakan: &[f64], k: usize) -> f64 {
let Some(g) = sg.get(k) else { return 0.0 };
let half = if compressed.get(k).copied().unwrap_or(false) { None } else { punct_half(g, Punct::Closing) };
let trim = half.unwrap_or(0.0) + wakan.get(k).copied().unwrap_or(0.0);
if trim.is_finite() && g.adv.is_finite() { trim.min(g.adv).max(0.0) } else { 0.0 }
}
/// The geometry of the line of `paragraph` from glyph `index`, placed at `position`: where it
/// ends with its trailing spaces trimmed, its hyphen, its room and margins, the glyphs that hang or
/// stay out of justification, and whether it is the paragraph's last line.
@@ -490,12 +553,10 @@ fn prepare_line_geometry(cx: &Ctx<'_>, t: &TextObject, paragraph: &ParagraphData
trimmed -= 1;
}
let hyphen = (position.hyph && position.end > index).then(|| hyphen_glyph(&sg[position.end - 1]));
// Mojikumi: a closing bracket, comma or full stop ending the line is set half width, and
// no Japanese–Latin space is left at the end of a line.
let end_trim = trimmed.checked_sub(1).filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf).map_or(0.0, |k| {
let half = if paragraph.compressed.get(k).copied().unwrap_or(false) { None } else { sg.get(k).and_then(|g| punct_half(g, Punct::Closing)) };
half.unwrap_or(0.0) + paragraph.wakan.get(k).copied().unwrap_or(0.0)
});
let end_trim = trimmed
.checked_sub(1)
.filter(|&k| k >= index && para.mojikumi == Mojikumi::LineEndHalf)
.map_or(0.0, |k| line_end_trim(sg, &paragraph.compressed, &paragraph.wakan, k));
let measured_width = set_width(sg, index, trimmed, &para.tabs, position.ind_l) + hyphen.as_ref().map_or(0.0, |g| g.adv) - end_trim;
let line_start = sg.get(index).map_or(paragraph.pr.start, |g| g.byte);
let line_end = sg.get(position.end).map_or(paragraph.pr.end, |g| g.byte);
+151 -13
View File
@@ -92,14 +92,15 @@ fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
KinsokuShoriType::PushOutOnly,
false,
false,
&[],
);
assert_eq!((end, hyphen), (4, true), "{tag}");
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &params).expect("every-line composition");
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &params).expect("every-line composition");
assert!(lines.iter().any(|(_, hyphen)| *hyphen), "{tag}");
assert!(lines.iter().all(|(end, _)| end % 2 == 0), "{tag}: {lines:?}");
let limited = Params { hyphen_limit: 1, ..params };
if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &limited) {
if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &|_| 0.0, &cands, &[], &limited) {
assert!(lines.windows(2).all(|pair| !pair[0].1 || !pair[1].1), "{tag}: {lines:?}");
}
}
@@ -164,7 +165,7 @@ fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() {
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
let (end, hyphenated) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphenated);
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
@@ -178,12 +179,12 @@ fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() {
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
let (first_end, _) =
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(first_end, 1);
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
let (second_end, hyphenated) =
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphenated);
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
@@ -239,7 +240,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
let text = "A B";
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
let (end, hyphen) =
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphen);
assert_eq!(end, 2, "without the mark the line ends after the space");
@@ -248,7 +249,7 @@ fn no_break_keeps_marked_characters_on_one_line() {
g.no_break = true;
}
let (end, hyphen) =
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert!(!hyphen);
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
@@ -257,15 +258,60 @@ fn no_break_keeps_marked_characters_on_one_line() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]);
glyphs[2].no_break = true;
glyphs[3].no_break = true;
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
let (end, _) =
break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(end, 2, "the marked pair moves to the next line together");
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false);
let (end, _) =
break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, KinsokuShoriType::PushOutOnly, false, false, &[]);
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}");
}
/// A soft hyphen inside a No Break run is not a place a line may break, so the word is measured
/// across it, as wide as [`break_line`] keeps it.
#[test]
fn word_width_measures_across_a_soft_hyphen_inside_a_no_break_run() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 1, 10.0, 'B'), (4, 1, 10.0, 'C')]);
// The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt.
for g in &mut glyphs {
g.sx = 0.001;
}
let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!(plain < 30.0, "without the mark the word ends at the soft hyphen: {plain}");
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 30.0).abs() < 1e-9, "the marked run is measured whole: {marked}");
}
/// A forced line break ends the word [`breaking::word_width`] measures, inside a No Break run too,
/// and a soft hyphen just before it adds no hyphen, as [`break_line`] sets the line.
#[test]
fn word_width_stops_at_a_forced_line_break() {
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 3, 5.0, '\u{2028}'), (4, 1, 10.0, 'B'), (5, 1, 10.0, 'C')]);
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}");
let mut glyphs = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 2, 0.0, '\u{00ad}'), (3, 3, 5.0, '\u{2028}'), (6, 1, 10.0, 'B')]);
// The hyphen a soft hyphen adds comes from the face, scaled to well under 10 pt.
for g in &mut glyphs {
g.sx = 0.001;
}
let plain = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((plain - 10.0).abs() < 1e-9, "no hyphen before the forced line break: {plain}");
for g in &mut glyphs {
g.no_break = true;
}
let marked = breaking::word_width(&glyphs, 0, &[], Kinsoku::None);
assert!((marked - 10.0).abs() < 1e-9, "the marked run ends at the forced line break: {marked}");
}
/// Synthetic full-width characters `text`, each 10 pt wide on a 10 pt em.
fn full_width(text: &str) -> Vec<SGlyph> {
let specs: Vec<(usize, usize, f64, char)> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 10.0, ch)).collect();
@@ -286,7 +332,7 @@ fn full_width(text: &str) -> Vec<SGlyph> {
fn kinsoku_shori_type_pushes_a_closing_bracket_in_or_out() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let hung = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType, hanging: bool| {
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false).0
break_line(text, &full_width(text), 0, width, &[], 0.0, None, Burasagari::None, kinsoku, ty, hanging, false, &[]).0
};
let end = |text: &str, width: f64, kinsoku: Kinsoku, ty: KinsokuShoriType| hung(text, width, kinsoku, ty, false);
let text = "一二三四五六七八九十一二三四五」六";
@@ -351,7 +397,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
let mixed = "一二三א五六七八九十一二三四五」六";
let mut glyphs = full_width(mixed);
glyphs[3].level = unicode_bidi::Level::rtl();
let mixed_end = |ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
let mixed_end =
|ty: KinsokuShoriType| break_line(mixed, &glyphs, 0, 150.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
assert_eq!(mixed_end(PushInFirst), 14, "right-to-left text on the line: 五 is pushed out with 」");
assert_eq!(mixed_end(PushOutOnly), 14);
@@ -362,7 +409,8 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
for g in &mut glyphs {
g.sy *= 2.0;
}
let tall_end = |ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false).0;
let tall_end =
|ty: KinsokuShoriType| break_line(tall, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, &[]).0;
assert_eq!(tall_end(PushInFirst), 4, "Vertical Scale does not widen the gaps: 五 is pushed out with 」");
// No gap closes inside a shaped cluster. 二 is two glyphs here, so the line has five gaps to
@@ -371,7 +419,97 @@ fn kinsoku_shori_type_counts_the_gaps_that_close() {
glyphs[1].adv = 5.0;
let half = glyphs[1].clone();
glyphs.insert(2, half);
let split_end = |width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false).0;
let split_end =
|width: f64| break_line(tall, &glyphs, 0, width, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushInFirst, false, false, &[]).0;
assert_eq!(split_end(54.0), 5, "五 is pushed out with 」");
assert_eq!(split_end(55.0), 7, "five gaps close by 5 pt: 」 is pushed in");
}
/// Mojikumi: the greedy breaker measures a line as it is set, with its last character narrowed
/// (`line_end`): 」 set half width ends a measure of three and a half characters. Only the last
/// glyph of the last cluster counts, and only where the line may end.
#[test]
fn greedy_wrap_counts_the_narrower_line_end() {
use KinsokuShoriType::{PushInFirst, PushOutFirst, PushOutOnly};
let text = "一二三」四五六";
let glyphs = full_width(text);
let mut credit = vec![0.0; glyphs.len()];
credit[3] = 5.0;
let end = |glyphs: &[SGlyph], width: f64, burasagari: Burasagari, line_end: &[f64]| {
break_line(text, glyphs, 0, width, &[], 0.0, None, burasagari, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
};
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &credit), 4, "」 half width fits");
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[]), 2, "」 full width does not: 三 goes with it");
assert_eq!(end(&glyphs, 34.0, Burasagari::None, &credit), 2, "half width is still too wide");
// A credit that isn't finite counts for nothing, and one larger than the glyph for its advance.
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::INFINITY]), 2);
assert_eq!(end(&glyphs, 35.0, Burasagari::None, &[0.0, 0.0, 0.0, f64::NAN]), 2);
assert_eq!(end(&glyphs, 30.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 4, "at most 10 pt off");
assert_eq!(end(&glyphs, 29.0, Burasagari::None, &[0.0, 0.0, 0.0, 100.0]), 2);
// The line may not end after 三 (、 follows it): its credit does not keep it on the line, so 、
// does not hang after a line already wider than the measure.
let comma = "一二三、";
let glyphs = full_width(comma);
let end = |line_end: &[f64]| {
break_line(comma, &glyphs, 0, 25.0, &[], 0.0, None, Burasagari::Standard, Kinsoku::Hard, PushOutOnly, false, false, line_end).0
};
assert_eq!(end(&[0.0, 0.0, 5.0, 0.0]), 2);
// Kinsoku Shori Type measures both lines as they are set. Pushing 。 in leaves the line 5 pt
// wider than the measure with 。 half width, which its five gaps close.
let pushed = "一二三四」。六";
let glyphs = full_width(pushed);
let end = |ty: KinsokuShoriType, line_end: &[f64]| {
break_line(pushed, &glyphs, 0, 50.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, ty, false, false, line_end).0
};
let stop = [0.0, 0.0, 0.0, 0.0, 0.0, 5.0, 0.0];
assert_eq!(end(PushInFirst, &stop), 6, "。 is pushed in");
assert_eq!(end(PushInFirst, &[]), 3, "full width, the gaps can't close enough");
// Push Out First: pushing out to after 」, set half width, leaves the line 15 pt short, more
// than an em, so 四 is pushed in.
let pushed = format!("{}二」「四", "一".repeat(10));
let glyphs = full_width(&pushed);
let end = |line_end: &[f64]| {
break_line(&pushed, &glyphs, 0, 130.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard, PushOutFirst, false, false, line_end).0
};
let mut bracket = vec![0.0; glyphs.len()];
bracket[11] = 5.0;
assert_eq!(end(&bracket), 14, "四 is pushed in");
assert_eq!(end(&[]), 12, "full width, pushing out leaves the line one em short: 「四 go on");
// 三 is two glyphs of one cluster: the credit of its first glyph does not count.
let text = "一二三";
let mut glyphs = full_width(text);
glyphs[2].adv = 5.0;
let half = glyphs[2].clone();
glyphs.insert(3, half);
let end =
|line_end: &[f64]| break_line(text, &glyphs, 0, 26.0, &[], 0.0, None, Burasagari::None, Kinsoku::None, PushOutOnly, false, false, line_end).0;
assert_eq!(end(&[0.0, 0.0, 4.0, 0.0]), 2, "the cluster stays whole and goes to the next line");
assert_eq!(end(&[0.0, 0.0, 0.0, 4.0]), 4, "its last glyph's credit keeps it on the line");
}
/// Mojikumi: the every-line composer measures each candidate line with its last character narrowed
/// (`end_credit`), never by more than that glyph's advance, and a line ending in a hyphen ends with
/// the hyphen.
#[test]
fn composition_counts_the_narrower_line_end() {
let text = "一二三」四五六";
let glyphs = full_width(text);
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
let lines =
|width: f64, end_credit: &dyn Fn(usize) -> f64, cands: &[Breakpoint]| compose(&glyphs, &|_| width, &|_| 0.0, end_credit, cands, &[], &params);
let half = |t: usize| if t == 4 { 5.0 } else { 0.0 };
assert_eq!(lines(35.0, &half, &cands), Some(vec![(4, false), (7, false)]));
let solid = lines(35.0, &|_| 0.0, &cands).expect("composed");
assert!(solid.iter().all(|&(end, _)| end != 4), "」 full width does not end the first line: {solid:?}");
// At most the glyph's advance; a credit that isn't finite counts for nothing.
assert_eq!(lines(30.0, &|t| if t == 4 { 100.0 } else { 0.0 }, &cands).and_then(|l| l.first().copied()), Some((4, false)));
assert!(lines(35.0, &|_| f64::NAN, &cands).is_some_and(|l| l.iter().all(|&(end, _)| end != 4)));
// No credit for a line ending in a hyphen.
let hyphenated = [Breakpoint { end: 4, hyphen: 1.0 }];
assert_eq!(lines(38.0, &half, &hyphenated), None);
assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)]));
}
+35
View File
@@ -414,6 +414,41 @@ pub fn vertical_punctuation_font(alternates: bool) -> Option<Vec<u8>> {
Some(builder.build())
}
/// The family of [`full_width_font`].
pub const FULL_WIDTH_FAMILY: &str = "Fullwidthtest Sans";
/// The ideographs and Japanese punctuation [`full_width_font`] sets one em wide.
pub const FULL_WIDTH_CHARS: &str = "一二三四五六「」、。";
/// A test-only cmap over bundled Source Sans 3: [`FULL_WIDTH_CHARS`] use the one-em-wide glyph of
/// U+2318, so Japanese composition is tested without OS fonts; Latin letters and the space keep
/// their own glyphs.
pub fn full_width_font() -> Option<Vec<u8>> {
use write_fonts::tables::cmap::Cmap;
const BASE: &[u8] = include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf");
let font = skrifa::FontRef::new(BASE).ok()?;
let charmap = font.charmap();
let wide = write_fonts::types::GlyphId::new(charmap.map('\u{2318}')?.to_u32());
let latin = ('A'..='Z').chain('a'..='z').chain([' ']);
let mut mappings: Vec<(char, write_fonts::types::GlyphId)> = FULL_WIDTH_CHARS.chars().map(|c| (c, wide)).collect();
for c in latin {
mappings.push((c, write_fonts::types::GlyphId::new(charmap.map(c)?.to_u32())));
}
let cmap = Cmap::from_mappings(mappings).ok()?;
let name = name_table(&[(1, FULL_WIDTH_FAMILY), (2, "Regular"), (4, "Fullwidthtest Sans Regular"), (6, "FullwidthtestSans-Regular")])?;
let mut builder = FontBuilder::new();
builder.add_table(&cmap).ok()?;
builder.add_raw(Tag::new(b"name"), name);
for r in font.table_directory.table_records() {
let tag = Tag::new(&r.tag().to_be_bytes());
if tag != Tag::new(b"GSUB") && !builder.contains(tag) {
builder.add_raw(tag, font.table_data(r.tag())?.as_bytes());
}
}
Some(builder.build())
}
/// The family of [`half_form_font`].
pub const HALF_FORM_FAMILY: &str = "Halftest Sans";
+170
View File
@@ -1075,6 +1075,176 @@ fn mojikumi_sets_an_opening_bracket_flush_at_the_start_of_a_wrapped_line() {
}
}
/// Mojikumi: with Line-end Punctuation Half Width, a line is measured as it is set. A closing
/// bracket, comma or full stop that fits only at half width ends the line in a measure of three
/// and a half ems, with both composers, horizontal and vertical; with Mojikumi None it goes to the
/// next line with the character before it. A closing mark that already lost its half to the
/// punctuation after it takes nothing more off, and the space between Japanese and Latin text
/// counts only where the line ends. Uses a test font whose ideographs and punctuation are one em.
#[test]
fn mojikumi_line_end_half_width_counts_when_a_line_breaks() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Burasagari, Composer, Kinsoku, KinsokuShoriType, Mojikumi};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
let lay = |text: &str, m: Mojikumi, composer: Composer, burasagari: Burasagari, vertical_type: bool, measure: f64| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
t.vertical = vertical_type;
t.para.mojikumi = m;
t.para.composer = composer;
t.para.burasagari = burasagari;
t.para.kinsoku = Kinsoku::Hard;
// Push Out Only lets the every-line composer take the paragraph.
if composer == Composer::EveryLine {
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
}
let frame = if vertical_type { Rect::new(0.0, 0.0, 200.0, measure) } else { Rect::new(0.0, 0.0, measure, 200.0) };
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame.to_path(0.1)) };
layout(&db, &t)
};
let ends = |l: &TextLayout, text: &str| l.lines.iter().map(|line| text[line.start..line.end].to_string()).collect::<Vec<_>>();
let widest = |l: &TextLayout| {
l.lines.iter().map(|line| l.glyphs[line.glyph_start..line.glyph_end].iter().map(|g| g.advance).sum::<f64>()).fold(0.0, f64::max)
};
assert!((lay("一", Mojikumi::None, Composer::SingleLine, Burasagari::None, false, 70.0).glyphs[0].advance - 20.0).abs() < 1e-9, "one em");
for vertical_type in [false, true] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let at = format!("vertical {vertical_type} {composer:?}");
let marks = [("一二三」四五六", "一二三」"), ("一二三。四五六", "一二三。"), ("一二三、四五六", "一二三、")];
for (text, first) in marks {
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&l, text), [first, "四五六"], "{at}: {text}");
assert!((l.glyphs[3].advance - 10.0).abs() < 1e-9, "{at}: {text}: the mark ends the line half width");
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: {text}: {}", widest(&l));
// Standard burasagari: the mark fits inside, so it doesn't hang.
let standard = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::Standard, vertical_type, 70.0);
assert_eq!(ends(&standard, text), [first, "四五六"], "{at}: {text} standard");
assert!(widest(&standard) <= 70.0 + 1e-9, "{at}: {text} standard");
// Full width it doesn't fit: kinsoku takes 三 to the next line with it.
let solid = lay(text, Mojikumi::None, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&solid, text).first().map(String::as_str), Some("一二"), "{at}: {text} none");
}
// 」 lost its half to 。 after it: the line would be four ems, so 三」。 go on.
let text = "一二三」。四五";
let l = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, vertical_type, 70.0);
assert_eq!(ends(&l, text).first().map(String::as_str), Some("一二"), "{at}: compressed");
assert!(widest(&l) <= 70.0 + 1e-9, "{at}: compressed");
}
}
// The quarter em between 三 and A goes where the line ends there: 一二三 fits three ems and a
// tenth, with or without the space at its end.
for composer in [Composer::SingleLine, Composer::EveryLine] {
let text = "一二三ABC";
let on = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 62.0);
assert_eq!(ends(&on, text).first().map(String::as_str), Some("一二三"), "{composer:?}");
assert!((on.glyphs[2].advance - 20.0).abs() < 1e-9, "{composer:?}: no space at the line's end");
let wide = lay(text, Mojikumi::LineEndHalf, composer, Burasagari::None, false, 200.0);
assert!((wide.glyphs[2].advance - 25.0).abs() < 1e-9, "{composer:?}: the space inside the line");
}
}
/// Mojikumi: in a frame that is narrow at the top, and in a frame a wrap object narrows at the top,
/// a line's first word is measured as the line sets it. A word ending with a closing bracket that
/// fits the narrow lines only with the bracket half width, and an opening bracket that fits them
/// only flush with the line's start, start on the first line, with both composers. An opening
/// bracket that kinsoku keeps with the character after it, and a No Break run, are measured whole.
#[test]
fn mojikumi_narrows_a_lines_first_word_when_looking_for_room() {
use crate::test_fonts::{FULL_WIDTH_FAMILY, full_width_font};
use vectorcraft_doc::{Composer, Kinsoku, KinsokuShoriType, Mojikumi, TextWrap, WrapShape};
let db = FontDb::with_font_dirs(vec![]);
db.add_font(full_width_font().expect("test font"));
// The top 40 pt of the frame are `top` wide, the rest 200: an L-shaped frame, or a rectangle
// with a wrap object over the top right.
let lay = |text: &str, styled: fn(&mut TextObject), composer: Composer, top: f64, wrapped: bool| {
let style = CharStyle { size: 20.0, font_family: FULL_WIDTH_FAMILY.into(), font_style: "Regular".into(), ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, style);
t.xf = Affine::IDENTITY;
styled(&mut t);
t.para.mojikumi = Mojikumi::LineEndHalf;
t.para.composer = composer;
t.para.kinsoku = Kinsoku::Hard;
t.para.kinsoku_type = KinsokuShoriType::PushOutOnly;
let frame = if wrapped {
t.wrap = vec![WrapShape {
path: PathData::from_bezpath(&Rect::new(top, -10.0, 210.0, 40.0).to_path(0.1)),
wrap: TextWrap { offset: 0.0, invert: false },
}];
Rect::new(0.0, 0.0, 200.0, 200.0).to_path(0.1)
} else {
let mut p = kurbo::BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((top, 0.0));
p.line_to((top, 40.0));
p.line_to((200.0, 40.0));
p.line_to((200.0, 200.0));
p.line_to((0.0, 200.0));
p.close_path();
p
};
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
layout(&db, &t)
};
let plain: fn(&mut TextObject) = |_| {};
let wide_bracket: fn(&mut TextObject) = |t| edit::style_range(&mut t.runs, 0, '「'.len_utf8(), |s| s.h_scale = 250.0);
let no_break: fn(&mut TextObject) = |t| {
let n = t.plain_text().len();
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
};
let no_break_but_the_last_word: fn(&mut TextObject) = |t| {
let n = t.plain_text().rfind(' ').map_or(0, |k| k + 1);
edit::style_range(&mut t.runs, 0, n, |s| s.no_break = true);
};
// "ABCD」" is `full` wide inside a line; ending one, 」 is half width.
let closing = "ABCD」EFGH";
let open = lay(closing, plain, Composer::SingleLine, 200.0, false);
let full: f64 = open.glyphs[..5].iter().map(|g| g.advance).sum();
assert!((open.glyphs[4].advance - 20.0).abs() < 1e-9, "」 is one em inside a line");
for wrapped in [false, true] {
for composer in [Composer::SingleLine, Composer::EveryLine] {
let at = format!("wrapped {wrapped} {composer:?}");
let top = full - 5.0;
let l = lay(closing, plain, composer, top, wrapped);
let first = l.lines.first().expect("a line");
assert_eq!(&closing[first.start..first.end], "ABCD」", "{at}");
assert!(first.baseline < 40.0, "{at}: the word starts on the narrow lines: {}", first.baseline);
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
assert!(set <= top + 1e-9, "{at}: {set} in {top}");
// 「 is 50 pt at 250%, 25 pt flush with the line's start: 「一 is 45 pt.
let opening = "「一二三四五六";
let l = lay(opening, wide_bracket, composer, 47.0, wrapped);
let first = l.lines.first().expect("a line");
assert_eq!(&opening[first.start..first.end], "「一", "{at}");
assert!(first.baseline < 40.0, "{at}: 「 starts on the narrow lines: {}", first.baseline);
let set: f64 = l.glyphs[first.glyph_start..first.glyph_end].iter().map(|g| g.advance).sum();
assert!(set <= 47.0 + 1e-9, "{at}: {set}");
// Kinsoku keeps 「 with 一: in 40 pt they don't fit, and the line starts below, whole.
let l = lay(opening, wide_bracket, composer, 40.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline > 40.0 && &opening[first.start..first.end] != "「", "{at}: kinsoku: {:?}", placed_lines(&l, opening));
// A No Break run is one word, its space included: it doesn't fit the narrow lines.
let run_text = "ABCD EFGH";
let run: f64 = lay(run_text, no_break, composer, 200.0, false).glyphs.iter().map(|g| g.advance).sum();
let l = lay(run_text, no_break, composer, run - 5.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline > 40.0 && &run_text[first.start..first.end] == run_text, "{at}: no break: {:?}", placed_lines(&l, run_text));
// …but the space ending a No Break run is not set at the line's end: the run fits a
// line 2 pt wider than it is without that space.
let ending = "ABCD EFGH IJ";
let l = lay(ending, no_break_but_the_last_word, composer, run + 2.0, wrapped);
let first = l.lines.first().expect("a line");
assert!(first.baseline < 40.0 && ending[first.start..first.end].trim_end() == run_text, "{at}: {:?}", placed_lines(&l, ending));
}
}
}
/// Each line's baseline and text.
fn placed_lines(l: &TextLayout, text: &str) -> Vec<(f64, String)> {
l.lines.iter().map(|line| (line.baseline, text[line.start..line.end].to_string())).collect()
}
/// Burasagari: in a measure of exactly five ems, a comma that would be the sixth character goes to
/// the next line with the character before it (None) or hangs outside the line (Standard, Forced);
/// a full stop that is the fifth character stays inside (None, Standard) or hangs while the four
+16 -7
View File
@@ -17,6 +17,8 @@ pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
mod mesh;
pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh};
mod shadow;
pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv};
@@ -672,6 +674,19 @@ impl HoleSearch {
}
}
/// How sanitized Revolve options `o` project a profile with `bounds`: about the axis at the
/// profile's middle height, with the eye as far as the swept radius or the height needs.
fn revolve_projection(bounds: Rect, o: Revolve) -> shadow::Projection {
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
shadow::Projection { view: o, origin: Point::new(axis, center_y), eye }
}
/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn,
/// each turned out of the region `rule` fills.
fn revolve_faces(
@@ -697,13 +712,7 @@ fn revolve_faces(
if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve requires finite profile coordinates within 100 million points".into());
}
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
let shadow::Projection { origin: Point { x: axis, y: center_y }, eye, .. } = revolve_projection(bounds, o);
let steps = o.steps();
let sweep = o.angle.to_radians();
let smooth = o.shade && o.smooth_shading;
+3 -3
View File
@@ -56,12 +56,12 @@ impl Mesh {
/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh.
pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Mesh, String> {
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces))
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces))
}
/// Rotate's plane ([`crate::plane`]) as a mesh.
pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Mesh, String> {
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces))
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces))
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one
@@ -72,7 +72,7 @@ pub fn inflate_meshes(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Mesh, String>> + use<> {
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces)))
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces)))
}
/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than
+343
View File
@@ -0,0 +1,343 @@
//! The shadow a 3D effect's solid casts: its faces projected along the first light onto a plane
//! behind the object (square to the view) or below it (under the object's own vertical axis, turned
//! with it), as one hard-edged vector shape.
//!
//! Every face is projected whole, so the shape is the union of the projected faces: each contour is
//! turned so that its face winds the same way, and the shape is filled with the nonzero rule. The
//! plane ends at a square about the foot of the object's center, so a light that grazes the plane
//! does not cast a shadow without end.
use std::ops::Range;
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale};
/// Where the shadow falls.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShadowPosition {
/// On a plane behind the object, square to the view.
#[default]
Behind,
/// On a plane below the object, square to its vertical axis and turned with it.
Below,
}
impl ShadowPosition {
pub const ALL: [ShadowPosition; 2] = [Self::Behind, Self::Below];
pub fn id(self) -> &'static str {
match self {
Self::Behind => "behind",
Self::Below => "below",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.id() == s)
}
}
/// A shadow's settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shadow {
pub position: ShadowPosition,
/// How far the plane is from the object, as a share (0..1) of the object's largest size.
pub distance: f64,
/// How far the plane reaches, as a share (0.1..4): at 1 the plane is a square four times the
/// object's largest size across.
pub bounds: f64,
}
impl Default for Shadow {
fn default() -> Self {
Self { position: ShadowPosition::Behind, distance: 0.0, bounds: 1.0 }
}
}
impl Shadow {
fn sanitized(self) -> Self {
Self { distance: finite(self.distance, 0.0, 0.0, 1.0), bounds: finite(self.bounds, 1.0, 0.1, 4.0), ..self }
}
}
/// The rotation and projection a solid is drawn with.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Projection {
/// The sanitized view: its rotation, perspective and lights.
pub(crate) view: Revolve,
/// Where the view's origin is on the page.
pub(crate) origin: Point,
/// The eye's distance in front of the origin; `None` without perspective.
pub(crate) eye: Option<f64>,
}
impl Projection {
fn project(&self, v: V3) -> Point {
let f = perspective_scale(self.eye, v.2);
Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f)
}
/// The unit direction toward the first light, in view space; `None` when it lights nothing:
/// Shading is off or its intensity is 0.
fn light(&self) -> Option<V3> {
let first = self.view.lights.and_then(|l| l.iter().next().copied());
let intensity = first.map_or(self.view.light_intensity, |l| l.intensity);
if !self.view.shade || intensity <= 0.0 {
return None;
}
let [x, y, z] = first.map_or_else(|| self.view.light_direction(), |l| l.direction());
Some(V3(x, y, z).unit())
}
}
/// The light must reach the plane at least this steeply (its cosine to the plane's normal).
const MIN_LIGHT: f64 = 1e-3;
/// Most faces one object's shadow is cast from, its solids together: as many as two of the largest
/// solids.
pub const MAX_SHADOW_FACES: usize = 2 * MAX_SOLID_FACES;
/// The error a solid added past [`MAX_SHADOW_FACES`] returns.
pub const TOO_COMPLEX_SHADOW: &str = "The 3D shadow is too complex; turn Shadows off or simplify the art";
/// The plane a shadow falls on ([`ShadowCaster::shadow_plane`]), placed once for the pieces of one
/// object's shadow ([`ShadowCaster::path_on`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadowPlane {
projection: Projection,
/// The unit direction toward the light, and its cosine to the plane's normal.
light: V3,
reach: f64,
/// The plane's unit normal toward the object, and two unit axes along it.
n: V3,
u: V3,
w: V3,
/// Where the plane is along its normal, and the foot of the object's center on it.
level: f64,
foot: V3,
/// Half the side of the square the plane ends at.
half: f64,
}
/// The faces of one object's solids, gathered to cast one shadow. The solids of one object share
/// their bounds and view, so they share a projection: the first solid's is kept. Each solid added
/// returns the range of its faces, so that the shadow of some of them can be drawn on the plane
/// of others ([`ShadowCaster::shadow_plane`], [`ShadowCaster::path_on`]).
#[derive(Clone, Debug, Default)]
pub struct ShadowCaster {
faces: Vec<Face3>,
projection: Option<Projection>,
}
impl ShadowCaster {
pub fn new() -> Self {
Self::default()
}
/// How many faces have been gathered.
pub fn len(&self) -> usize {
self.faces.len()
}
pub fn is_empty(&self) -> bool {
self.faces.is_empty()
}
/// Keep `faces`, or an error when they would take the faces gathered past [`MAX_SHADOW_FACES`].
fn add(&mut self, faces: Vec<Face3>, projection: Option<Projection>) -> Result<Range<usize>, String> {
let start = self.faces.len();
if start.saturating_add(faces.len()) > MAX_SHADOW_FACES {
return Err(TOO_COMPLEX_SHADOW.into());
}
if !faces.is_empty() && self.projection.is_none() {
self.projection = projection;
}
self.faces.extend(faces);
Ok(start..self.faces.len())
}
/// Add Extrude & Bevel's solid ([`crate::extrude`]).
pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?;
self.add(faces, projection)
}
/// Add Rotate's plane ([`crate::plane`]).
pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?;
self.add(faces, projection)
}
/// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each
/// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids
/// drawn, but the faces of the others are not kept (their range is empty). A shape that does
/// not inflate is left out, as its solid is; the result says which, in order.
pub fn inflate(
&mut self,
shapes: &[(&PathData, FillRule)],
casts: &[bool],
bounds: Rect,
options: Inflate,
view: View,
) -> Vec<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, true)
.enumerate()
.map(|(i, r)| {
let (_, faces, projection) = r?;
if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) }
})
.collect()
}
/// Add Revolve's surface ([`crate::revolve`]).
pub fn revolve(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result<Range<usize>, String> {
let mut faces = vec![];
crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?;
let o = options.sanitized();
self.add(faces, Some(crate::revolve_projection(bounds, o)))
}
/// The shadow of the faces gathered, on the page, filled with the nonzero rule: `None` when
/// there are none, when the first light lights nothing (Shading is off or its intensity is 0)
/// or does not reach the plane (it is behind the object for a shadow behind it, or below the
/// plane for one below), or when nothing of the shadow falls within the plane.
pub fn path(&self, shadow: Shadow) -> Option<PathData> {
let all = 0..self.faces.len();
let all = std::slice::from_ref(&all);
self.path_on(&self.shadow_plane(shadow, all)?, all)
}
/// The faces in the ranges `faces`, from the faces gathered.
fn within(&self, faces: &[Range<usize>]) -> impl Iterator<Item = &Face3> {
faces.iter().flat_map(|r| self.faces.get(r.clone()).unwrap_or_default())
}
/// The plane `shadow` falls on for the faces in the ranges `faces` (its distance, size and
/// center follow from them), for [`ShadowCaster::path_on`]: `None` when there are none or when
/// the first light lights nothing or does not reach the plane.
pub fn shadow_plane(&self, shadow: Shadow, faces: &[Range<usize>]) -> Option<ShadowPlane> {
let p = self.projection?;
let light = p.light()?;
let s = shadow.sanitized();
let corners = || self.within(faces).flat_map(|(contours, _)| contours.iter().flatten());
let first = *corners().next()?;
let (lo, hi) = corners()
.fold((first, first), |(lo, hi), v| (V3(lo.0.min(v.0), lo.1.min(v.1), lo.2.min(v.2)), V3(hi.0.max(v.0), hi.1.max(v.1), hi.2.max(v.2))));
let size = (hi.0 - lo.0).max(hi.1 - lo.1).max(hi.2 - lo.2).max(1e-9);
// The plane's normal toward the object, and two axes along it.
let (n, u, w) = match s.position {
ShadowPosition::Behind => (V3(0.0, 0.0, 1.0), V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)),
ShadowPosition::Below => {
let r = |v: V3| p.view.rotate(v).unit();
(r(V3(0.0, -1.0, 0.0)), r(V3(1.0, 0.0, 0.0)), r(V3(0.0, 0.0, 1.0)))
}
};
let reach = n.dot(light);
if !reach.is_finite() || reach < MIN_LIGHT {
return None;
}
// The plane: every corner at least `distance` × size in front of it.
let level = corners().map(|v| n.dot(*v)).fold(f64::INFINITY, f64::min) - s.distance * size;
let centre = V3((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0, (lo.2 + hi.2) / 2.0);
let foot = add(centre, scale(n, level - n.dot(centre)));
Some(ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half: 2.0 * s.bounds * size })
}
/// The shadow the faces in the ranges `faces` cast on `plane`, on the page, filled with the
/// nonzero rule; `None` when nothing of it falls within the plane.
pub fn path_on(&self, plane: &ShadowPlane, faces: &[Range<usize>]) -> Option<PathData> {
let &ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half } = plane;
// A corner's shadow, in the plane's axes about the foot.
let cast = |v: V3| {
let q = add(v, scale(light, -(n.dot(v) - level) / reach));
let d = q.sub(foot);
Point::new(u.dot(d), w.dot(d))
};
let to_page = |q: Point| p.project(add(foot, add(scale(u, q.x), scale(w, q.y))));
let mut bp = BezPath::new();
for (contours, _) in self.within(faces) {
let flat: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| cast(*v)).collect()).collect();
let turn: f64 = flat.iter().map(|c| signed_area(c)).sum();
// A face edge-on to the light casts nothing.
if flat_area(turn, flat.iter().flatten()) {
continue;
}
for c in &flat {
let mut clipped = clip(c, half);
if turn < 0.0 {
clipped.reverse();
}
// A plane seen edge-on draws nothing.
let page: Vec<Point> = clipped.into_iter().map(to_page).collect();
if flat_area(signed_area(&page), &page) {
continue;
}
let Some((start, rest)) = page.split_first() else { continue };
bp.move_to(*start);
for q in rest {
bp.line_to(*q);
}
bp.close_path();
}
}
let path = PathData::from_bezpath(&bp);
(!path.subpaths.is_empty()).then_some(path)
}
}
/// Whether `area`, the signed area of a polygon with corners `points`, is within rounding of
/// nothing for its size (the square of its bounds' diagonal), or not a number, so the polygon draws
/// nothing. A long, narrow polygon keeps an area well above that.
fn flat_area<'a>(area: f64, points: impl IntoIterator<Item = &'a Point>) -> bool {
let mut points = points.into_iter();
let Some(first) = points.next() else { return true };
let bounds = points.fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p));
let size = bounds.width().powi(2) + bounds.height().powi(2);
!area.is_finite() || !size.is_finite() || area.abs() <= 1e-14 * size
}
fn add(a: V3, b: V3) -> V3 {
V3(a.0 + b.0, a.1 + b.1, a.2 + b.2)
}
fn scale(a: V3, k: f64) -> V3 {
V3(a.0 * k, a.1 * k, a.2 * k)
}
/// Twice the signed area of polygon `c` (positive counterclockwise with y up).
fn signed_area(c: &[Point]) -> f64 {
c.iter().zip(c.iter().cycle().skip(1)).take(c.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
}
/// Polygon `c` cut to the square from -`half` to `half` on both axes, edge by edge (the square is
/// convex, so each contour's winding inside it is kept).
fn clip(c: &[Point], half: f64) -> Vec<Point> {
let mut out: Vec<Point> = c.to_vec();
// Each side: the axis it cuts across (x or y) and whether it is the upper one.
for (across_x, upper) in [(true, false), (true, true), (false, false), (false, true)] {
let coord = |p: Point| if across_x { p.x } else { p.y };
let limit = if upper { half } else { -half };
let inside = |p: Point| if upper { coord(p) <= limit } else { coord(p) >= limit };
let input = std::mem::take(&mut out);
let Some(&last) = input.last() else { break };
let mut prev = last;
for &cur in &input {
let (a, b) = (inside(prev), inside(cur));
if a != b {
let (pc, cc) = (coord(prev), coord(cur));
let t = (limit - pc) / (cc - pc);
out.push(prev.lerp(cur, t));
}
if b {
out.push(cur);
}
prev = cur;
}
}
out
}
#[cfg(test)]
mod tests;
+187
View File
@@ -0,0 +1,187 @@
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape};
use super::*;
use crate::{Extrude, Inflate, Light, Lights, Revolve, View};
fn square(x: f64, y: f64, s: f64) -> PathData {
let mut b = BezPath::new();
b.move_to((x, y));
b.line_to((x + s, y));
b.line_to((x + s, y + s));
b.line_to((x, y + s));
b.close_path();
PathData::from_bezpath(&b)
}
/// The view straight on: no rotation, the default light.
fn front() -> View {
View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, ..View::default() }
}
fn extruded(view: View, shadow: Shadow) -> Option<PathData> {
let path = square(100.0, 100.0, 100.0);
let mut caster = ShadowCaster::new();
caster.extrude(&path, FillRule::NonZero, Rect::new(100.0, 100.0, 200.0, 200.0), Extrude::default(), view).unwrap();
caster.path(shadow)
}
/// The area the shadow covers, sampled on a grid over `r` with the nonzero rule.
fn covered(path: &PathData, r: Rect, steps: usize) -> f64 {
let bp = path.to_bezpath();
let (dx, dy) = (r.width() / steps as f64, r.height() / steps as f64);
let mut hits = 0;
for i in 0..steps {
for j in 0..steps {
let p = Point::new(r.x0 + (i as f64 + 0.5) * dx, r.y0 + (j as f64 + 0.5) * dy);
if bp.winding(p) != 0 {
hits += 1;
}
}
}
hits as f64 * dx * dy
}
#[test]
fn a_light_straight_in_front_casts_the_silhouette_behind_the_object() {
let view = View { light_height: 90.0, ..front() };
let path = extruded(view, Shadow::default()).expect("a shadow");
let b = path.bounds().unwrap();
for (got, want) in [(b.x0, 100.0), (b.y0, 100.0), (b.x1, 200.0), (b.y1, 200.0)] {
assert!((got - want).abs() < 1e-6, "{b:?}");
}
// Every face of the box projects into the square, and the nonzero union fills it whole.
let area = covered(&path, Rect::new(90.0, 90.0, 210.0, 210.0), 120);
assert!((area - 10_000.0).abs() < 300.0, "{area}");
}
#[test]
fn the_shadow_behind_falls_away_from_the_light_and_moves_with_the_distance() {
// The default light is up and to the left, in front: the shadow falls down and to the right.
let near = extruded(front(), Shadow::default()).unwrap().bounds().unwrap();
assert!(near.x1 > 200.0 + 1.0 && near.y1 > 200.0 + 1.0, "{near:?}");
assert!(near.x0 >= 100.0 - 1e-6 && near.y0 >= 100.0 - 1e-6, "{near:?}");
let far = extruded(front(), Shadow { distance: 1.0, ..Shadow::default() }).unwrap().bounds().unwrap();
assert!(far.x1 > near.x1 + 10.0 && far.y1 > near.y1 + 10.0, "{near:?} {far:?}");
}
#[test]
fn a_light_behind_the_object_casts_no_shadow_behind_it() {
assert!(extruded(View { light_behind: true, ..front() }, Shadow::default()).is_none());
let behind = Light { behind: true, ..Light::default() };
let lights = Lights::new(&[behind, Light::default()]).unwrap();
// The first light casts the shadow.
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none());
let lights = Lights::new(&[Light::default(), behind]).unwrap();
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_some());
}
#[test]
fn a_light_that_lights_nothing_casts_no_shadow() {
assert!(extruded(View { light_intensity: 0.0, ..front() }, Shadow::default()).is_none());
assert!(extruded(View { shade: false, ..front() }, Shadow::default()).is_none());
let dark = Light { intensity: 0.0, ..Light::default() };
let lights = Lights::new(&[dark, Light::default()]).unwrap();
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none());
}
#[test]
fn the_shadow_below_lies_under_the_object_on_its_floor() {
let below = Shadow { position: ShadowPosition::Below, ..Shadow::default() };
// Seen level, the floor is edge-on: nothing to draw.
assert!(extruded(front(), below).is_none());
// Seen from above (the effects' default Off-Axis Front view), the floor shows.
let off_axis = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..View::default() };
assert!(extruded(off_axis, below).is_some());
// Seen from straight above, the floor is square to the view, under the object: with the light
// straight in front too, the shadow is the object's outline, as the one behind it is.
let top = View { rotation_x: -90.0, light_height: 90.0, ..front() };
let under = extruded(top, below).unwrap().bounds().unwrap();
let back = extruded(top, Shadow::default()).unwrap().bounds().unwrap();
for (a, b) in [(under.x0, back.x0), (under.y0, back.y0), (under.x1, back.x1), (under.y1, back.y1)] {
assert!((a - b).abs() < 1e-6, "{under:?} {back:?}");
}
// A light from below the floor casts nothing on it.
let from_below = View { light_rotation: -90.0, light_height: 0.0, ..off_axis };
assert!(extruded(from_below, below).is_none());
}
#[test]
fn shadow_bounds_limit_the_plane_the_shadow_falls_on() {
// A low light casts a long shadow; the plane cuts it.
let low = View { light_height: 5.0, ..front() };
let small = extruded(low, Shadow { bounds: 0.1, ..Shadow::default() }).unwrap().bounds().unwrap();
let large = extruded(low, Shadow { bounds: 4.0, ..Shadow::default() }).unwrap().bounds().unwrap();
// At 10 % the square reaches 0.2 × the size (100) from the foot of the center (150, 150).
for v in [small.x0, small.y0, small.x1, small.y1] {
assert!((130.0 - 1e-6..=170.0 + 1e-6).contains(&v), "{small:?}");
}
assert!(large.width() > small.width() * 5.0, "{small:?} {large:?}");
for v in [large.x0, large.y0, large.x1, large.y1] {
assert!((-650.0 - 1e-6..=950.0 + 1e-6).contains(&v), "{large:?}");
}
}
#[test]
fn bad_settings_are_sanitized_and_an_empty_caster_casts_nothing() {
assert!(ShadowCaster::new().path(Shadow::default()).is_none());
let wild = Shadow { distance: f64::NAN, bounds: f64::INFINITY, ..Shadow::default() };
assert_eq!(wild.sanitized(), Shadow { bounds: 1.0, ..Shadow::default() });
assert_eq!(Shadow { distance: -3.0, bounds: 99.0, ..Shadow::default() }.sanitized(), Shadow { bounds: 4.0, ..Shadow::default() });
assert_eq!(ShadowPosition::parse("below"), Some(ShadowPosition::Below));
assert_eq!(ShadowPosition::parse("under"), None);
}
#[test]
fn the_faces_one_shadow_is_cast_from_are_bounded() {
// An open profile of 500 points revolves into about 64,000 faces at 128 segments: five fit,
// the sixth does not.
let mut b = BezPath::new();
b.move_to((110.0, 0.0));
for i in 1..500 {
b.line_to((110.0 + f64::from(i % 2) * 5.0, f64::from(i)));
}
let path = PathData::from_bezpath(&b);
let bounds = path.bounds().unwrap();
let options = Revolve { segments: 128, offset: 10.0, ..Revolve::default() };
let mut caster = ShadowCaster::new();
for _ in 0..5 {
let faces = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap();
assert!(faces.len() > 60_000, "{faces:?}");
}
let err = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap_err();
assert!(err.contains("too complex"), "{err}");
assert!(caster.len() <= MAX_SHADOW_FACES);
assert!(caster.path(Shadow::default()).is_some());
}
#[test]
fn a_small_solid_beside_a_huge_one_keeps_its_shadow() {
let bounds = Rect::new(0.0, 0.0, 1_000_010.0, 10.0);
let view = View { light_height: 90.0, ..front() };
let mut caster = ShadowCaster::new();
let small = caster.extrude(&square(0.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap();
let huge = caster.extrude(&square(1_000_000.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap();
let all = [small.clone(), huge];
let plane = caster.shadow_plane(Shadow { bounds: 4.0, ..Shadow::default() }, &all).unwrap();
let path = caster.path_on(&plane, std::slice::from_ref(&small)).expect("the small solid's shadow");
let b = path.bounds().unwrap();
assert!((b.width() - 10.0).abs() < 1e-3 && (b.height() - 10.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn every_effect_casts_a_shadow() {
let path = square(100.0, 100.0, 100.0);
let bounds = Rect::new(100.0, 100.0, 200.0, 200.0);
let mut plane = ShadowCaster::new();
plane.plane(&path, FillRule::NonZero, bounds, View::default()).unwrap();
assert!(plane.path(Shadow::default()).is_some());
let mut inflate = ShadowCaster::new();
let results = inflate.inflate(&[(&path, FillRule::NonZero)], &[true], bounds, Inflate::default(), View::default());
assert!(results.iter().all(Result::is_ok));
assert!(inflate.path(Shadow::default()).is_some());
let mut revolve = ShadowCaster::new();
revolve.revolve(&path, FillRule::NonZero, bounds, Revolve::default()).unwrap();
let b = revolve.path(Shadow::default()).unwrap().bounds().unwrap();
// The surface turns about the square's left edge, reaching 100 to either side of it.
assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}");
}
+11 -5
View File
@@ -9,6 +9,7 @@ use super::{
Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance,
perspective_scale,
};
use crate::shadow::Projection;
/// Turns 3D points into document points and shades faces for one view.
pub(super) struct Camera {
@@ -48,14 +49,19 @@ impl Camera {
pub(super) fn brightness(&self, n: V3) -> f64 {
crate::brightness(&self.view, n, self.light)
}
/// The rotation and projection this camera draws with, for a shadow cast from its faces.
pub(super) fn projection(&self) -> Projection {
Projection { view: self.view, origin: self.origin, eye: self.eye }
}
}
/// A face of a solid before projection: its contours in view space (holes running the other way)
/// and its outward normal.
pub(crate) type Face3 = (Vec<Vec<V3>>, V3);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection.
pub(crate) type Built = (Vec<Facet>, Vec<Face3>);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection,
/// and the projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Vec<Face3>, Option<Projection>);
/// Facets being collected for one solid.
pub(super) struct Builder<'a> {
@@ -250,7 +256,7 @@ impl Builder<'_> {
/// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]).
pub(super) fn finish(mut self) -> Built {
self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth));
(self.facets, self.mesh.unwrap_or_default())
(self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection()))
}
}
@@ -492,7 +498,7 @@ fn steps(o: &Extrude) -> Vec<Step> {
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets)
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets)
}
/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets.
@@ -602,7 +608,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets)
plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets)
}
/// [`plane`], or with `mesh` its face before projection instead of its facet.
+2 -2
View File
@@ -62,7 +62,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest
/// puff needs with Perspective. One result for each shape, in order.
pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec<Result<Vec<Facet>, String>> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect()
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect()
}
/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets.
@@ -81,7 +81,7 @@ pub(crate) fn inflate_faces(
let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max);
puffs.into_iter().map(move |p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh),
Ok(None) => Ok((vec![], vec![])),
Ok(None) => Ok((vec![], vec![], None)),
Err(e) => Err(e),
})
}
+64 -7
View File
@@ -230,24 +230,73 @@ pub(super) fn status_readouts(app: &VectorcraftApp, ui: &mut Ui, t: &Tokens) {
.collect();
// Reserve the spinner's space while idle too: starting a background job changes the text,
// not its origin. Messages take priority over optional readouts in a narrow window.
let message = if let Some(job) = busy.first() {
let (message, notes) = if let Some(job) = busy.first() {
let spinner = egui::Rect::from_center_size(egui::pos2(area.left() + 6.0, area.center().y), vec2(12.0, 12.0));
ui.put(spinner, egui::Spinner::new().size(12.0).color(t.accent));
let label = crate::i18n::msg(job);
let more = busy.len() - 1;
if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }
(if more > 0 { format!("{label}… (+{more})") } else { format!("{label}…") }, &[][..])
} else {
crate::i18n::msg(&app.ui.status)
(crate::i18n::msg(&app.ui.status), status_notes(&app.ui))
};
if !message.is_empty() {
let rect = egui::Rect::from_min_max(egui::pos2((area.left() + 18.0).min(right), area.top()), egui::pos2(right, area.bottom()));
status_field(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text);
status_field_with_notes(ui, "message", rect, message, egui::FontId::proportional(11.0), t.text, notes);
}
}
/// Most notes the status message's hover and access information list.
const MAX_LISTED_NOTES: usize = 30;
/// The notes behind the status message (the open's, [`crate::state::UiState::status_notes`]) when
/// there are several and the status bar still shows the message they came with, else none.
fn status_notes(ui: &crate::state::UiState) -> &[String] {
let notes = ui.status_notes.as_ref().filter(|(status, notes)| *status == ui.status && notes.len() > 1);
notes.map(|(_, notes)| notes.as_slice()).unwrap_or_default()
}
/// `notes` in the interface language, one to a line, the first `most` of them (at most
/// [`MAX_LISTED_NOTES`]) and how many more.
pub(super) fn listed_notes(notes: &[String], most: usize) -> String {
let lang = crate::i18n::current();
let most = most.min(MAX_LISTED_NOTES);
let mut lines: Vec<String> = notes.iter().take(most).map(|n| format!("• {}", crate::i18n::message(lang, n))).collect();
if notes.len() > most {
lines.push(format!("… (+{})", notes.len() - most));
}
lines.join("\n")
}
/// The hover of the status message `text` with `notes`: the message, then as many of the notes
/// ([`listed_notes`]) as fit in the window above or below the field `field`, wrapped within the
/// window's width.
fn notes_tooltip(response: egui::Response, field: egui::Rect, text: &str, notes: &[String]) -> egui::Response {
response.on_hover_ui(|ui| {
let screen = ui.ctx().content_rect();
let margins = ui.spacing().menu_margin.sum() + 2.0 * ui.spacing().item_spacing;
let width = ui.spacing().tooltip_width.min(screen.width() - margins.x).max(0.0);
ui.set_max_width(width);
let room = (field.top() - screen.top()).max(screen.bottom() - field.bottom()) - 2.0 * margins.y;
let tip = |most: usize| format!("{text}\n{}", listed_notes(notes, most));
let height =
|s: &str| egui::WidgetText::from(s).into_galley(ui, Some(egui::TextWrapMode::Wrap), width, egui::FontSelection::Default).size().y;
let mut most = notes.len().min(MAX_LISTED_NOTES);
while most > 0 && height(&tip(most)) > room {
most -= 1;
}
ui.add(egui::Label::new(tip(most)).wrap());
})
}
/// A field never grows with its text. Clipping also bounds over-wide glyphs; hover and access
/// information retain the complete value when its on-screen label is truncated.
fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32) {
status_field_with_notes(ui, id, rect, text, font, color, &[]);
}
/// [`status_field`], whose hover, when there are `notes`, lists them ([`notes_tooltip`]) and whose
/// access information has them after the text.
fn status_field_with_notes(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egui::FontId, color: egui::Color32, notes: &[String]) {
if rect.width() <= 0.0 || rect.height() <= 0.0 {
return;
}
@@ -261,9 +310,17 @@ fn status_field(ui: &mut Ui, id: &str, rect: egui::Rect, text: String, font: egu
let elided = galley.elided;
ui.painter().with_clip_rect(ui.clip_rect().intersect(rect)).galley(at, galley, color);
let response = ui.interact(rect, ui.scope_id().with(("status-field", id)), Sense::hover());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text));
if elided {
full_text_tooltip(response, text);
if notes.is_empty() {
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &text));
if elided {
full_text_tooltip(response, text);
}
return;
}
// The list is made only for a hover or a screen reader.
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, format!("{text}\n{}", listed_notes(notes, MAX_LISTED_NOTES))));
if response.hovered() {
notes_tooltip(response, rect, &text, notes);
}
}
+59
View File
@@ -461,6 +461,65 @@ fn a_clipped_status_message_shows_in_full_on_hover() {
assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "a message that fits has no tooltip: {hovered:?}");
}
/// The status message of an open with several notes lists them all on hover, and screen readers
/// receive them after the message, while the status bar shows that message. A later message
/// leaves the list out, and so does an open with one note.
#[test]
fn the_message_of_an_open_with_several_notes_lists_them_on_hover() {
let (mut app, ctx) = status_readout_app();
ctx.enable_accesskit();
let notes = vec!["the file's layers weren't read".to_string(), "a brush wasn't read".to_string()];
app.ui.status = crate::io::opened_with_notes(2, &notes[0]);
app.ui.status_notes = Some((app.ui.status.clone(), notes));
let listed = "• the file's layers weren't read\n• a brush wasn't read";
let full = format!("{}\n{listed}", app.ui.status);
let (texts, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar);
assert!(!status_text(&texts, "Opened").elided);
assert!(nodes.iter().any(|n| n.value() == Some(full.as_str())), "screen readers receive the notes: {nodes:?}");
assert!(hovered.iter().any(|t| t.text == full), "the tooltip lists the notes: {hovered:?}");
app.ui.status = "Saved recovery data for Untitled-1".to_owned();
let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Saved", super::status_bar);
assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}");
assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Saved")).count(), 1, "no tooltip: {hovered:?}");
// One note is the message itself: no list.
app.ui.status = crate::io::opened_with_notes(1, "a brush wasn't read");
app.ui.status_notes = Some((app.ui.status.clone(), vec!["a brush wasn't read".to_string()]));
let (_, nodes, hovered) = bar_hover(&mut app, &ctx, 1400.0, "Opened", super::status_bar);
assert!(!nodes.iter().any(|n| n.value().is_some_and(|v| v.contains("• "))), "{nodes:?}");
assert_eq!(hovered.iter().filter(|t| t.text.starts_with("Opened")).count(), 1, "no tooltip: {hovered:?}");
}
/// The list has at most 30 notes, then how many more there are.
#[test]
fn the_hover_lists_thirty_notes_and_counts_the_rest() {
crate::i18n::set_current(crate::i18n::Lang::EN);
let notes: Vec<String> = (1..=32).map(|i| format!("note {i}")).collect();
let listed = super::status_bar::listed_notes(&notes, usize::MAX);
let lines: Vec<&str> = listed.lines().collect();
assert_eq!((lines.len(), lines[0], lines[29], lines[30]), (31, "• note 1", "• note 30", "… (+2)"));
assert_eq!(super::status_bar::listed_notes(&notes[..2], usize::MAX), "• note 1\n• note 2");
assert_eq!(super::status_bar::listed_notes(&notes[..3], 1), "• note 1\n… (+2)");
}
/// A hover with more long notes than the window can show lists the ones that fit and how many
/// more there are, inside the window. A window 300 points tall is shorter than the main window's
/// smallest size (500 points) at UI Scaling 1.5.
#[test]
fn the_hover_of_many_notes_stays_inside_a_small_window() {
let (mut app, ctx) = status_readout_app();
let notes: Vec<String> = (1..=32).map(|i| format!("note {i}: {}", "this file's PostScript couldn't be read ".repeat(3))).collect();
app.ui.status = crate::io::opened_with_notes(notes.len(), &notes[0]);
app.ui.status_notes = Some((app.ui.status.clone(), notes));
let (_, _, hovered) = bar_hover(&mut app, &ctx, 700.0, "Opened", super::status_bar);
let tip: Vec<_> = hovered.iter().filter(|t| t.text.contains("\n• note 1:")).collect();
let [tip] = &tip[..] else { panic!("one tooltip lists the notes: {hovered:?}") };
assert!(tip.bounds.top() >= 0.0 && tip.bounds.bottom() <= 300.0 && tip.bounds.right() <= 700.0, "{tip:?}");
let last = tip.text.lines().last().unwrap_or_default();
assert!(last.starts_with("… (+") && !tip.text.contains("• note 30:"), "{last:?}");
}
/// The Help Bar's hint ends in an ellipsis when it is wider than the window and shows in full on
/// hover, in a tooltip that wraps within the window (#6622). The Dimension tool's hint is wider
/// than the smallest main window (800 points); 400 points is that window at UI Scaling 2.0.
+2
View File
@@ -8,6 +8,7 @@
mod about;
pub mod action_batch;
pub mod action_insert_menu_item;
pub mod action_new;
pub mod action_playback;
pub mod action_set_options;
@@ -391,6 +392,7 @@ registry! {
ActionPlayback: [action_playback::KIND] => action_playback::SPEC,
ActionStopRecord: [action_stop::RECORD_KIND] => action_stop::RECORD_SPEC,
ActionStopMessage: [action_stop::MESSAGE_KIND] => action_stop::MESSAGE_SPEC,
ActionInsertMenuItem: [action_insert_menu_item::KIND] => action_insert_menu_item::SPEC,
SliceOptions: [slices::OPTIONS] => slices::OPTIONS_SPEC,
DivideSlices: [slices::DIVIDE] => slices::DIVIDE_SPEC,
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
@@ -0,0 +1,261 @@
//! Actions panel menu › Insert Menu Item… (`ui.action.insertMenuItem`): the menu items that can be
//! steps of an action, to pick one from the list or find it by name.
//!
//! Its fields: `find` (the words the list is narrowed to), `command` and `params` (the item picked;
//! `command` is empty until one is), `note` (`none` or `several` when Find picked nothing), and
//! `set`, `action` and `step`, the place of the new step (no `set` and `action`: the recording in
//! progress). Picking an item runs nothing. OK inserts it; an invalid place is an error, and the
//! dialog stays open.
use std::sync::Arc;
use serde_json::{Value, json};
use super::DialogSpec;
use crate::state::Dialog;
use crate::theme::Tokens;
use crate::{VectorcraftApp, menus, widgets};
/// The dialog kind of Insert Menu Item.
pub const KIND: &str = "actionInsertMenuItem";
/// The most rows the list shows at once; Find narrows it.
const MAX_LISTED: usize = 200;
/// The width of the Menu Item label.
const LABEL_W: f32 = 80.0;
pub(super) const SPEC: DialogSpec = DialogSpec {
heading: |_| tl!("Insert Menu Item").into(),
body,
confirm,
ok_enabled: Some(|_, d| !d.str("command").is_empty()),
min_width: 460.0,
..DialogSpec::FORM
};
/// Opens Insert Menu Item for action `target` as (set, action), after `step`, else for the recording
/// in progress.
pub fn open(app: &mut VectorcraftApp, target: Option<(usize, usize)>, step: Option<usize>) {
let mut fields = json!({"find": "", "command": "", "params": {}, "note": ""});
if let Some((set, action)) = target {
fields["set"] = json!(set);
fields["action"] = json!(action);
if let Some(step) = step {
fields["step"] = json!(step);
}
}
app.ui.dialog = Some(Dialog::new(KIND, fields));
}
/// A row of the list: an item of [`menus::insertable_items`], with what it shows and what Find
/// matches worked out once.
pub(crate) struct Row {
command: &'static str,
params: Value,
/// The menu path and label in the UI language (`Object › Group`).
shown: String,
/// The English path and label, the shown one and the command id, lowercased, one per line.
haystack: String,
}
/// The rows in `lang`, for menus that name fonts in English when `english_names` is set.
fn build(lang: crate::i18n::Lang, english_names: bool) -> Vec<Row> {
menus::insertable_items(english_names)
.into_iter()
.map(|item| {
let parts = || item.path.iter().chain(std::iter::once(&item.label)).copied();
let english = parts().collect::<Vec<_>>().join(" › ");
let shown = parts().map(|s| crate::i18n::tr(lang, s)).collect::<Vec<_>>().join(" › ");
let haystack = format!("{}\n{}\n{}", english.to_lowercase(), shown.to_lowercase(), item.command.to_lowercase());
Row { command: item.command, params: item.params, shown, haystack }
})
.collect()
}
/// The rows, built again only when the language, the installed plug-ins, the font name setting or
/// the loaded fonts change, not on every frame.
fn rows(app: &VectorcraftApp, ctx: &egui::Context) -> Arc<Vec<Row>> {
type Key = (&'static str, u64, bool, u64, bool);
let lang = crate::i18n::current();
let english_names = app.session.prefs.font_names_in_english;
let db = vectorcraft_text::FontDb::global();
// Read before building, as the Font menu does: fonts that load meanwhile build the rows again.
let (cataloged, generation) = (db.installed_fonts_ready(), db.generation());
let key: Key = (lang.code(), menus::plugin_revision(), english_names, generation, cataloged);
let id = egui::Id::unique("action-insert-menu-item-rows");
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, Arc<Vec<Row>>)>(id))
&& k == key
{
return v;
}
let built = Arc::new(build(lang, english_names));
ctx.data_mut(|d| d.insert_temp::<(Key, Arc<Vec<Row>>)>(id, (key, built.clone())));
built
}
/// The rows that every word of `find` matches, in menu order.
fn matches<'a>(rows: &'a [Row], find: &str) -> impl Iterator<Item = &'a Row> {
let words: Vec<String> = find.to_lowercase().split_whitespace().map(str::to_string).collect();
rows.iter().filter(move |r| words.iter().all(|w| r.haystack.contains(w.as_str())))
}
/// Picks `row` in `d`.
fn pick(d: &mut Dialog, row: &Row) {
d.fields.insert("command".into(), json!(row.command));
d.fields.insert("params".into(), row.params.clone());
d.fields.insert("note".into(), json!(""));
}
/// Find: picks the one row that `find` matches; with none or several, picks nothing and notes it.
fn find(rows: &[Row], d: &mut Dialog) {
let found: Vec<&Row> = matches(rows, &d.str("find")).take(2).collect();
match found[..] {
[row] => pick(d, row),
[] => {
d.fields.insert("note".into(), json!("none"));
}
_ => {
d.fields.insert("note".into(), json!("several"));
}
}
}
/// Whether `row` is the item picked in `d`.
fn picked(d: &Dialog, row: &Row) -> bool {
d.fields.get("command").and_then(Value::as_str) == Some(row.command) && d.fields.get("params") == Some(&row.params)
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let rows = rows(app, ui.ctx());
let dim = Tokens::get(ui.ctx()).text_dim;
let mut find_now = false;
ui.horizontal(|ui| {
if super::form::text_named(ui, d, "find", tl!("Find"), 300.0) {
d.fields.insert("note".into(), json!(""));
}
find_now = widgets::secondary_button(ui, tl!("Find")).clicked();
});
if find_now {
find(&rows, d);
}
match d.str("note").as_str() {
"none" => {
ui.label(egui::RichText::new(tl!("No menu item matches.")).color(dim));
}
"several" => {
ui.label(egui::RichText::new(tl!("Several menu items match. Choose one from the list.")).color(dim));
}
_ => {}
}
ui.add_space(4.0);
let mut chosen: Option<usize> = None;
let query = d.str("find");
egui::ScrollArea::vertical().max_height(260.0).auto_shrink([false, true]).show(ui, |ui| {
for (i, row) in matches(&rows, &query).enumerate().take(MAX_LISTED) {
if ui.selectable_label(picked(d, row), row.shown.as_str()).clicked() {
chosen = Some(i);
}
}
});
if let Some(row) = chosen.and_then(|i| matches(&rows, &query).nth(i)) {
pick(d, row);
}
ui.add_space(4.0);
let shown = rows.iter().find(|r| picked(d, r)).map_or_else(
|| {
let command = d.str("command");
if command.is_empty() { tl!("None").to_string() } else { command }
},
|r| r.shown.clone(),
);
widgets::label_row(ui, "Menu Item:", LABEL_W, |ui| {
ui.label(shown);
});
ui.label(egui::RichText::new(tl!("Items that open a dialog, and File, Window and Help menu items, are not listed.")).color(dim));
false
}
fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {
let mut params = json!({"command": d.str("command"), "params": d.fields.get("params").cloned().unwrap_or_else(|| json!({}))});
for key in ["set", "action", "step"] {
if let Some(value) = d.fields.get(key) {
params[key] = value.clone();
}
}
let r = app.run(crate::panels::actions::INSERT_MENU_ITEM, params)?;
app.ui.dialog = None;
Ok(r)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::i18n::Lang;
#[test]
fn find_picks_the_one_match_and_notes_none_or_several() {
let rows = build(Lang::EN, true);
let mut d = Dialog::new(KIND, json!({"find": "object group", "command": "", "params": {}, "note": ""}));
find(&rows, &mut d);
assert_eq!((d.str("note"), d.str("command")), ("several".to_string(), String::new()), "Group and Ungroup both match");
d.fields.insert("find".into(), json!("OBJECT.UNGROUP"));
find(&rows, &mut d);
assert_eq!((d.str("command"), d.fields["params"].clone(), d.str("note")), ("object.ungroup".to_string(), json!({}), String::new()));
d.fields.insert("find".into(), json!("no such menu item here"));
find(&rows, &mut d);
assert_eq!((d.str("note"), d.str("command")), ("none".to_string(), "object.ungroup".to_string()), "a failed Find keeps the pick");
}
/// The list holds the menu items that run an engine command without asking for values first,
/// outside the application, File, Window and Help menus, and leaves out Undo, Redo, the Pastes,
/// Object › Align, Image Trace's Make and Make and Expand, Pattern's Make and Edit Pattern and the
/// saved-selection, saved-view and recent-font items.
#[test]
fn the_list_holds_only_menu_items_that_can_be_steps() {
let items = menus::insertable_items(true);
assert!(items.len() > 100, "only {} items", items.len());
for item in &items {
assert!(vectorcraft_engine::find_command(item.command).is_some(), "{item:?}");
assert!(!item.label.ends_with('…'), "{item:?}");
assert!(!["VectorCraft", "File", "Window", "Help"].contains(&item.path[0]), "{item:?}");
assert!(item.params.is_object(), "{item:?}");
}
let has = |command: &str| items.iter().any(|i| i.command == command);
for command in ["object.group", "object.ungroup", "object.arrange.bringToFront"] {
assert!(has(command), "{command}");
}
for command in [
"edit.undo",
"edit.redo",
"edit.paste",
"edit.pasteInFront",
"object.move",
"object.align",
"imageTrace.make",
"imageTrace.makeAndExpand",
"object.pattern.make",
"object.pattern.edit",
"file.save",
"view.zoomIn",
"select.recall1",
] {
assert!(!has(command), "{command}");
}
}
/// Rows show their menu path in the UI language, and Find matches the English path, the shown
/// one and the command id.
#[test]
fn rows_show_the_translated_path_and_match_english_shown_text_and_ids() {
let de = Lang::from_code("de").unwrap();
let rows = build(de, true);
let group = rows.iter().find(|r| r.command == "object.group").unwrap();
assert_eq!(group.shown, format!("{} › {}", crate::i18n::tr(de, "Object"), crate::i18n::tr(de, "Group")));
for words in ["object group", group.shown.as_str(), "object.group"] {
assert!(matches(&rows, words).any(|r| r.command == "object.group"), "{words}");
}
let shared = rows.iter().any(|r| rows.iter().any(|o| o.command == r.command && o.params != r.params));
assert!(shared, "items that share a command are listed apart, by their params");
}
}
+6
View File
@@ -391,6 +391,12 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(
vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE,
"shadowPosition",
) => {
vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => {
vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")]
}
+21
View File
@@ -333,6 +333,27 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
store_selected(d, &selected);
}
});
shadow_controls(ui, d);
}
/// The Shadows switch, and below it while the switch is on or mixed, the shadow's Position,
/// Distance from Object and Shadow Bounds. The first light casts the shadow, so they are
/// unavailable while Shading is off.
fn shadow_controls(ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| {
toggle(ui, d, "shadow", tl!("Shadows"), false);
if flag(d, "shadow", false) == Some(false) {
return;
}
let positions: Vec<(Value, &str)> = super::effect::own_choices(&d.str("__effect"), "shadowPosition", false)
.unwrap_or_default()
.into_iter()
.map(|(label, value)| (json!(value), label))
.collect();
choice(ui, d, "shadowPosition", tl!("Position"), &positions);
number(ui, d, "shadowDistance", tl!("Distance from Object"), (0.0, 100.0), "%", 1, None);
number(ui, d, "shadowBounds", tl!("Shadow Bounds"), (10.0, 400.0), "%", 1, None);
});
}
fn lighting_presets(ui: &mut egui::Ui, d: &mut Dialog) {
+21 -1
View File
@@ -148,6 +148,24 @@ fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable()
assert!(!h.app.session.in_interaction());
}
#[test]
fn the_shadows_switch_shows_its_settings_and_turns_the_shadow_on_in_one_undo_step() {
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", EXTRUDE, vec2(1280.0, 900.0));
let before = h.app.session.doc().unwrap().doc.clone();
h.settle();
h.click(h.rect("Lighting").center());
assert!(!h.texts.iter().any(|(s, _)| s == "Shadow Bounds"), "the settings show only while shadows are on");
h.click(h.rect("Shadows").center());
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields.get("shadow"), Some(&json!(true)));
assert!(h.texts.iter().any(|(s, _)| s == "Distance from Object"));
let state = h.app.session.doc().unwrap();
let node = state.doc.node(state.selection.objects[0]).unwrap();
assert_eq!(node.appearance.effects[0].params["shadow"], json!(true), "the preview draws the shadow");
dialogs::confirm(&mut h.app).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc().unwrap().doc, before);
}
#[test]
fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
let mut h = Harness::new(vec2(1280.0, 900.0));
@@ -158,7 +176,9 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
}
let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap();
assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact lighting controls with light management: {panel:?}");
// The Shadows switch adds one 28-point row to the 570-point bound; its settings show only
// while it is on.
assert!(panel.width() < 420.0 && panel.height() < 600.0, "compact lighting controls with light management: {panel:?}");
h.drag(h.rect("Height").center(), vec2(24.0, 0.0));
assert_ne!(h.value("lightHeight"), 30.0);
h.click(h.response(("revolve-reset", "lightHeight")).center());
+15
View File
@@ -5521,6 +5521,11 @@ theme Appearance Външен вид
Softness Мекота
Ambient Light Околна светлина
Light behind the object Светлина зад обекта
Shadows Сенки
Distance from Object Разстояние от обекта
Shadow Bounds Граници на сянката
Behind Object Зад обекта
Below Object Под обекта
3D Lighting Preset Предварителна настройка за 3D осветление
Standard Стандартно
Diffuse Дифузно
@@ -5898,6 +5903,16 @@ axis Y Axis Y ос
@msg message must be text message трябва да бъде текст
@msg allowContinue must be true or false allowContinue трябва да бъде true или false
@msg Batch cannot play an action with a stop Пакет не може да изпълни действие със спиране
Insert Menu Item… Вмъкване на елемент от меню…
Insert Menu Item Вмъкване на елемент от меню
Menu Item: Елемент от меню:
No menu item matches. Няма съвпадащ елемент от меню.
Several menu items match. Choose one from the list. Съвпадат няколко елемента от меню. Изберете един от списъка.
Items that open a dialog, and File, Window and Help menu items, are not listed. Елементите, които отварят диалогов прозорец, и елементите от менютата „Файл“, „Прозорец“ и „Помощ“ не са изброени.
@msg command must be text command трябва да бъде текст
@msg no menu item that can be inserted runs this command няма елемент от меню за вмъкване, който изпълнява тази команда
@msg the action already has 10,000 steps действието вече има 10 000 стъпки
@msg the action set would be larger than 8 MiB наборът от действия ще стане по-голям от 8 MiB
Playback Options Опции за възпроизвеждане
Accelerated Ускорено
Step by Step Стъпка по стъпка
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5605,6 +5605,11 @@ mark-style Japanese Japanski
Softness Mekoća
Ambient Light Ambijentalno svjetlo
Light behind the object Svjetlo iza objekta
Shadows Sjene
Distance from Object Udaljenost od objekta
Shadow Bounds Granice sjene
Behind Object Iza objekta
Below Object Ispod objekta
3D Lighting Preset Predložak 3D osvjetljenja
Standard Standardno
Diffuse Difuzno
@@ -5937,6 +5942,16 @@ axis Y Axis Y osa
@msg message must be text message mora biti tekst
@msg allowContinue must be true or false allowContinue mora biti true ili false
@msg Batch cannot play an action with a stop Paketna obrada ne može reprodukovati akciju sa zaustavljanjem
Insert Menu Item… Umetni stavku menija…
Insert Menu Item Umetni stavku menija
Menu Item: Stavka menija:
No menu item matches. Nijedna stavka menija ne odgovara.
Several menu items match. Choose one from the list. Odgovara više stavki menija. Izaberite jednu sa liste.
Items that open a dialog, and File, Window and Help menu items, are not listed. Stavke koje otvaraju dijalog i stavke menija Datoteka, Prozor i Pomoć nisu navedene.
@msg command must be text command mora biti tekst
@msg no menu item that can be inserted runs this command nijedna stavka menija koja se može umetnuti ne pokreće ovu naredbu
@msg the action already has 10,000 steps akcija već ima 10.000 koraka
@msg the action set would be larger than 8 MiB skup akcija bio bi veći od 8 MiB
Playback Options Opcije reprodukcije
Accelerated Ubrzano
Step by Step Korak po korak
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2266,6 +2266,11 @@ digits Malayalam Malaiàlam
Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat.
Light Clar
Light behind the object Llum darrere de l’objecte
Shadows Ombres
Distance from Object Distància de l’objecte
Shadow Bounds Límits de l’ombra
Behind Object Darrere de l’objecte
Below Object Sota l’objecte
Light Blue Blau clar
Light Red Vermell clar
Light Direction Direcció de la llum
@@ -3942,6 +3947,16 @@ digits Malayalam Malaiàlam
@msg message must be text message ha de ser text
@msg allowContinue must be true or false allowContinue ha de ser true o false
@msg Batch cannot play an action with a stop Lot no pot reproduir una acció amb una aturada
Insert Menu Item… Insereix un element de menú…
Insert Menu Item Insereix un element de menú
Menu Item: Element de menú:
No menu item matches. Cap element de menú no coincideix.
Several menu items match. Choose one from the list. Coincideixen diversos elements de menú. Trieu-ne un de la llista.
Items that open a dialog, and File, Window and Help menu items, are not listed. No es mostren els elements que obren un quadre de diàleg ni els elements dels menús Fitxer, Finestra i Ajuda.
@msg command must be text command ha de ser text
@msg no menu item that can be inserted runs this command cap element de menú que es pugui inserir no executa aquesta ordre
@msg the action already has 10,000 steps l’acció ja té 10.000 passos
@msg the action set would be larger than 8 MiB el conjunt d’accions superaria els 8 MiB
Playback Options Opcions de reproducció
Accelerated Accelerada
Step by Step Pas a pas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5736,6 +5736,11 @@ mark-style Japanese Japonské
Softness Měkkost
Ambient Light Okolní světlo
Light behind the object Světlo za objektem
Shadows Stíny
Distance from Object Vzdálenost od objektu
Shadow Bounds Hranice stínu
Behind Object Za objektem
Below Object Pod objektem
3D Lighting Preset Předvolba 3D osvětlení
Standard Standardní
Diffuse Rozptýlené
@@ -6127,6 +6132,16 @@ axis Y Axis osa Y
@msg message must be text message musí být text
@msg allowContinue must be true or false allowContinue musí být true nebo false
@msg Batch cannot play an action with a stop Dávka nemůže přehrát akci se zastavením
Insert Menu Item… Vložit položku nabídky…
Insert Menu Item Vložit položku nabídky
Menu Item: Položka nabídky:
No menu item matches. Žádná položka nabídky neodpovídá.
Several menu items match. Choose one from the list. Odpovídá několik položek nabídky. Vyberte jednu ze seznamu.
Items that open a dialog, and File, Window and Help menu items, are not listed. Položky, které otevírají dialogové okno, a položky nabídek Soubor, Okno a Nápověda nejsou uvedeny.
@msg command must be text command musí být text
@msg no menu item that can be inserted runs this command tento příkaz nespouští žádná položka nabídky, kterou lze vložit
@msg the action already has 10,000 steps akce už má 10 000 kroků
@msg the action set would be larger than 8 MiB sada akcí by byla větší než 8 MiB
Playback Options Možnosti přehrávání
Accelerated Zrychleně
Step by Step Krok za krokem
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5257,6 +5257,11 @@ mark-style Japanese Japansk
Softness Blødhed
Ambient Light Omgivelseslys
Light behind the object Lys bag objektet
Shadows Skygger
Distance from Object Afstand fra objektet
Shadow Bounds Skyggegrænser
Behind Object Bag objektet
Below Object Under objektet
3D Lighting Preset Forudindstilling for 3D-belysning
Standard Standard
Diffuse Diffust
@@ -5913,6 +5918,16 @@ axis Y Axis Y-akse
@msg message must be text message skal være tekst
@msg allowContinue must be true or false allowContinue skal være true eller false
@msg Batch cannot play an action with a stop Batch kan ikke afspille en handling med et stop
Insert Menu Item… Indsæt menupunkt…
Insert Menu Item Indsæt menupunkt
Menu Item: Menupunkt:
No menu item matches. Intet menupunkt passer.
Several menu items match. Choose one from the list. Flere menupunkter passer. Vælg et fra listen.
Items that open a dialog, and File, Window and Help menu items, are not listed. Punkter, der åbner en dialogboks, og punkter i menuerne Filer, Vindue og Hjælp vises ikke.
@msg command must be text command skal være tekst
@msg no menu item that can be inserted runs this command intet menupunkt, der kan indsættes, kører denne kommando
@msg the action already has 10,000 steps handlingen har allerede 10.000 trin
@msg the action set would be larger than 8 MiB handlingssættet ville blive større end 8 MiB
Playback Options Afspilningsindstillinger
Accelerated Accelereret
Step by Step Trin for trin
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5311,6 +5311,11 @@ effect Soft Weich
Softness Weichheit
Ambient Light Umgebungslicht
Light behind the object Licht hinter dem Objekt
Shadows Schatten
Distance from Object Abstand vom Objekt
Shadow Bounds Schattenbegrenzung
Behind Object Hinter dem Objekt
Below Object Unter dem Objekt
3D Lighting Preset 3D-Beleuchtungsvorgabe
Standard Standard
Diffuse Diffus
@@ -5710,6 +5715,16 @@ axis Y Axis Y-Achse
@msg message must be text message muss Text sein
@msg allowContinue must be true or false allowContinue muss true oder false sein
@msg Batch cannot play an action with a stop Die Stapelverarbeitung kann keine Aktion mit einem Stopp abspielen
Insert Menu Item… Menübefehl einfügen…
Insert Menu Item Menübefehl einfügen
Menu Item: Menübefehl:
No menu item matches. Kein Menübefehl passt.
Several menu items match. Choose one from the list. Mehrere Menübefehle passen. Wählen Sie einen aus der Liste.
Items that open a dialog, and File, Window and Help menu items, are not listed. Befehle, die ein Dialogfeld öffnen, sowie Befehle der Menüs „Datei“, „Fenster“ und „Hilfe“ werden nicht aufgeführt.
@msg command must be text command muss Text sein
@msg no menu item that can be inserted runs this command kein einfügbarer Menübefehl führt diesen Befehl aus
@msg the action already has 10,000 steps die Aktion hat bereits 10.000 Schritte
@msg the action set would be larger than 8 MiB der Aktionssatz wäre größer als 8 MiB
Playback Options Wiedergabeoptionen
Accelerated Beschleunigt
Step by Step Schrittweise
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5415,6 +5415,11 @@ digits Malayalam Μαλαγιάλαμ
to set spacing and orientation για ορισμό απόστασης και προσανατολισμού
: :
Light behind the object Φως πίσω από το αντικείμενο
Shadows Σκιές
Distance from Object Απόσταση από το αντικείμενο
Shadow Bounds Όρια σκιάς
Behind Object Πίσω από το αντικείμενο
Below Object Κάτω από το αντικείμενο
3D Lighting Preset Προεπιλογή φωτισμού 3D
Standard Τυπικός
Diffuse Διάχυτος
@@ -6078,6 +6083,16 @@ axis Y Axis Άξονας Y
@msg message must be text Το message πρέπει να είναι κείμενο
@msg allowContinue must be true or false Το allowContinue πρέπει να είναι true ή false
@msg Batch cannot play an action with a stop Η μαζική επεξεργασία δεν μπορεί να αναπαράγει ενέργεια με διακοπή
Insert Menu Item… Εισαγωγή στοιχείου μενού…
Insert Menu Item Εισαγωγή στοιχείου μενού
Menu Item: Στοιχείο μενού:
No menu item matches. Κανένα στοιχείο μενού δεν ταιριάζει.
Several menu items match. Choose one from the list. Ταιριάζουν πολλά στοιχεία μενού. Επιλέξτε ένα από τη λίστα.
Items that open a dialog, and File, Window and Help menu items, are not listed. Τα στοιχεία που ανοίγουν παράθυρο διαλόγου και τα στοιχεία των μενού «Αρχείο», «Παράθυρο» και «Βοήθεια» δεν εμφανίζονται.
@msg command must be text Το command πρέπει να είναι κείμενο
@msg no menu item that can be inserted runs this command κανένα στοιχείο μενού που μπορεί να εισαχθεί δεν εκτελεί αυτή την εντολή
@msg the action already has 10,000 steps η ενέργεια έχει ήδη 10.000 βήματα
@msg the action set would be larger than 8 MiB το σύνολο ενεργειών θα ξεπερνούσε τα 8 MiB
Playback Options Επιλογές αναπαραγωγής
Accelerated Επιταχυμένα
Step by Step Βήμα προς βήμα
Can't render this file because it is too large.
+15
View File
@@ -5677,6 +5677,11 @@ mark-style Japanese Japonesas
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
Shadows Sombras
Distance from Object Distancia del objeto
Shadow Bounds Límites de la sombra
Behind Object Detrás del objeto
Below Object Debajo del objeto
3D Lighting Preset Ajuste preestablecido de iluminación 3D
Standard Estándar
Diffuse Difusa
@@ -6069,6 +6074,16 @@ axis Y Axis Eje Y
@msg message must be text message debe ser texto
@msg allowContinue must be true or false allowContinue debe ser true o false
@msg Batch cannot play an action with a stop Lote no puede reproducir una acción con una parada
Insert Menu Item… Insertar elemento de menú…
Insert Menu Item Insertar elemento de menú
Menu Item: Elemento de menú:
No menu item matches. Ningún elemento de menú coincide.
Several menu items match. Choose one from the list. Coinciden varios elementos de menú. Elija uno de la lista.
Items that open a dialog, and File, Window and Help menu items, are not listed. No se muestran los elementos que abren un cuadro de diálogo ni los de los menús Archivo, Ventana y Ayuda.
@msg command must be text command debe ser texto
@msg no menu item that can be inserted runs this command ningún elemento de menú que se pueda insertar ejecuta este comando
@msg the action already has 10,000 steps la acción ya tiene 10 000 pasos
@msg the action set would be larger than 8 MiB el conjunto de acciones superaría los 8 MiB
Playback Options Opciones de reproducción
Accelerated Acelerada
Step by Step Paso a paso
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5618,6 +5618,11 @@ mark-style Japanese Japanilainen
Softness Pehmeys
Ambient Light Ympäristövalo
Light behind the object Valo objektin takana
Shadows Varjot
Distance from Object Etäisyys objektista
Shadow Bounds Varjon rajat
Behind Object Objektin takana
Below Object Objektin alla
3D Lighting Preset 3D-valaistuksen esiasetus
Standard Vakio
Diffuse Hajavalo
@@ -6157,6 +6162,16 @@ axis Y Axis Y-akseli
@msg message must be text message on oltava tekstiä
@msg allowContinue must be true or false allowContinue on oltava true tai false
@msg Batch cannot play an action with a stop Erä ei voi toistaa toimintoa, jossa on pysäytys
Insert Menu Item… Lisää valikkokohta…
Insert Menu Item Lisää valikkokohta
Menu Item: Valikkokohta:
No menu item matches. Mikään valikkokohta ei vastaa hakua.
Several menu items match. Choose one from the list. Useampi valikkokohta vastaa hakua. Valitse yksi luettelosta.
Items that open a dialog, and File, Window and Help menu items, are not listed. Valintaikkunan avaavia kohtia sekä Tiedosto-, Ikkuna- ja Ohje-valikkojen kohtia ei luetella.
@msg command must be text command on oltava tekstiä
@msg no menu item that can be inserted runs this command mikään lisättävissä oleva valikkokohta ei suorita tätä komentoa
@msg the action already has 10,000 steps toiminnossa on jo 10 000 vaihetta
@msg the action set would be larger than 8 MiB toimintosarja olisi suurempi kuin 8 MiB
Playback Options Toistoasetukset
Accelerated Nopeutettu
Step by Step Vaihe kerrallaan
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5732,6 +5732,11 @@ theme Appearance Apparence
Softness Douceur
Ambient Light Lumière ambiante
Light behind the object Lumière derrière l'objet
Shadows Ombres
Distance from Object Distance de l'objet
Shadow Bounds Limites de l'ombre
Behind Object Derrière l'objet
Below Object Sous l'objet
3D Lighting Preset Préréglage d’éclairage 3D
Standard Standard
Diffuse Diffus
@@ -6122,6 +6127,16 @@ axis Y Axis Axe Y
@msg message must be text message doit être du texte
@msg allowContinue must be true or false allowContinue doit valoir true ou false
@msg Batch cannot play an action with a stop Le traitement par lots ne peut pas exécuter un script comportant un arrêt
Insert Menu Item… Insérer une commande de menu…
Insert Menu Item Insérer une commande de menu
Menu Item: Commande de menu :
No menu item matches. Aucune commande de menu ne correspond.
Several menu items match. Choose one from the list. Plusieurs commandes de menu correspondent. Choisissez-en une dans la liste.
Items that open a dialog, and File, Window and Help menu items, are not listed. Les commandes qui ouvrent une boîte de dialogue et celles des menus Fichier, Fenêtre et Aide ne sont pas listées.
@msg command must be text command doit être du texte
@msg no menu item that can be inserted runs this command aucune commande de menu insérable n’exécute cette commande
@msg the action already has 10,000 steps le script compte déjà 10 000 étapes
@msg the action set would be larger than 8 MiB le jeu de scripts dépasserait 8 Mio
Playback Options Options d’exécution
Accelerated Accélérée
Step by Step Pas à pas
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -2485,6 +2485,11 @@ antiAlias Sharp शार्प
Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं।
Light लाइट
Light behind the object ऑब्जेक्ट के पीछे प्रकाश
Shadows छायाएँ
Distance from Object ऑब्जेक्ट से दूरी
Shadow Bounds छाया सीमाएँ
Behind Object ऑब्जेक्ट के पीछे
Below Object ऑब्जेक्ट के नीचे
Light Blue हल्का नीला
Light Direction लाइट की दिशा
Light Gray हल्का ग्रे
@@ -3391,6 +3396,16 @@ antiAlias Sharp शार्प
@msg message must be text message टेक्स्ट होना चाहिए
@msg allowContinue must be true or false allowContinue true या false होना चाहिए
@msg Batch cannot play an action with a stop बैच स्टॉप वाला एक्शन नहीं चला सकता
Insert Menu Item… मेनू आइटम जोड़ें…
Insert Menu Item मेनू आइटम जोड़ें
Menu Item: मेनू आइटम:
No menu item matches. कोई मेनू आइटम मेल नहीं खाता।
Several menu items match. Choose one from the list. कई मेनू आइटम मेल खाते हैं। सूची से एक चुनें।
Items that open a dialog, and File, Window and Help menu items, are not listed. डायलॉग खोलने वाले आइटम और फ़ाइल, विंडो और सहायता मेनू के आइटम सूचीबद्ध नहीं हैं।
@msg command must be text command टेक्स्ट होना चाहिए
@msg no menu item that can be inserted runs this command जोड़ा जा सकने वाला कोई मेनू आइटम यह कमांड नहीं चलाता
@msg the action already has 10,000 steps एक्शन में पहले से 10,000 कदम हैं
@msg the action set would be larger than 8 MiB एक्शन सेट 8 MiB से बड़ा हो जाएगा
Playback Options प्लेबैक ऑप्शन्स
Playback Options… प्लेबैक ऑप्शन्स…
Plug-in Info प्लगइन जानकारी
Can't render this file because it is too large.
+15
View File
@@ -5719,6 +5719,11 @@ imageTrace Outline Körvonal
Softness Lágyság
Ambient Light Környezeti fény
Light behind the object Fény az objektum mögött
Shadows Árnyékok
Distance from Object Távolság az objektumtól
Shadow Bounds Árnyék határai
Behind Object Az objektum mögött
Below Object Az objektum alatt
3D Lighting Preset 3D világítási készlet
Standard Normál
Diffuse Szórt
@@ -6110,6 +6115,16 @@ axis Y Axis Y tengely
@msg message must be text message: szöveg kell
@msg allowContinue must be true or false allowContinue: true vagy false kell
@msg Batch cannot play an action with a stop A kötegelt feldolgozás nem játszhat le megállást tartalmazó műveletet
Insert Menu Item… Menüelem beszúrása…
Insert Menu Item Menüelem beszúrása
Menu Item: Menüelem:
No menu item matches. Egy menüelem sem egyezik.
Several menu items match. Choose one from the list. Több menüelem is egyezik. Válasszon egyet a listából.
Items that open a dialog, and File, Window and Help menu items, are not listed. A párbeszédablakot megnyitó elemek, valamint a Fájl, az Ablak és a Súgó menü elemei nem szerepelnek a listában.
@msg command must be text command: szöveg kell
@msg no menu item that can be inserted runs this command egyetlen beszúrható menüelem sem futtatja ezt a parancsot
@msg the action already has 10,000 steps a műveletnek már 10 000 lépése van
@msg the action set would be larger than 8 MiB a műveletkészlet nagyobb lenne 8 MiB-nál
Playback Options Lejátszási beállítások
Accelerated Gyorsított
Step by Step Lépésenként
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5257,6 +5257,11 @@ mark-style Japanese Jepang
Softness Kelembutan
Ambient Light Cahaya ambient
Light behind the object Cahaya di belakang objek
Shadows Bayangan
Distance from Object Jarak dari objek
Shadow Bounds Batas bayangan
Behind Object Di belakang objek
Below Object Di bawah objek
3D Lighting Preset Preset Pencahayaan 3D
Standard Standar
Diffuse Difus
@@ -5913,6 +5918,16 @@ axis Y Axis Sumbu Y
@msg message must be text message harus berupa teks
@msg allowContinue must be true or false allowContinue harus true atau false
@msg Batch cannot play an action with a stop Batch tidak dapat memutar tindakan dengan titik henti
Insert Menu Item… Sisipkan item menu…
Insert Menu Item Sisipkan item menu
Menu Item: Item menu:
No menu item matches. Tidak ada item menu yang cocok.
Several menu items match. Choose one from the list. Beberapa item menu cocok. Pilih salah satu dari daftar.
Items that open a dialog, and File, Window and Help menu items, are not listed. Item yang membuka dialog, serta item menu Berkas, Jendela, dan Bantuan, tidak dicantumkan.
@msg command must be text command harus berupa teks
@msg no menu item that can be inserted runs this command tidak ada item menu yang dapat disisipkan yang menjalankan perintah ini
@msg the action already has 10,000 steps tindakan sudah memiliki 10.000 langkah
@msg the action set would be larger than 8 MiB set tindakan akan lebih besar dari 8 MiB
Playback Options Opsi pemutaran
Accelerated Dipercepat
Step by Step Langkah demi langkah
Can't render this file because it contains an unexpected character in line 2347 and column 5.
+15
View File
@@ -5346,6 +5346,11 @@ lighting Light Luce
Softness Morbidezza
Ambient Light Luce ambientale
Light behind the object Luce dietro l'oggetto
Shadows Ombre
Distance from Object Distanza dall'oggetto
Shadow Bounds Limiti dell'ombra
Behind Object Dietro l'oggetto
Below Object Sotto l'oggetto
3D Lighting Preset Predefinito illuminazione 3D
Standard Standard
Diffuse Diffusa
@@ -5744,6 +5749,16 @@ axis Y Axis Asse Y
@msg message must be text message deve essere testo
@msg allowContinue must be true or false allowContinue deve essere true o false
@msg Batch cannot play an action with a stop L'elaborazione batch non può eseguire un'azione con un'interruzione
Insert Menu Item… Inserisci voce di menu…
Insert Menu Item Inserisci voce di menu
Menu Item: Voce di menu:
No menu item matches. Nessuna voce di menu corrisponde.
Several menu items match. Choose one from the list. Corrispondono più voci di menu. Sceglierne una dall’elenco.
Items that open a dialog, and File, Window and Help menu items, are not listed. Le voci che aprono una finestra di dialogo e le voci dei menu File, Finestra e Aiuto non sono elencate.
@msg command must be text command deve essere testo
@msg no menu item that can be inserted runs this command nessuna voce di menu inseribile esegue questo comando
@msg the action already has 10,000 steps l’azione ha già 10.000 passaggi
@msg the action set would be larger than 8 MiB il set di azioni supererebbe 8 MiB
Playback Options Opzioni di riproduzione
Accelerated Accelerata
Step by Step Passo per passo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5388,6 +5388,11 @@ lighting Light ライト
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
Shadows シャドウ
Distance from Object オブジェクトからの距離
Shadow Bounds シャドウの範囲
Behind Object オブジェクトの背後
Below Object オブジェクトの下
3D Lighting Preset 3D ライティングプリセット
Standard 標準
Diffuse 拡散
@@ -5809,6 +5814,16 @@ axis Y Axis Y 軸
@msg message must be text message にはテキストを指定してください
@msg allowContinue must be true or false allowContinue には true または false を指定してください
@msg Batch cannot play an action with a stop バッチでは停止を含むアクションを再生できません
Insert Menu Item… メニュー項目を挿入…
Insert Menu Item メニュー項目を挿入
Menu Item: メニュー項目:
No menu item matches. 一致するメニュー項目がありません。
Several menu items match. Choose one from the list. 複数のメニュー項目が一致します。リストから 1 つ選択してください。
Items that open a dialog, and File, Window and Help menu items, are not listed. ダイアログを開く項目と、ファイル、ウィンドウ、ヘルプメニューの項目は表示されません。
@msg command must be text command にはテキストを指定してください
@msg no menu item that can be inserted runs this command このコマンドを実行する、挿入できるメニュー項目はありません
@msg the action already has 10,000 steps アクションにはすでに 10,000 ステップがあります
@msg the action set would be larger than 8 MiB アクションセットが 8 MiB を超えます
Playback Options 再生オプション
Accelerated 高速
Step by Step ステップ実行
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5298,6 +5298,11 @@ imageTrace Outline კონტური
In the File ფაილში
Keys კლავიშები
Light behind the object სინათლე ობიექტის უკან
Shadows ჩრდილები
Distance from Object მანძილი ობიექტიდან
Shadow Bounds ჩრდილის საზღვრები
Behind Object ობიექტის უკან
Below Object ობიექტის ქვეშ
3D Lighting Preset 3D განათების წინასწარი პარამეტრი
Standard სტანდარტული
Diffuse დიფუზური
@@ -6078,6 +6083,16 @@ axis Y Axis Y ღერძი
@msg message must be text message უნდა იყოს ტექსტი
@msg allowContinue must be true or false allowContinue უნდა იყოს true ან false
@msg Batch cannot play an action with a stop პაკეტურ დამუშავებას არ შეუძლია გაჩერების მქონე მოქმედების შესრულება
Insert Menu Item… მენიუს ელემენტის ჩასმა…
Insert Menu Item მენიუს ელემენტის ჩასმა
Menu Item: მენიუს ელემენტი:
No menu item matches. მენიუს არცერთი ელემენტი არ ემთხვევა.
Several menu items match. Choose one from the list. ემთხვევა მენიუს რამდენიმე ელემენტი. აირჩიეთ ერთი სიიდან.
Items that open a dialog, and File, Window and Help menu items, are not listed. დიალოგის გამხსნელი ელემენტები და მენიუების „ფაილი“, „ფანჯარა“ და „დახმარება“ ელემენტები სიაში არ არის.
@msg command must be text command უნდა იყოს ტექსტი
@msg no menu item that can be inserted runs this command ჩასმადი მენიუს არცერთი ელემენტი არ უშვებს ამ ბრძანებას
@msg the action already has 10,000 steps მოქმედებას უკვე 10 000 ნაბიჯი აქვს
@msg the action set would be larger than 8 MiB მოქმედებების ნაკრები 8 MiB-ზე დიდი გახდებოდა
Playback Options შესრულების პარამეტრები
Accelerated დაჩქარებული
Step by Step ნაბიჯ-ნაბიჯ
Can't render this file because it is too large.
+15
View File
@@ -5307,6 +5307,11 @@ lighting Light 광원
Softness 부드러움
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
Shadows 그림자
Distance from Object 개체로부터의 거리
Shadow Bounds 그림자 범위
Behind Object 개체 뒤
Below Object 개체 아래
3D Lighting Preset 3D 조명 사전 설정
Standard 표준
Diffuse 확산
@@ -5705,6 +5710,16 @@ axis Y Axis Y축
@msg message must be text message의 값은 텍스트여야 합니다
@msg allowContinue must be true or false allowContinue의 값은 true 또는 false여야 합니다
@msg Batch cannot play an action with a stop 일괄 처리에서는 중지가 있는 액션을 재생할 수 없습니다
Insert Menu Item… 메뉴 항목 삽입…
Insert Menu Item 메뉴 항목 삽입
Menu Item: 메뉴 항목:
No menu item matches. 일치하는 메뉴 항목이 없습니다.
Several menu items match. Choose one from the list. 여러 메뉴 항목이 일치합니다. 목록에서 하나를 선택하세요.
Items that open a dialog, and File, Window and Help menu items, are not listed. 대화 상자를 여는 항목과 파일, 윈도우, 도움말 메뉴의 항목은 표시되지 않습니다.
@msg command must be text command의 값은 텍스트여야 합니다
@msg no menu item that can be inserted runs this command 이 명령을 실행하는 삽입 가능한 메뉴 항목이 없습니다
@msg the action already has 10,000 steps 액션에 이미 단계가 10,000개 있습니다
@msg the action set would be larger than 8 MiB 액션 세트가 8 MiB를 넘게 됩니다
Playback Options 재생 옵션
Accelerated 가속
Step by Step 단계별
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5269,6 +5269,11 @@ mark-style Japanese Japans
Softness Zachtheid
Ambient Light Omgevingslicht
Light behind the object Licht achter het object
Shadows Schaduwen
Distance from Object Afstand tot object
Shadow Bounds Schaduwgrenzen
Behind Object Achter object
Below Object Onder object
3D Lighting Preset Voorinstelling 3D-belichting
Standard Standaard
Diffuse Diffuus
@@ -5964,6 +5969,16 @@ axis Y Axis Y-as
@msg message must be text message moet tekst zijn
@msg allowContinue must be true or false allowContinue moet true of false zijn
@msg Batch cannot play an action with a stop Batch kan geen actie met een stop afspelen
Insert Menu Item… Menu-item invoegen…
Insert Menu Item Menu-item invoegen
Menu Item: Menu-item:
No menu item matches. Geen menu-item komt overeen.
Several menu items match. Choose one from the list. Meerdere menu-items komen overeen. Kies er een uit de lijst.
Items that open a dialog, and File, Window and Help menu items, are not listed. Items die een dialoogvenster openen en items uit de menu’s Bestand, Venster en Help worden niet weergegeven.
@msg command must be text command moet tekst zijn
@msg no menu item that can be inserted runs this command geen invoegbaar menu-item voert deze opdracht uit
@msg the action already has 10,000 steps de actie heeft al 10.000 stappen
@msg the action set would be larger than 8 MiB de actieset zou groter worden dan 8 MiB
Playback Options Afspeelopties
Accelerated Versneld
Step by Step Stap voor stap
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5575,6 +5575,11 @@ mark-style Japanese Japońskie
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
Shadows Cienie
Distance from Object Odległość od obiektu
Shadow Bounds Granice cienia
Behind Object Za obiektem
Below Object Pod obiektem
3D Lighting Preset Ustawienie oświetlenia 3D
Standard Standardowe
Diffuse Rozproszone
@@ -5981,6 +5986,16 @@ axis Y Axis Oś Y
@msg message must be text message musi być tekstem
@msg allowContinue must be true or false allowContinue musi mieć wartość true lub false
@msg Batch cannot play an action with a stop Przetwarzanie wsadowe nie może odtworzyć operacji z zatrzymaniem
Insert Menu Item… Wstaw polecenie menu…
Insert Menu Item Wstaw polecenie menu
Menu Item: Polecenie menu:
No menu item matches. Żadne polecenie menu nie pasuje.
Several menu items match. Choose one from the list. Pasuje kilka poleceń menu. Wybierz jedno z listy.
Items that open a dialog, and File, Window and Help menu items, are not listed. Polecenia otwierające okno dialogowe oraz polecenia menu Plik, Okno i Pomoc nie są wyświetlane.
@msg command must be text command musi być tekstem
@msg no menu item that can be inserted runs this command żadne polecenie menu, które można wstawić, nie uruchamia tego polecenia
@msg the action already has 10,000 steps operacja ma już 10 000 kroków
@msg the action set would be larger than 8 MiB zestaw operacji byłby większy niż 8 MiB
Playback Options Opcje odtwarzania
Accelerated Przyspieszone
Step by Step Krok po kroku
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -4595,6 +4595,11 @@ axis Both Ambos
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
Shadows Sombras
Distance from Object Distância do objeto
Shadow Bounds Limites da sombra
Behind Object Atrás do objeto
Below Object Abaixo do objeto
3D Lighting Preset Predefinição de iluminação 3D
Standard Padrão
Diffuse Difusa
@@ -6042,6 +6047,16 @@ axis Y Axis Eixo Y
@msg message must be text message deve ser texto
@msg allowContinue must be true or false allowContinue deve ser true ou false
@msg Batch cannot play an action with a stop Lote não pode reproduzir uma ação com uma parada
Insert Menu Item… Inserir Item de Menu…
Insert Menu Item Inserir Item de Menu
Menu Item: Item de menu:
No menu item matches. Nenhum item de menu corresponde.
Several menu items match. Choose one from the list. Vários itens de menu correspondem. Escolha um da lista.
Items that open a dialog, and File, Window and Help menu items, are not listed. Itens que abrem uma caixa de diálogo e itens dos menus Arquivo, Janela e Ajuda não são listados.
@msg command must be text command deve ser texto
@msg no menu item that can be inserted runs this command nenhum item de menu que possa ser inserido executa este comando
@msg the action already has 10,000 steps a ação já tem 10.000 etapas
@msg the action set would be larger than 8 MiB o conjunto de ações ficaria maior que 8 MiB
Playback Options Opções de Reprodução
Accelerated Acelerada
Step by Step Passo a Passo
Can't render this file because it contains an unexpected character in line 6 and column 47.
+15
View File
@@ -5340,6 +5340,11 @@ lighting Light Свет
Softness Мягкость
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
Shadows Тени
Distance from Object Расстояние от объекта
Shadow Bounds Границы тени
Behind Object Позади объекта
Below Object Под объектом
3D Lighting Preset Предустановка 3D освещения
Standard Стандартное
Diffuse Рассеянное
@@ -5738,6 +5743,16 @@ axis Y Axis Ось Y
@msg message must be text message должно быть текстом
@msg allowContinue must be true or false allowContinue должно быть true или false
@msg Batch cannot play an action with a stop Пакет не может воспроизвести действие с остановкой
Insert Menu Item… Вставить пункт меню…
Insert Menu Item Вставить пункт меню
Menu Item: Пункт меню:
No menu item matches. Нет подходящих пунктов меню.
Several menu items match. Choose one from the list. Подходит несколько пунктов меню. Выберите один из списка.
Items that open a dialog, and File, Window and Help menu items, are not listed. Пункты, открывающие диалоговое окно, и пункты меню «Файл», «Окно» и «Справка» не показаны.
@msg command must be text command должно быть текстом
@msg no menu item that can be inserted runs this command ни один пункт меню, который можно вставить, не выполняет эту команду
@msg the action already has 10,000 steps в действии уже 10 000 шагов
@msg the action set would be larger than 8 MiB набор действий стал бы больше 8 MiB
Playback Options Параметры воспроизведения
Accelerated Ускоренно
Step by Step Пошагово
Can't render this file because it is too large.
+15
View File
@@ -5634,6 +5634,11 @@ theme Appearance Utseende
Softness Mjukhet
Ambient Light Omgivande ljus
Light behind the object Ljus bakom objektet
Shadows Skuggor
Distance from Object Avstånd från objektet
Shadow Bounds Skuggans gränser
Behind Object Bakom objektet
Below Object Under objektet
3D Lighting Preset Förinställning för 3D-belysning
Standard Standard
Diffuse Diffust
@@ -6146,6 +6151,16 @@ axis Y Axis Y-axel
@msg message must be text message måste vara text
@msg allowContinue must be true or false allowContinue måste vara true eller false
@msg Batch cannot play an action with a stop Batch kan inte spela upp en åtgärd med ett stopp
Insert Menu Item… Infoga menyalternativ…
Insert Menu Item Infoga menyalternativ
Menu Item: Menyalternativ:
No menu item matches. Inget menyalternativ matchar.
Several menu items match. Choose one from the list. Flera menyalternativ matchar. Välj ett i listan.
Items that open a dialog, and File, Window and Help menu items, are not listed. Alternativ som öppnar en dialogruta och alternativ i menyerna Fil, Fönster och Hjälp visas inte.
@msg command must be text command måste vara text
@msg no menu item that can be inserted runs this command inget menyalternativ som kan infogas kör det här kommandot
@msg the action already has 10,000 steps åtgärden har redan 10 000 steg
@msg the action set would be larger than 8 MiB åtgärdsuppsättningen skulle bli större än 8 MiB
Playback Options Uppspelningsalternativ
Accelerated Accelererad
Step by Step Steg för steg
Can't render this file because it contains an unexpected character in line 5 and column 25.
+15
View File
@@ -5424,6 +5424,11 @@ theme Appearance Görünüm
Softness Yumuşaklık
Ambient Light Ortam Işığı
Light behind the object Nesnenin arkasındaki ışık
Shadows Gölgeler
Distance from Object Nesneden uzaklık
Shadow Bounds Gölge sınırları
Behind Object Nesnenin arkasında
Below Object Nesnenin altında
Registration: Kayıt:
Dimension Boyut
Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir.
@@ -6083,6 +6088,16 @@ axis Y Axis Y ekseni
@msg message must be text "message" metin olmalıdır
@msg allowContinue must be true or false "allowContinue" true veya false olmalıdır
@msg Batch cannot play an action with a stop Toplu işlem durak içeren bir eylemi oynatamaz
Insert Menu Item… Menü Öğesi Ekle…
Insert Menu Item Menü Öğesi Ekle
Menu Item: Menü öğesi:
No menu item matches. Eşleşen menü öğesi yok.
Several menu items match. Choose one from the list. Birden çok menü öğesi eşleşiyor. Listeden birini seçin.
Items that open a dialog, and File, Window and Help menu items, are not listed. İletişim kutusu açan öğeler ile Dosya, Pencere ve Yardım menülerinin öğeleri listelenmez.
@msg command must be text "command" metin olmalıdır
@msg no menu item that can be inserted runs this command eklenebilen hiçbir menü öğesi bu komutu çalıştırmaz
@msg the action already has 10,000 steps eylemde zaten 10.000 adım var
@msg the action set would be larger than 8 MiB eylem seti 8 MiB'tan büyük olurdu
Playback Options Oynatma Seçenekleri
Accelerated Hızlandırılmış
Step by Step Adım Adım
Can't render this file because it contains an unexpected character in line 5 and column 25.
+15
View File
@@ -5326,6 +5326,11 @@ lighting Light Світло
Softness М'якість
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
Shadows Тіні
Distance from Object Відстань від об'єкта
Shadow Bounds Межі тіні
Behind Object Позаду об'єкта
Below Object Під об'єктом
3D Lighting Preset Попереднє налаштування 3D освітлення
Standard Стандартне
Diffuse Розсіяне
@@ -5724,6 +5729,16 @@ axis Y Axis Вісь Y
@msg message must be text message має бути текстом
@msg allowContinue must be true or false allowContinue має бути true або false
@msg Batch cannot play an action with a stop Пакет не може відтворити дію із зупинкою
Insert Menu Item… Вставити пункт меню…
Insert Menu Item Вставити пункт меню
Menu Item: Пункт меню:
No menu item matches. Немає відповідних пунктів меню.
Several menu items match. Choose one from the list. Відповідає кілька пунктів меню. Виберіть один зі списку.
Items that open a dialog, and File, Window and Help menu items, are not listed. Пункти, що відкривають діалогове вікно, і пункти меню «Файл», «Вікно» та «Довідка» не показано.
@msg command must be text command має бути текстом
@msg no menu item that can be inserted runs this command жоден пункт меню, який можна вставити, не виконує цю команду
@msg the action already has 10,000 steps дія вже має 10 000 кроків
@msg the action set would be larger than 8 MiB набір дій став би більшим за 8 MiB
Playback Options Параметри відтворення
Accelerated Прискорено
Step by Step Покроково
Can't render this file because it contains an unexpected character in line 182 and column 5.
+15
View File
@@ -5329,6 +5329,11 @@ theme Appearance Giao diện
Softness Độ mềm
Ambient Light Ánh sáng môi trường
Light behind the object Ánh sáng phía sau đối tượng
Shadows Bóng đổ
Distance from Object Khoảng cách từ đối tượng
Shadow Bounds Giới hạn bóng
Behind Object Phía sau đối tượng
Below Object Bên dưới đối tượng
3D Lighting Preset Thiết lập sẵn ánh sáng 3D
Standard Tiêu chuẩn
Diffuse Khuếch tán
@@ -5725,6 +5730,16 @@ axis Y Axis Trục Y
@msg message must be text message phải là văn bản
@msg allowContinue must be true or false allowContinue phải là true hoặc false
@msg Batch cannot play an action with a stop Hàng loạt không thể phát hành động có điểm dừng
Insert Menu Item… Chèn mục menu…
Insert Menu Item Chèn mục menu
Menu Item: Mục menu:
No menu item matches. Không có mục menu nào khớp.
Several menu items match. Choose one from the list. Có nhiều mục menu khớp. Hãy chọn một mục trong danh sách.
Items that open a dialog, and File, Window and Help menu items, are not listed. Các mục mở hộp thoại và các mục trong menu Tệp, Cửa sổ và Trợ giúp không được liệt kê.
@msg command must be text command phải là văn bản
@msg no menu item that can be inserted runs this command không có mục menu nào có thể chèn chạy lệnh này
@msg the action already has 10,000 steps hành động đã có 10.000 bước
@msg the action set would be larger than 8 MiB bộ hành động sẽ lớn hơn 8 MiB
Playback Options Tùy chọn phát lại
Accelerated Tăng tốc
Step by Step Từng bước
Can't render this file because it contains an unexpected character in line 204 and column 5.
+15
View File
@@ -5608,6 +5608,11 @@ lighting Light 光源
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
Shadows 阴影
Distance from Object 与对象的距离
Shadow Bounds 阴影边界
Behind Object 对象后方
Below Object 对象下方
3D Lighting Preset 3D 光照预设
Standard 标准
Diffuse 漫射
@@ -5965,6 +5970,16 @@ axis Y Axis Y 轴
@msg message must be text message 必须是文本
@msg allowContinue must be true or false allowContinue 必须是 true 或 false
@msg Batch cannot play an action with a stop 批处理无法播放含有停止的动作
Insert Menu Item… 插入菜单项…
Insert Menu Item 插入菜单项
Menu Item: 菜单项:
No menu item matches. 没有匹配的菜单项。
Several menu items match. Choose one from the list. 有多个菜单项匹配。请从列表中选择一个。
Items that open a dialog, and File, Window and Help menu items, are not listed. 未列出会打开对话框的项目,以及“文件”“窗口”和“帮助”菜单中的项目。
@msg command must be text command 必须是文本
@msg no menu item that can be inserted runs this command 没有可插入的菜单项运行此命令
@msg the action already has 10,000 steps 该动作已有 10,000 个步骤
@msg the action set would be larger than 8 MiB 动作组将超过 8 MiB
Playback Options 播放选项
Accelerated 加速
Step by Step 逐步
Can't render this file because it contains an unexpected character in line 2 and column 73.
+15
View File
@@ -5429,6 +5429,11 @@ lighting Light 光源
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
Shadows 陰影
Distance from Object 與物件的距離
Shadow Bounds 陰影邊界
Behind Object 物件後方
Below Object 物件下方
3D Lighting Preset 3D 光照預設
Standard 標準
Diffuse 漫射
@@ -5762,6 +5767,16 @@ axis Y Axis Y 軸
@msg message must be text message 必須是文字
@msg allowContinue must be true or false allowContinue 必須是 true 或 false
@msg Batch cannot play an action with a stop 批次無法播放含有停止的動作
Insert Menu Item… 插入選單項目…
Insert Menu Item 插入選單項目
Menu Item: 選單項目:
No menu item matches. 沒有相符的選單項目。
Several menu items match. Choose one from the list. 有多個選單項目相符。請從清單中選擇一個。
Items that open a dialog, and File, Window and Help menu items, are not listed. 未列出會開啟對話框的項目,以及「檔案」、「視窗」和「說明」選單中的項目。
@msg command must be text command 必須是文字
@msg no menu item that can be inserted runs this command 沒有可插入的選單項目執行此指令
@msg the action already has 10,000 steps 這個動作已有 10,000 個步驟
@msg the action set would be larger than 8 MiB 動作組會超過 8 MiB
Playback Options 播放選項
Accelerated 加速
Step by Step 逐步
Can't render this file because it contains an unexpected character in line 6 and column 47.
+45
View File
@@ -80,6 +80,51 @@ mod tests {
assert!(app.ui.dialog.is_none());
}
/// A file opened with a note about its page first and a note that its layers weren't read
/// second: the status bar names the layers note, counts both, and keeps both, the layers note
/// first, for its hover. A message about its linked files still replaces that message. Two
/// notes of what was left out keep the order the import gave them, and an open without notes
/// leaves the message and the notes an earlier open left.
#[test]
fn an_open_names_the_note_of_what_was_left_out_first() {
use vectorcraft_testkit::ai;
let (mut app, _) = app();
let damaged = b"%AI12_CompressedData not zlib";
let notes = |r: &Value| -> Vec<String> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).map(str::to_string).collect() };
let pdf = ai::pdf(damaged, "0 g 150 20 100 60 re f");
let r = open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap();
let [page, layers] = &notes(&r)[..] else { panic!("two notes: {r}") };
assert!(layers.contains("layers weren't read") && !page.contains("layers weren't read"), "{r}");
assert_eq!(app.ui.status, opened_with_notes(2, layers));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![layers.clone(), page.clone()])));
// A message about its linked files replaces the open's message, as before.
app.session.prefs.update_links = "manually".into();
let mut linked = r.clone();
linked["modifiedLinks"] = json!([{ "ids": [1] }]);
opened(&mut app, &linked);
assert_eq!(app.ui.status, dialogs::missing_links::changed_on_disk_status(1));
assert_eq!(app.ui.status_notes, Some((opened_with_notes(2, layers), vec![layers.clone(), page.clone()])));
let r = open_bytes(&mut app, "damaged.ai", &ai::ai(damaged, ai::page_pdf()), None).unwrap();
let both = notes(&r);
assert_eq!(both.len(), 2, "{r}");
assert_eq!(app.ui.status, opened_with_notes(2, &both[0]));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), both)));
let r = open_document(&mut app, "page.pdf", &pdf, None, &json!({"editingData": false})).unwrap();
let [only] = &notes(&r)[..] else { panic!("one note: {r}") };
assert_eq!(app.ui.status, opened_with_notes(1, only));
assert_eq!(app.ui.status_notes, Some((app.ui.status.clone(), vec![only.clone()])));
let clean = br#"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="10" height="10"/></svg>"#;
open_bytes(&mut app, "damaged.pdf", &pdf, None).unwrap();
let shown = (app.ui.status.clone(), app.ui.status_notes.clone());
let r = open_bytes(&mut app, "clean.svg", clean, None).unwrap();
assert_eq!(r["warnings"], json!([]), "{r}");
assert_eq!((app.ui.status.clone(), app.ui.status_notes.clone()), shown);
}
/// #1685: a save or an export that can't write its file says so in a dialog that names the
/// file and the reason and stays until it is closed; the document stays modified and keeps
/// the file it had. An agent's request only gets the error, and a message that arrives while
+18 -3
View File
@@ -137,15 +137,30 @@ pub fn open_document(app: &mut VectorcraftApp, name: &str, bytes: &[u8], path: O
}
/// A document just opened (`r`: `document.open`'s result): the views follow, the status bar says
/// what didn't come in as it was, and missing links are asked about.
/// what didn't come in as it was, and missing links are asked about. The status bar names the
/// first of the notes that say reading the file left something out (the ones that make Save ask
/// before writing over it), else the first note, and keeps the notes in that order for its hover.
pub(crate) fn opened(app: &mut VectorcraftApp, r: &Value) {
app.sync_views();
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
app.status(opened_with_notes(w.len(), w[0].as_str().unwrap_or_default()));
let notes = open_notes(app, r);
if let Some(first) = notes.first() {
app.status(opened_with_notes(notes.len(), first));
app.ui.status_notes = Some((app.ui.status.clone(), notes));
}
crate::dialogs::missing_links::after_open(app, r);
}
/// The notes of the open `r`, those of what reading the file left out (its document's
/// `import_losses`) first, each group in the order the import gave them.
fn open_notes(app: &VectorcraftApp, r: &Value) -> Vec<String> {
let notes: Vec<String> = r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str).map(str::to_string).collect();
let index = r["index"].as_u64().and_then(|i| usize::try_from(i).ok());
let losses = index.and_then(|i| app.session.documents().get(i)).map(|st| st.import_losses.as_slice()).unwrap_or_default();
let (mut first, rest): (Vec<String>, Vec<String>) = notes.into_iter().partition(|n| losses.contains(n));
first.extend(rest);
first
}
/// The status bar's line for a document opened with `n` notes, the first of them `first`.
pub(crate) fn opened_with_notes(n: usize, first: &str) -> String {
crate::i18n::msg_n(n as u64, "Opened with {n} note: {first}", "Opened with {n} notes: {first}", &[("first", first)])
+3 -1
View File
@@ -64,7 +64,9 @@ pub(crate) use bar::{home_key, home_showing};
#[cfg(test)]
use bar::{menu_body, pointer_reaches_title};
pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_items};
pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut, shown_shortcut};
pub use entries::{
InsertableItem, MenuEntry, Row, context_entries, entry, insertable, insertable_items, menu_entries, pretty_shortcut, shown_shortcut,
};
pub(crate) use invoke::graph_dialog;
#[cfg(test)]
pub(crate) use invoke::with_held_keys;
+73
View File
@@ -127,6 +127,79 @@ pub fn context_entries(app: &VectorcraftApp) -> Vec<MenuEntry> {
out
}
/// A menu item that the Actions panel's Insert Menu Item… adds to an action as a step
/// ([`insertable_items`]).
#[derive(Clone, Debug, PartialEq)]
pub struct InsertableItem {
/// The menu title and the submenus that hold the item, as the tree names them in English.
pub path: Vec<&'static str>,
/// The item's label in the tree, in English.
pub label: &'static str,
pub command: &'static str,
/// The item's params; `{}` for an item that has none.
pub params: Value,
}
/// The menus whose items are not added to actions: the application menu, File (documents and
/// files), Window (panels and workspaces) and Help.
const NOT_INSERTED_MENUS: [&str; 4] = ["VectorCraft", "File", "Window", "Help"];
/// Commands whose menu items do more than run the command, which action playback does not do:
/// Object › Image Trace › Make and Make and Expand trace off the UI thread with progress and Cancel
/// ([`crate::trace_runs::start`]), and Object › Pattern › Make and Edit Pattern open the Pattern
/// Options panel.
const MENU_ONLY_COMMANDS: [&str; 4] = ["imageTrace.make", "imageTrace.makeAndExpand", "object.pattern.make", "object.pattern.edit"];
/// Whether the item labeled `label` in menu `title`, which runs command `id` with `p`, can be a step
/// of an action. It can when it runs an engine command, which action playback runs as the menu
/// does; its label does not end with "…", the mark of an item that asks for values first; it is not
/// in a menu of [`NOT_INSERTED_MENUS`]; and it is not Undo, Redo, a Paste, an item that stands for a
/// saved selection, a saved view or a recent font, a plug-in's item or an action's own command. Nor
/// is it Object › Align, whose menu items add the Align To choice and the selection's key to their
/// params when clicked ([`crate::panels::align::with_align_to`]), which playback does not, or another
/// item of [`MENU_ONLY_COMMANDS`].
pub fn insertable(title: &str, label: &str, id: &str, p: &Value) -> bool {
use super::slots::{RECENT_FONT_IDS, SAVED_SELECTION_IDS, SAVED_VIEW_IDS};
vectorcraft_engine::find_command(id).is_some()
&& !label.ends_with('…')
&& !NOT_INSERTED_MENUS.contains(&title)
&& !matches!(id, "edit.undo" | "edit.redo" | "command.batch" | "object.align")
&& !MENU_ONLY_COMMANDS.contains(&id)
&& !id.starts_with("edit.paste")
&& !id.starts_with("prefs.")
&& !id.starts_with("action.")
&& ![&RECENT_FONT_IDS[..], &SAVED_SELECTION_IDS[..], &SAVED_VIEW_IDS[..]].iter().any(|ids| ids.contains(&id))
&& !super::labels::lists_plugin(id, p)
}
/// Every menu item that can be a step of an action ([`insertable`]), in menu order. An item that
/// shows in two places is listed in both. `english_names` is as [`menu_tree_named`] takes it.
pub fn insertable_items(english_names: bool) -> Vec<InsertableItem> {
fn walk(title: &'static str, path: &mut Vec<&'static str>, items: &[Item], out: &mut Vec<InsertableItem>) {
for it in items {
match it {
Item::Cmd(label, id, p) if insertable(title, label, id, p) => out.push(InsertableItem {
path: path.clone(),
label,
command: id,
params: if p.is_null() { serde_json::json!({}) } else { p.clone() },
}),
Item::Sub(label, children) => {
path.push(label);
walk(title, path, children, out);
path.pop();
}
_ => {}
}
}
}
let mut out = vec![];
for (title, items) in menu_tree_named(english_names) {
walk(title, &mut vec![title], &items, &mut out);
}
out
}
fn flatten(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
+1
View File
@@ -919,6 +919,7 @@ fn run_ui_part11(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result
crate::panels::actions::START_RECORDING => crate::panels::actions::record(app, p),
crate::panels::actions::STOP_RECORDING => crate::panels::actions::stop(app, p),
crate::panels::actions::INSERT_STOP => crate::panels::actions::insert_stop(app, p),
crate::panels::actions::INSERT_MENU_ITEM => crate::panels::actions::insert_menu_item(app, p),
crate::panels::actions::CONTINUE_PLAYBACK => crate::panels::actions::continue_playback(app, p),
crate::panels::actions::DELETE_STEP => crate::panels::actions::delete_step(app, p),
"ui.batchDialog" => {
+7 -1
View File
@@ -1396,7 +1396,13 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
crate::panels::actions::INSERT_STOP,
"Insert Stop",
"",
"{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, or no action and no recording is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
"{set?: index, action?: index, step?: index, message?, allowContinue?: bool} insert a stop, at which a playing action waits and shows message. With set and action (0 is the top set or action), the stop goes into that action, after step, else after its last step; without them it goes into the recording in progress, after the steps recorded so far. message defaults to none, and allowContinue, which offers Continue in the message box, defaults to false. A stop in an action becomes the panel's selected step, and its action and set become the selected ones. An index past the last set, action or step, a param of another type, params that are not an object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Stop… (dialog `actionStopRecord` with message, allowContinue, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
crate::panels::actions::INSERT_MENU_ITEM,
"Insert Menu Item",
"",
"{set?: index, action?: index, step?: index, command, params?: object} insert the command of a menu item as a step, without running it; the step plays as choosing the item runs it. command and params (default {}) must be those of a menu item that can be a step: one that runs an engine command, whose label does not end with \"…\", outside the VectorCraft, File, Window and Help menus, and that is not Undo, Redo, a Paste, a batch (command.batch), a preference (prefs.*) or action (action.*) command, an Object › Align item, Image Trace's Make or Make and Expand, Pattern's Make or Edit Pattern, a saved selection, saved view or recent font item, or a plug-in's item. The place is as ui.action.insertStop takes it: with set and action, after step, else after the action's last step; without them, the recording in progress. The new step becomes the panel's selected step, and its action and set become the selected ones. A command and params that no such menu item has, a param of another type, params that are not an object, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file more than 8 MiB, counting the steps that the recording in progress adds to the set, is an error and changes nothing. The panel menu's Insert Menu Item… (dialog `actionInsertMenuItem` with find, command, params, set, action and step) runs this on OK → {set, action, step}, or {recording: true}",
),
(
crate::panels::actions::CONTINUE_PLAYBACK,
+451 -12
View File
@@ -870,31 +870,145 @@ const SELECTED_STEP: &str = "actions-selected-step";
/// them, it goes into the recording in progress, after the steps recorded so far. `message` is
/// the text playback shows (default: none), and `allowContinue` offers Continue in the message box
/// (default: false). An index past the last set, action or step, a param of another type, params
/// that are not an object, or no action and no recording is an error, and then nothing changes.
/// Record Stop's OK runs this. A stop in an action becomes the selected step, and its action and set
/// become the selected ones. → {set, action, step}, or {recording: true}
/// that are not an object, or no action and no recording is an error, and then nothing changes. So
/// is a stop that would give the action more than 10,000 steps, or its set a file larger than 8 MiB,
/// the most Load Actions… reads. Record Stop's OK runs this. A stop in an action becomes the selected
/// step, and its action and set become the selected ones. → {set, action, step}, or {recording: true}
pub fn insert_stop(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
if !p.is_object() {
return Err("`params` must be an object".into());
}
let (message, allow_continue) = stop_params(p)?;
let step = (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue}));
insert(app, p, (STOP_STEP.to_string(), json!({"message": message, "allowContinue": allow_continue})))
}
/// The command that inserts a menu item's command as a step ([`insert_menu_item`]).
pub const INSERT_MENU_ITEM: &str = "ui.action.insertMenuItem";
/// `ui.action.insertMenuItem {set?, action?, step?, command, params?}`: inserts the command of a
/// menu item as a step, without running it. `command` and `params` (default: `{}`) are those of a
/// menu item that can be a step ([`crate::menus::insertable_items`]); the step plays as the item
/// runs. The place is as [`insert_stop`] takes it, and the step is selected as a stop is. A command
/// and params that no such item has, a param of another type, params that are not an object, or a
/// place [`insert_stop`] refuses is an error, and then nothing changes. Insert Menu Item's OK runs
/// this. → {set, action, step}, or {recording: true}
pub fn insert_menu_item(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
if !p.is_object() {
return Err("`params` must be an object".into());
}
let command = p.get("command").and_then(Value::as_str).ok_or("command must be text")?;
let params = match p.get("params") {
None => json!({}),
Some(v) if v.is_object() => v.clone(),
Some(_) => return Err("`params` must be an object".into()),
};
let items = crate::menus::insertable_items(app.session.prefs.font_names_in_english);
if !items.iter().any(|item| item.command == command && item.params == params) {
return Err("no menu item that can be inserted runs this command".into());
}
insert(app, p, (command.to_string(), params))
}
/// Err when adding `step` to the recording in progress would give the action that Stop
/// Playing/Recording keeps more than [`files::MAX_ACTION_STEPS`] steps, or its set a file larger
/// than [`files::MAX_SET_FILE`], the most Load Actions… reads.
fn recording_room(app: &VectorcraftApp, step: &(String, Value)) -> Result<(), String> {
let Some((set, name, start)) = app.ui.recording.clone() else { return Err("no such action".into()) };
let start = app.session.journal.held().unwrap_or(start);
let mut steps = app.session.journal_for_action(start);
let into = app.ui.recording_into;
let holder = into.and_then(|id| app.ui.action_sets.iter().position(|s| s.actions.iter().any(|a| a.id == id))).unwrap_or(set);
let Some(mut s) = app.ui.action_sets.get(holder).cloned() else { return Ok(()) };
let at = match into.and_then(|id| s.actions.iter().position(|a| a.id == id)) {
Some(at) => at,
None => {
s.actions.push(Action::new(name, vec![]));
s.actions.len() - 1
}
};
if s.actions[at].steps.len() + steps.len() >= files::MAX_ACTION_STEPS {
return Err("the action already has 10,000 steps".into());
}
steps.push(step.clone());
s.actions[at].steps.extend(steps);
if files::write_set(&s).len() > files::MAX_SET_FILE {
return Err("the action set would be larger than 8 MiB".into());
}
Ok(())
}
/// What Stop Playing/Recording adds to a set ([`recorded_into`]).
struct Recorded {
/// The set's action that the recording records into; None for the new action that the
/// recording adds.
into: Option<usize>,
/// The new action's name.
name: String,
/// The steps recorded so far.
steps: Vec<(String, Value)>,
}
/// What Stop Playing/Recording adds to set `set`; None when the recording in progress adds nothing
/// to it.
fn recorded_into(app: &VectorcraftApp, set: usize) -> Option<Recorded> {
let (recording_set, name, start) = app.ui.recording.as_ref()?;
let into = match app.ui.recording_into {
Some(id) => Some(app.ui.action_sets.get(set)?.actions.iter().position(|a| a.id == id)?),
None if *recording_set == set => None,
None => return None,
};
let steps = app.session.journal_for_action(app.session.journal.held().unwrap_or(*start));
Some(Recorded { into, name: name.clone(), steps })
}
/// Puts `step` where params `p` say ([`insert_stop`]) → what [`insert_stop`] returns. When the
/// recording in progress adds steps to the set, the limits count them as Stop Playing/Recording
/// would leave the set.
fn insert(app: &mut VectorcraftApp, p: &Value, step: (String, Value)) -> Result<Value, String> {
let index = |k: &str| p.get(k).and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok());
if p.get("set").is_none() && p.get("action").is_none() && p.get("step").is_none() {
if app.ui.recording.is_none() {
return Err("no such action".into());
}
recording_room(app, &step)?;
app.session.journal.push(step);
return Ok(json!({"recording": true}));
}
let (set, at) = index("set").zip(index("action")).ok_or("no such action")?;
let action = app.ui.action_sets.get_mut(set).and_then(|s| s.actions.get_mut(at)).ok_or("no such action")?;
let recorded = recorded_into(app, set);
let pending = match &recorded {
Some(r) if r.into == Some(at) => r.steps.len(),
_ => 0,
};
let s = app.ui.action_sets.get_mut(set).filter(|s| at < s.actions.len()).ok_or("no such action")?;
let action = &mut s.actions[at];
let after = match p.get("step") {
None => action.steps.len(),
Some(_) => index("step").filter(|&s| s < action.steps.len()).ok_or("no such action step")? + 1,
};
if action.steps.len() + pending >= files::MAX_ACTION_STEPS {
return Err("the action already has 10,000 steps".into());
}
let excluded = action.excluded_steps.clone();
action.insert_step(after, step);
let id = action.id;
let file_len = match recorded {
None => files::write_set(s).len(),
Some(Recorded { into, name, steps }) => {
let mut kept = s.clone();
match into {
Some(into) => kept.actions[into].steps.extend(steps),
None => kept.actions.push(Action::new(name, steps)),
}
files::write_set(&kept).len()
}
};
if file_len > files::MAX_SET_FILE {
let action = &mut s.actions[at];
action.steps.remove(after);
action.excluded_steps = excluded;
return Err("the action set would be larger than 8 MiB".into());
}
app.ui.action_set = Some(set);
app.ui.action_sets_selected = vec![set];
if let Some(ctx) = app.ui_ctx.clone() {
@@ -922,8 +1036,8 @@ pub const DELETE_STEP: &str = "ui.action.deleteStep";
/// `ui.action.deleteStep {set, action, step}`: deletes step `step`, a stop or a command, of the
/// action at index `action` of set `set`. The steps after it keep whether they are excluded, and
/// no step of that action stays selected. A Record Stop dialog open on that step closes, and one
/// open on a later step of the action follows it up. A missing or invalid index is an error, and
/// no step of that action stays selected. A Record Stop or Insert Menu Item dialog open on that step
/// closes, and one open on a later step of the action follows it up. A missing or invalid index is an error, and
/// then nothing changes. Undo does not bring the step back. Delete Selection runs this for the
/// selected step. → {set, action, step}
pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
@@ -943,7 +1057,7 @@ pub fn delete_step(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
.ui
.dialog
.as_ref()
.filter(|d| d.kind == crate::dialogs::action_stop::RECORD_KIND)
.filter(|d| holds_step(d))
.filter(|d| d.fields.get("set").and_then(number) == Some(set) && d.fields.get("action").and_then(number) == Some(at))
.and_then(|d| d.fields.get("step").and_then(number));
if held == Some(step) {
@@ -1190,10 +1304,16 @@ pub fn delete_sets(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String>
fn holds_set(d: &crate::state::Dialog) -> bool {
d.kind == crate::dialogs::action_batch::KIND
|| d.kind == crate::dialogs::action_new::KIND
|| d.kind == crate::dialogs::action_stop::RECORD_KIND
|| holds_step(d)
|| (d.kind == crate::dialogs::action_set_options::KIND && d.fields.contains_key("set"))
}
/// Whether dialog `d` is a Record Stop or Insert Menu Item dialog, which holds the place of a new
/// step in its `set`, `action` and `step` fields.
fn holds_step(d: &crate::state::Dialog) -> bool {
d.kind == crate::dialogs::action_stop::RECORD_KIND || d.kind == crate::dialogs::action_insert_menu_item::KIND
}
/// The command that duplicates an action or an action set ([`duplicate`]).
pub const DUPLICATE: &str = "ui.actionSet.duplicate";
@@ -1240,9 +1360,9 @@ pub fn duplicate(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let Some(mut copy) = src.actions.get(a).map(Action::copy) else { return Err("no such action".into()) };
copy.name = format!("{} copy", copy.name);
src.actions.insert(a + 1, copy);
// A Batch or Record Stop dialog open on a later action of the set follows it down.
if let Some(d) =
app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || d.kind == crate::dialogs::action_stop::RECORD_KIND)
// A Batch, Record Stop or Insert Menu Item dialog open on a later action of the set follows it
// down.
if let Some(d) = app.ui.dialog.as_mut().filter(|d| d.kind == crate::dialogs::action_batch::KIND || holds_step(d))
&& d.fields.get("set").and_then(index) == Some(set)
&& let Some(da) = d.fields.get("action").and_then(index).filter(|&da| da > a)
{
@@ -1317,6 +1437,10 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
let step = selected_step(app, ui.ctx(), selected);
crate::dialogs::action_stop::open_record(app, if recording { None } else { selected }, step);
}
if widgets::menu_item(ui, tl!("Insert Menu Item…"), recording || has, false) {
let step = selected_step(app, ui.ctx(), selected);
crate::dialogs::action_insert_menu_item::open(app, if recording { None } else { selected }, step);
}
if widgets::menu_item(ui, tl!("Action Options…"), has, false)
&& let Some((s, a)) = selected
{
@@ -3963,6 +4087,253 @@ mod tests {
assert!(app.ui.action_sets[0].actions.last().unwrap().steps.is_empty(), "the refused stop is not recorded");
}
#[test]
fn insert_menu_item_goes_after_the_given_step_or_at_the_end_runs_nothing_and_plays_as_the_menu_runs_it() {
let (mut app, x) = app_with_three_moves();
app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 2]);
let doc = app.session.active().unwrap().doc.clone();
let journal = app.session.journal.len();
let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap();
assert_eq!(r, json!({"set": 0, "action": 0, "step": 1}));
let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "type.changeCase", "params": {"case": "upper"}})).unwrap();
assert_eq!(r, json!({"set": 0, "action": 0, "step": 4}));
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "inserting runs nothing");
assert_eq!(app.session.journal.len(), journal, "and records nothing");
let action = &app.ui.action_sets[0].actions[0];
let ids: Vec<&str> = action.steps.iter().map(|(c, _)| c.as_str()).collect();
assert_eq!(ids, ["object.move", "object.group", "object.move", "object.move", "type.changeCase"]);
assert_eq!((&action.steps[1].1, &action.steps[4].1), (&json!({}), &json!({"case": "upper"})));
assert_eq!(action.excluded_steps, BTreeSet::from([0, 3]), "the step excluded after the insertion point moved with it");
app.ui.action_sets[0].actions[0].excluded_steps.insert(4);
let before = undo_steps(&app);
let r = play(&mut app, 0, 0).unwrap();
let results = r["results"].as_array().unwrap();
assert_eq!(results.len(), 2, "the group and the included move play, and the excluded change of case does not: {r}");
assert!(results[0]["id"].is_u64(), "the group step made a group: {r}");
assert_eq!(undo_steps(&app), before + 1, "the action plays as one undo step");
assert_eq!(x(&app), 2.0, "the move of 2 moved the group");
}
#[test]
fn an_inserted_menu_item_plays_step_by_step() {
let (mut app, x) = app_with_three_moves();
app.ui.action_sets[0].actions[0].steps.truncate(1);
app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap();
app.run(PLAYBACK_OPTIONS, json!({"speed": "step"})).unwrap();
let rect = app.session.active().unwrap().selection.objects.first().copied();
let before = undo_steps(&app);
let r = play(&mut app, 0, 0).unwrap();
assert_eq!(r["pending"], json!(1), "the move ran, the group waits: {r}");
play_out(&mut app);
assert_eq!(undo_steps(&app), before + 2, "each step its own undo step");
assert_eq!(x(&app), 1.0);
let selected = app.session.active().unwrap().selection.objects.first().copied();
assert!(selected.is_some() && selected != rect, "the group step ran and selected the new group");
}
#[test]
fn insert_menu_item_refuses_what_no_menu_item_runs_and_changes_nothing() {
let (mut app, _) = app_with_three_moves();
let place = |extra: Value| {
let mut p = json!({"set": 0, "action": 0});
if let (Some(p), Some(extra)) = (p.as_object_mut(), extra.as_object()) {
p.extend(extra.clone());
}
p
};
let none = "no menu item that can be inserted runs this command";
for (params, want) in [
(place(json!({})), "command must be text"),
(place(json!({"command": 5})), "command must be text"),
(place(json!({"command": "no.such.command"})), none),
// Undo, a File menu item, an item that asks for values first, the app's own commands and
// an action's own command.
(place(json!({"command": "edit.undo"})), none),
(place(json!({"command": "file.save"})), none),
(place(json!({"command": "object.move"})), none),
(place(json!({"command": "object.move", "params": {"dx": 1, "dy": 0}})), none),
(place(json!({"command": "view.zoomIn"})), none),
(place(json!({"command": INSERT_MENU_ITEM})), none),
(place(json!({"command": "edit.paste"})), none),
// Align, whose menu items add the Align To choice when clicked.
(place(json!({"command": "object.align", "params": {"horizontal": "left"}})), none),
// Image Trace, whose menu items trace off the UI thread with progress and Cancel.
(place(json!({"command": "imageTrace.make", "params": {"preset": "Default"}})), none),
(place(json!({"command": "imageTrace.makeAndExpand", "params": {"preset": "Default"}})), none),
// Pattern's Make and Edit Pattern, whose menu items open the Pattern Options panel.
(place(json!({"command": "object.pattern.make"})), none),
(place(json!({"command": "object.pattern.edit"})), none),
// Params that no menu item gives its command.
(place(json!({"command": "type.changeCase"})), none),
(place(json!({"command": "type.changeCase", "params": {"case": "middle"}})), none),
(place(json!({"command": "object.group", "params": {"ids": [1]}})), none),
(place(json!({"command": "object.group", "params": [1]})), "`params` must be an object"),
(place(json!({"command": "object.group", "params": null})), "`params` must be an object"),
(place(json!({"command": "object.group", "step": 3})), "no such action step"),
(place(json!({"command": "object.group", "step": null})), "no such action step"),
(json!({"command": "object.group"}), "no such action"),
(json!({"command": "object.group", "set": 0}), "no such action"),
(json!({"command": "object.group", "set": 0, "action": 1}), "no such action"),
(json!({"command": "object.group", "set": 1, "action": 0}), "no such action"),
(json!(5), "`params` must be an object"),
(json!(null), "`params` must be an object"),
] {
assert_eq!(app.run(INSERT_MENU_ITEM, params.clone()).unwrap_err(), want, "{params}");
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "{params}");
}
}
/// A listed menu item runs from the menu what its step runs in playback: the click opens no
/// dialog, and it journals the item's own command with the item's own params.
#[test]
fn every_listed_menu_item_runs_from_the_menu_what_its_step_holds() {
for item in crate::menus::insertable_items(false) {
let (mut app, _) = app_with_three_moves();
let start = app.session.journal.len();
crate::menus::invoke(&mut app, item.command, item.params.clone());
assert!(app.ui.dialog.is_none(), "{item:?} opens a dialog");
let ran = app.session.journal_for_action(start);
assert!(ran.iter().all(|(c, p)| c != item.command || *p == item.params), "{item:?} ran {ran:?}");
}
}
#[test]
fn insert_menu_item_goes_into_the_recording() {
let (mut app, _) = app_with_three_moves();
app.run(START_RECORDING, json!({})).unwrap();
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap(), json!({"recording": true}));
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "file.save"})).unwrap_err(), "no menu item that can be inserted runs this command");
app.run(STOP_RECORDING, json!({})).unwrap();
let recorded = app.ui.action_sets[0].actions.last().unwrap();
assert_eq!(recorded.steps, [("object.group".to_string(), json!({}))]);
}
/// An action stays one that Load Actions… reads back: a stop or a menu item that would give it
/// more than 10,000 steps, or its set's file more than 8 MiB, is refused, and the action keeps its
/// steps and exclusions as they were.
#[test]
fn inserted_steps_keep_an_action_set_that_load_actions_reads() {
let (mut app, _) = app_with_three_moves();
let full = vec![("object.group".to_string(), json!({})); files::MAX_ACTION_STEPS];
app.ui.action_sets[0].actions[0].steps = full;
let steps = "the action already has 10,000 steps";
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), steps);
assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0, "step": 0})).unwrap_err(), steps);
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), files::MAX_ACTION_STEPS);
app.ui.action_sets[0].actions[0].steps.pop();
assert!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).is_ok(), "the 10,000th step goes in");
let saved = files::write_set(&app.ui.action_sets[0]);
assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), files::MAX_ACTION_STEPS, "and loads again");
let pad = |n: usize| vec![("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)}))];
app.ui.action_sets[0].actions[0].steps = pad(0);
app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([0, 7]);
let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len();
app.ui.action_sets[0].actions[0].steps = pad(room - 8);
let size = "the action set would be larger than 8 MiB";
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 0, "command": "object.group"})).unwrap_err(), size);
assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), size);
let action = &app.ui.action_sets[0].actions[0];
assert_eq!((action.steps.len(), &action.excluded_steps), (1, &BTreeSet::from([0, 7])), "nothing changed");
assert!(files::write_set(&app.ui.action_sets[0]).len() <= files::MAX_SET_FILE);
}
/// The limits hold for a step inserted into the recording in progress too: they count the steps
/// recorded so far and, for New Action's recording, the action's own steps and its set.
#[test]
fn steps_inserted_into_a_recording_keep_an_action_set_that_load_actions_reads() {
let (mut app, _) = app_with_three_moves();
app.run(START_RECORDING, json!({})).unwrap();
app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap();
for _ in 1..files::MAX_ACTION_STEPS - 1 {
app.session.journal.push(("object.group".to_string(), json!({})));
}
let steps = "the action already has 10,000 steps";
assert_eq!(app.run(INSERT_STOP, json!({})).unwrap(), json!({"recording": true}), "the 10,000th step goes in");
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), steps);
assert_eq!(app.run(INSERT_STOP, json!({})).unwrap_err(), steps);
app.run(STOP_RECORDING, json!({})).unwrap();
let saved = files::write_set(&app.ui.action_sets[0]);
assert_eq!(files::read_set(&saved, "Test").unwrap().actions.last().unwrap().steps.len(), files::MAX_ACTION_STEPS, "and loads again");
let (mut app, _) = app_with_three_moves();
let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)}));
app.ui.action_sets[0].actions[0].steps = vec![pad(0)];
let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len();
app.ui.action_sets[0].actions[0].steps = vec![pad(room - 8)];
let into = app.ui.action_sets[0].actions[0].id;
app.run(START_RECORDING, json!({})).unwrap();
app.ui.recording_into = Some(into);
let size = "the action set would be larger than 8 MiB";
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"command": "object.group"})).unwrap_err(), size);
assert_eq!(app.run(INSERT_STOP, json!({})).unwrap_err(), size);
app.run(STOP_RECORDING, json!({})).unwrap();
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 1, "nothing was recorded into the action");
assert!(files::write_set(&app.ui.action_sets[0]).len() <= files::MAX_SET_FILE);
}
/// A step put by its set and action into the action that the recording in progress records into
/// counts the steps recorded so far, which Stop Playing/Recording adds to that action.
#[test]
fn steps_inserted_into_the_action_being_recorded_into_count_the_recorded_steps() {
let (mut app, _) = app_with_three_moves();
app.ui.action_sets[0].actions.push(Action::new("Other", vec![]));
let into = app.ui.action_sets[0].actions[0].id;
let stored = app.ui.action_sets[0].actions[0].steps.len();
app.run(START_RECORDING, json!({})).unwrap();
app.ui.recording_into = Some(into);
for _ in stored..files::MAX_ACTION_STEPS {
app.session.journal.push(("object.group".to_string(), json!({})));
}
let steps = "the action already has 10,000 steps";
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), steps);
assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), steps);
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), stored, "nothing was inserted");
assert!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 1, "command": "object.group"})).is_ok(), "another action takes the step");
app.run(STOP_RECORDING, json!({})).unwrap();
let saved = files::write_set(&app.ui.action_sets[0]);
assert_eq!(files::read_set(&saved, "Test").unwrap().actions[0].steps.len(), files::MAX_ACTION_STEPS, "and loads again");
let (mut app, _) = app_with_three_moves();
let pad = |n: usize| ("object.move".to_string(), json!({"dx": 1, "dy": 0, "pad": "x".repeat(n)}));
let into = app.ui.action_sets[0].actions[0].id;
let room = files::MAX_SET_FILE - files::write_set(&app.ui.action_sets[0]).len();
app.run(START_RECORDING, json!({})).unwrap();
app.ui.recording_into = Some(into);
app.session.journal.push(pad(room - 8));
let size = "the action set would be larger than 8 MiB";
assert_eq!(app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "command": "object.group"})).unwrap_err(), size);
assert_eq!(app.run(INSERT_STOP, json!({"set": 0, "action": 0})).unwrap_err(), size);
// Another action of the set counts them too, for a recording into an action and for a
// recording that adds a new action.
for into_first in [true, false] {
let (mut app, _) = app_with_three_moves();
app.ui.action_sets[0].actions.push(Action::new("Other", vec![]));
let first = app.ui.action_sets[0].actions[0].id;
app.run(START_RECORDING, json!({})).unwrap();
app.ui.recording_into = into_first.then_some(first);
app.session.journal.push(pad(room - 8));
let r = app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 1, "command": "object.group"}));
assert_eq!(r.unwrap_err(), size, "recording into the first action: {into_first}");
assert!(app.ui.action_sets[0].actions[1].steps.is_empty(), "recording into the first action: {into_first}");
}
}
/// A step inserted from the menu saves and loads as the command and params it holds, with its
/// place and exclusion.
#[test]
fn an_inserted_menu_item_saves_and_loads() {
let (mut app, _) = app_with_three_moves();
app.run(INSERT_MENU_ITEM, json!({"set": 0, "action": 0, "step": 1, "command": "type.changeCase", "params": {"case": "title"}})).unwrap();
app.ui.action_sets[0].actions[0].excluded_steps = BTreeSet::from([2]);
let loaded = files::read_set(&files::write_set(&app.ui.action_sets[0]), "Test").unwrap();
let action = &loaded.actions[0];
assert_eq!(action.steps, app.ui.action_sets[0].actions[0].steps);
assert_eq!(action.steps[2], ("type.changeCase".to_string(), json!({"case": "title"})));
assert_eq!(action.excluded_steps, BTreeSet::from([2]));
}
#[test]
fn delete_step_removes_one_step_and_moves_the_exclusions_up() {
let (mut app, _) = app_with_a_stop(false);
@@ -4004,6 +4375,22 @@ mod tests {
assert_eq!(open_kind(&app), None, "deleting its step closes it");
}
#[test]
fn step_and_set_edits_keep_an_open_insert_menu_item_on_its_place() {
let (mut app, _) = app_with_three_moves();
app.ui.action_sets[0].actions.insert(0, Action::new("First", vec![]));
crate::dialogs::action_insert_menu_item::open(&mut app, Some((0, 1)), Some(2));
app.run(DUPLICATE, json!({"set": 0, "action": 0})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["action"], json!(2), "a copied action above moves it down");
app.run(DELETE_STEP, json!({"set": 0, "action": 2, "step": 0})).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().fields["step"], json!(1), "a deleted step above moves it up");
app.run(DELETE_STEP, json!({"set": 0, "action": 2, "step": 1})).unwrap();
assert_eq!(open_kind(&app), None, "deleting its step closes it");
crate::dialogs::action_insert_menu_item::open(&mut app, Some((0, 0)), None);
app.run(DELETE_SETS, json!({"sets": [0]})).unwrap();
assert_eq!(open_kind(&app), None, "deleting its set closes it");
}
/// Selects the first step of "Three Moves" by clicking its row, with the action open.
fn click_first_step(ctx: &egui::Context, app: &mut VectorcraftApp) {
use super::super::tests_appearance::{click, frame_events};
@@ -4474,6 +4861,58 @@ mod tests {
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3);
}
/// The panel menu's Insert Menu Item… after the selected step: Find narrows the list, a click on
/// a row picks the item and runs nothing, Cancel inserts nothing, and OK inserts the pick after
/// the step. With nothing selected and nothing recording, the item is off.
#[test]
fn insert_menu_item_in_the_menu_picks_from_the_list_and_inserts_on_ok() {
use super::super::tests_appearance::{click, frame_events, text_rect};
let (mut app, _) = app_with_three_moves();
app.ui.action_set = Some(0);
let ctx = egui::Context::default();
app.ui_ctx = Some(ctx.clone());
let open = |ctx: &egui::Context, app: &mut VectorcraftApp| {
let texts = frame_events(ctx, app, vec![], menu);
let item = texts.iter().find(|(s, _)| s.trim() == "Insert Menu Item…").map(|(_, r)| *r).expect("Insert Menu Item… in the menu");
click(ctx, app, item.center(), menu);
};
open(&ctx, &mut app);
assert_eq!(open_kind(&app), None, "nothing selected and nothing recording: the item is off");
click_first_step(&ctx, &mut app);
open(&ctx, &mut app);
let d = app.ui.dialog.as_ref().expect("Insert Menu Item… opens its dialog");
assert_eq!(d.kind, crate::dialogs::action_insert_menu_item::KIND);
assert_eq!((&d.fields["set"], &d.fields["action"], &d.fields["step"]), (&json!(0), &json!(0), &json!(0)));
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("find".into(), json!("object.ungroup"));
}
let draw = |app: &mut VectorcraftApp, ui: &mut egui::Ui| crate::dialogs::show(app, ui.ctx());
let mut texts = vec![];
for _ in 0..3 {
texts = frame_events(&ctx, &mut app, vec![], draw);
}
assert!(!texts.iter().any(|(s, _)| s == "Object › Group"), "Find narrows the list: {texts:?}");
let doc = app.session.active().unwrap().doc.clone();
click(&ctx, &mut app, text_rect(&texts, "Object › Ungroup").center(), draw);
let d = app.ui.dialog.as_ref().expect("a pick keeps the dialog open");
assert_eq!((d.str("command"), &d.fields["params"]), ("object.ungroup".to_string(), &json!({})));
assert!(std::sync::Arc::ptr_eq(&app.session.active().unwrap().doc, &doc), "a pick runs nothing");
crate::dialogs::cancel(&mut app);
assert_eq!(app.ui.action_sets[0].actions[0].steps.len(), 3, "Cancel inserts nothing");
open(&ctx, &mut app);
if let Some(d) = app.ui.dialog.as_mut() {
d.fields.insert("command".into(), json!("object.ungroup"));
}
crate::dialogs::confirm(&mut app).unwrap();
assert_eq!(open_kind(&app), None, "OK closes the dialog");
let ids: Vec<String> = app.ui.action_sets[0].actions[0].steps.iter().map(|(c, _)| c.clone()).collect();
assert_eq!(ids, ["object.move", "object.ungroup", "object.move", "object.move"], "the pick goes after the selected step");
assert_eq!(pstate::<Option<(u64, usize)>>(&ctx, SELECTED_STEP), Some((app.ui.action_sets[0].actions[0].id, 1)), "and is selected");
}
#[test]
fn set_edits_keep_an_open_record_stop_on_its_action() {
let mut app = app_with_sets();
+1 -1
View File
@@ -34,7 +34,7 @@ pub(super) const MAX_SET_FILE: usize = 8 << 20;
const MAX_SET_ACTIONS: usize = 1000;
/// The most steps [`read_set`] reads in one action.
const MAX_ACTION_STEPS: usize = 10_000;
pub(super) const MAX_ACTION_STEPS: usize = 10_000;
/// The most left-out steps that Load Actions… lists for an action set another app saved.
const MAX_SKIPPED: usize = 1000;
+5
View File
@@ -537,6 +537,10 @@ pub struct UiState {
/// The Replace Font request behind the status bar's warning ([`ReplaceFontWarning`]). Not saved.
#[serde(skip)]
pub replace_font_warning: Option<ReplaceFontWarning>,
/// The notes of the last open that gave any, with the status text that names the first of
/// them: hovering that text lists them when there are several. Not saved.
#[serde(skip)]
pub status_notes: Option<(String, Vec<String>)>,
pub palette_open: bool,
pub palette_query: String,
/// Screen mode: 0 normal, 1 full screen with menu, 2 full screen, 3 presentation. Not saved:
@@ -1065,6 +1069,7 @@ impl Default for UiState {
group_tool: vectorcraft_tools::TOOL_GROUPS.iter().map(|g| g[0].id.to_string()).collect(),
status: String::new(),
replace_font_warning: None,
status_notes: None,
palette_open: false,
palette_query: String::new(),
screen_mode: 0,
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.vectorcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.vectorcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ the two can still win that race.
| `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` |
| `ui.resize` / `ui.focus` | | |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, importer?, params?}` / `{path?}` / `{path?, format?, artboard?, range?, scale?, …}` | `app.open` reads every format `document.open` reads (see `document.formats`) and returns its result (`{index, title, format, warnings, …}`; `warnings` say what didn't come in as it was, such as an EPS shown as its preview image and why its PostScript couldn't be read), or null when a dialog asks first or a library loads. The status bar (`ui.inspect` → `ui.status`) counts the `warnings` and names the first that says reading the file left something out (the notes that make Save ask before writing over it), else the first; with several, hovering it lists them in that order, as many as fit in the window (at most 30), then how many more. With `importer` (an importer plug-in's format id, `plugin.<id>`) it runs `file.openWith`: the file opens with that importer, also when a built-in format reads its extension, with the importer's parameters `params` (their defaults when omitted; no dialog asks for them). Without `importer`, `params` go to the importer that opens the file, and a built-in format does not read them. `app.export` encodes through the engine's `document.export` (same options; the document keeps its path) and writes `path` through the host; without `path` it returns `{dataBase64, format, bytes}`, as headless mode does. It refuses a key that no format takes, and an unknown key inside a PDF or EPS option object, before it writes anything (`docs/mcp.md`, Parameter keys). `app.save` refuses a key that no save format takes, beside the path or in `options`, the same way. `app.quit`, `file.close` and `file.closeAll` first open a `saveChanges` dialog for each modified document (they return `{"pending": "saveChanges"}`): `ui.dialog.confirm` saves, `ui.dialog.set {field: "discard", value: true}` then confirm discards, `ui.dialog.cancel` cancels the whole close or quit |
| `app.export` with `useArtboards` | `{path, useArtboards: true, range? \| artboards?, …}` | Export As: PNG, JPEG and WebP write one file per chosen artboard (default all; SVG does so for a `range`), `<path stem>-<artboard>.<ext>`, and return `{path, files: [path…]}`; a PDF keeps them as pages of `path`. `useArtboards: false` covers the bounds of the visible art. The menu's Export As… is `file.exportAs`: the `exportAs` dialog (`format`, `useArtboards`, `all`, `range`), then a save dialog and the format's options: `pngOptions` / `jpgOptions` / `webpOptions`, `svgOptions`, or `savePdf` for a PDF with Use Artboards |
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
+1 -1
View File
@@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|---|---|
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none |
| Turntable (30.0 beta) | none |
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
+45 -1
View File
@@ -2739,8 +2739,26 @@ already walks Image Trace. Short agent entry points:
false) offers Continue in the message box. An action with an empty message still waits at the stop, which is
VectorCraft's own rule. A stop in an action becomes the panel's selected step, and its action and set become the
selected ones. An index past the last set, action or step, a param of another type, params that are not an
object, or no action and no recording is an error, and then nothing changes. The panel menu's Insert Stop… opens the `actionStopRecord` dialog
object, no action and no recording, or a stop that would give the action more than 10,000 steps or its set's file
more than 8 MiB is an error, and then nothing changes. The limits count the steps that the recording in progress
adds to the set. The panel menu's Insert Stop… opens the `actionStopRecord` dialog
(`message`, `allowContinue`, `set`, `action`, `step`), whose OK runs this.
- Actions: `ui.action.insertMenuItem {set?, action?, step?, command, params?}` inserts a menu item's command as a step
without running it. `command` and `params` (default: `{}`) must be those of an item in the menus that can be a step:
an item that runs a document command without opening a dialog first, outside the File, Window and Help menus, and
not Undo, Redo, a Paste command, Object › Align (its items add the Align To choice when clicked), Object › Image
Trace › Make or Make and Expand (the menu traces off the UI thread, with progress and Cancel), Object › Pattern ›
Make or Edit Pattern (the menu also opens the Pattern Options panel), a preference
toggle, an Actions command or a recent-font, saved-selection or saved-view slot. Disabled items are listed, and playback checks the step when it runs. With `set` and `action`, the
step goes into that action, after step `step`, else after its last step, becomes the panel's selected step, and the
command returns `{set, action, step}`. Without them it goes into the recording in progress and returns
`{recording: true}`. A command no such item runs, params that are not an object, an index past the last set, action
or step, no action and no recording, or a step that would give the action more than 10,000 steps or its set's file
more than 8 MiB is an error, and then nothing changes. The limits count the steps that the recording in progress
adds to the set. The panel menu's Insert Menu Item… opens the
`actionInsertMenuItem` dialog (`find`, `command`, `params`, `set`, `action`, `step`): typing in Find and pressing
Find picks the one item whose menu path, shown name or command id holds every word typed, or lists the matches, and
OK runs this.
- Actions: `ui.action.continue {}` plays on from the stop the playing action waits at, on the document active now, and
closes the stop's message box (`actionStopMessage`). The next step runs at once, or at Accelerated the steps up to
the next stop as one undo step. It returns `{results, pending, atStop?}`. With no action waiting at a stop it is an
@@ -3033,6 +3051,32 @@ The document stores their numeric settings; manual edits remain possible through
{"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}}
```
## 3D shadows
Revolve, Extrude & Bevel, Inflate and Rotate take `shadow` (boolean, default false), `shadowPosition` (`"behind"`
or `"below"`, default `"behind"`), `shadowDistance` (0..100%, default 0) and `shadowBounds` (10..400%, default 100),
set with `effect.apply` or `effect.setParams` in one undo step. With `shadow` on, the first light casts a
hard-edged shadow of the solid, drawn behind the solid as one path filled black at 50% opacity times the opacity
of the art that casts it, in that art's blending mode. `"behind"` puts it
on a plane square to the view behind the object; `"below"` puts it on the floor under the object, square to the
object's own vertical axis and turned with it, so with no X rotation the floor is seen edge-on and draws nothing.
`shadowDistance` moves the plane away from the object by that share of the object's largest size. `shadowBounds`
limits the plane to a square about the foot of the object's center, four times the object's largest size across
at 100%. No shadow is drawn when `shade` is false, when the first light's intensity is 0, or when it does not
reach the plane (a light behind the object for a shadow behind it); a fill, stroke or group member at 0% opacity
casts none. Values out of range, another `shadowPosition` and a `shadow` that is not a boolean are refused before
the document changes. A shadow is cast from at most 327,680 faces, the solids of one object together (VectorCraft's
own limit); past that, a command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the
art", and a loaded file draws the source path, as it does for a solid past its limits. The color, the opacity and how Distance and Bounds scale are VectorCraft's own; the public
documentation names the controls and their ranges
([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)).
The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps
it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows).
```json
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}}
```
## 3D object type
`effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}`
+6
View File
@@ -42,6 +42,12 @@ pick that branch for a full run. Run on any other branch, it is a dry run: the L
FreeBSD and web jobs build and upload their artifacts, the environment refuses the signing jobs,
and no draft release is created.
Flatpak permission repairs need one update through the existing host-side installation method.
After that update, Help > Check for Updates can inspect and update standalone bundles.
Repository-managed installations continue to use their package manager; launcher-pinned
versions keep their selected version. Both Flatpak manifests must grant network access,
the host Flatpak interface, and read-only access to the shared version choices.
## What gets built
| Platform | Artifacts | Built on |
File diff suppressed because one or more lines are too long
+40 -5
View File
@@ -51,8 +51,9 @@ pages describe it.
unavailable while a setting of a single light differs between the selected effects.
- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step
when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing.
- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on
the artboard are part of the options dialog only.
- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and
ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the
options dialog only.
## Extrude & Bevel
@@ -242,6 +243,39 @@ the default light had (45° left of the eye and 45° up). Effects saved before (
`lightAzimuth` and `lightElevation`; those are read as the Rotation, Height and side of the same direction, so the
shading is exactly as before.
## Shadows
The public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)
page lists shadow settings that apply while shadows are on: a Position behind or below the object, a Distance
from Object from 0 to 100 % and Shadow Bounds from 10 to 400 %. The Lighting tab of Revolve, Extrude & Bevel,
Inflate and Rotate has a **Shadows** switch with those three settings below it.
| Option | |
|---|---|
| Shadows | Off by default. On, the first light casts a hard-edged shadow of the solid, drawn behind it. |
| Position | **Behind Object** (the default): on a plane square to the view, behind the object. **Below Object**: on the floor under the object, square to its own vertical axis and turned with it. |
| Distance from Object | 0–100 %, default 0 %: how far the plane is from the object, as a share of the object's largest size. |
| Shadow Bounds | 10–400 %, default 100 %: how far the plane reaches. At 100 % it is a square four times the object's largest size across, about the foot of the object's center; the shadow is cut at its edges. |
Our reading:
- The shadow is one path, filled black at 50 % opacity with the nonzero rule: every face of every solid of the
object (its fill and its strokes, or the members of a group) projected along the first light onto the plane.
Art that is partly transparent casts its part at 50 % times its opacity, and art with a blending mode casts
its part in that mode, each as a path of its own. A member whose
opacity mask or blending mode applies to its solids casts its part in that mask and mode too. The color, the
opacity and how Distance and Bounds scale are our own.
- No shadow is drawn while Shading is off (the Shadows settings are then unavailable), when the first light's
Intensity is 0 %, or when it does not reach the plane: a light behind the object casts none behind it, and a
light from under the floor casts none below it. A fill, stroke or group member at 0 % opacity casts none. With no X rotation the floor is seen edge-on, so a
shadow below draws nothing until the object is tilted (the default Off-Axis Front position shows it).
- Softness is not applied to the shadow, and only the first light casts one.
- A shadow is cast from at most 327,680 faces, the solids of one object together (our own limit). Past it, a
command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded
file draws the source path.
- Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a
path below the faces. Extrude & Bevel (Classic) has no shadow.
## Parameters (`effect.apply`, `effect.setParams`)
`threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`,
@@ -249,7 +283,7 @@ shading is exactly as before.
`threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightColor` (`#rrggbb`, default `#ffffff`), `lightIntensity` (%), `lightRotation`,
`lightHeight` (degrees), `lightSoftness` (%), `ambient` (%, to 200), `lightBehind`. Lengths are points and scale with the object (Scale Strokes & Effects).
Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely.
Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely. All four take `shadow` (boolean, false), `shadowPosition` (`behind` or `below`), `shadowDistance` (0..100%, 0) and `shadowBounds` (10..400%, 100) ([Shadows](#shadows)); values out of range are rejected before editing.
## What differs from the public documentation
@@ -258,9 +292,10 @@ and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effe
pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or
out, perspective). No Adobe software, assets, presets or bevel shapes were used.
- **Materials** (base, Adobe Substance and graphics), **shadows**
- **Materials** (base, Adobe Substance and graphics), soft shadows, shadows of lights after the first,
and **ray-traced rendering** are not built: shading uses directional lights, in the paint of the fill or
stroke the solid is built for ([Fills and strokes](#fills-and-strokes)).
stroke the solid is built for ([Fills and strokes](#fills-and-strokes)), and the shadow is hard-edged
([Shadows](#shadows)).
- The bevel shapes and their names are our own; custom bevel paths are not supported.
- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, and the rest are drawn
back to front, with a flat cap or flat back turned toward the viewer drawn after all of them. Concave solids
@@ -17,6 +17,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: vectorcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
@@ -17,6 +17,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: vectorcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Keep identical to ai.storyteller.vectorcraft.bundle.yml (the release bundle); comments there.
- --share=ipc