mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 22:22:25 +00:00
Merge main into feat/js-e4x-xml
# Conflicts: # crates/js/src/interp.rs
This commit is contained in:
Generated
+16
-4
@@ -975,12 +975,12 @@ source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -1049,7 +1049,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=bb8320f5fa5d20fa78a026d0de06fe4e2f08a099#bb8320f5fa5d20fa78a026d0de06fe4e2f08a099"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -1096,7 +1096,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-vectorcraft-format"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb#9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde",
|
||||
@@ -6367,6 +6367,13 @@ dependencies = [
|
||||
"ruzstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-apischema"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-brush"
|
||||
version = "0.21.0"
|
||||
@@ -6492,8 +6499,10 @@ dependencies = [
|
||||
"ruzstd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"skrifa 0.47.0",
|
||||
"thiserror 2.0.21",
|
||||
"vectorcraft-affinity",
|
||||
"vectorcraft-apischema",
|
||||
"vectorcraft-brush",
|
||||
"vectorcraft-cad",
|
||||
"vectorcraft-color",
|
||||
@@ -6643,6 +6652,8 @@ dependencies = [
|
||||
"kurbo 0.13.1",
|
||||
"log",
|
||||
"md-5",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"roxmltree 0.21.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6676,6 +6687,7 @@ dependencies = [
|
||||
"vectorcraft-geom",
|
||||
"wasmi",
|
||||
"wat",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+3
-2
@@ -14,14 +14,14 @@ repository = "https://github.com/storytold/vectorcraft"
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", default-features = false, features = ["interpret", "write"] }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["serde"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "bb8320f5fa5d20fa78a026d0de06fe4e2f08a099", features = ["serde"] }
|
||||
craft-keymap = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-proof = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
# Decides who draws the window frame (desktop vs. app) so the suite shares one native titlebar per
|
||||
# desktop. The `egui` feature adds `craft_window::decorate`. (craft-libs#217.)
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb", features = ["egui"] }
|
||||
# The `.vectorcraft` envelope: its members, version, embedded images and the data after the JSON.
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "9be9a63782298ab59cd9b7a42ee9cba4f42a5ccb" }
|
||||
craft-vectorcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||
# internal
|
||||
@@ -55,6 +55,7 @@ vectorcraft-plugins = { path = "crates/plugins" }
|
||||
vectorcraft-affinity = { path = "crates/affinity" }
|
||||
vectorcraft-scriptcheck = { path = "crates/scriptcheck" }
|
||||
vectorcraft-js = { path = "crates/js" }
|
||||
vectorcraft-apischema = { path = "crates/apischema" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
|
||||
+14
-18
@@ -1,21 +1,8 @@
|
||||
# VectorCraft Roadmap
|
||||
|
||||
- Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged.
|
||||
- Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged.
|
||||
|
||||
- Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged.
|
||||
- UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged.
|
||||
|
||||
- PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document.
|
||||
- PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned.
|
||||
|
||||
- UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available.
|
||||
|
||||
- UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned.
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-09 · **Change:** minor (Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (docs tidy-up, no content change: stray notes at the top moved into the revision table; scores and estimates unchanged; previously minor: Raster effects 85% → 88%, gallery thumbnails and in-dialog preview in; headline numbers unchanged; Symbols area ~87% → ~88%: dynamic instance parts take opacity, blend, gradient and pattern on the canvas; G5 docs sync for Overlap and Pattern auto corners; previously minor: Tools stays beside the canvas with left-side panels; Hindi catalog draft complete under the translation tests; contributor loanword terminology reviewed; wider proofreading welcome; Compound Shapes add-on, beyond Illustrator; scores and estimates unchanged; Contour add-on, beyond Illustrator; scores and estimates unchanged; new gradients follow document color mode; scores and estimates unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -35,7 +22,6 @@ blocking it ([Alpha gate](docs/roadmap.md#alpha-gate)).
|
||||
|
||||
| | Value | Kind |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
|
||||
| **Feature breadth** (Illustrator's menu items, tools, effects, formats exist) | **~88%** | measured in part: menu items 348/375 wired (92.8%), tools 89/92, Illustrator effects 46/50, Photoshop effects 57/57, 3D 4/4 (initial types, no materials) |
|
||||
| **Feature depth** (weighted by use, scored by behaviour) | **~75%** | estimated |
|
||||
| **To beta** | **~170–270 h** one agent · ~50–80 h with 4–6 agents | estimated |
|
||||
@@ -83,7 +69,7 @@ The dimension hours overlap (feature rows hold some UI, format and AI work), so
|
||||
| Strokes, brushes, width profiles | 88% | 8–15 |
|
||||
| Appearance, transparency, graphic styles, masks | 97% | 1–2 |
|
||||
| Live vector effects | 85% | 4–8 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 85% | 1–2 |
|
||||
| Raster effects (Effect Gallery) — [effects-parity.md](docs/effects-parity.md) | 88% | — |
|
||||
| 3D and Materials | ~25% | 30–55 |
|
||||
| Type core — [type-parity.md](docs/type-parity.md) | 80% | 14–19 |
|
||||
| Type advanced (CJK, spelling, Touch Type) | 50% | 20–28 |
|
||||
@@ -131,8 +117,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
|
||||
2. **`.ai` files from real users** (G9): layer structure, guides, speed: 10–20 h.
|
||||
3. **Launch and stability on real machines** (G10): 20–35 h.
|
||||
4. **Core-tool correctness** (G11: Shape Builder regions, Asset Export borders): 8–15 h.
|
||||
5. **Raster effects** (G2) alongside: the Effect Gallery's filter thumbnails and in-dialog preview area (the gallery and every Photoshop-style filter are in), 1–2 h.
|
||||
6. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3).
|
||||
5. **Then:** type core and advanced type (G4), the Illustrator 2026 additions (G13), 3D (G3). The Effect Gallery's filter thumbnails and in-dialog preview are in (G2).
|
||||
|
||||
## Progress log
|
||||
|
||||
@@ -226,6 +211,13 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-10 | minor | Tidy-up, no content change: the unheaded change bullets at the top of the file and a revision row inside the headline-numbers table move to this table under their commit dates. Scores and estimates unchanged. |
|
||||
| 2026-11-10 | minor | Raster effects: Effect Gallery thumbnails and an in-dialog preview (an original drawing through each filter). Features-table Raster effects 85% → 88%, hours 1–2 → none. Headline numbers unchanged, so the progress log is unchanged. |
|
||||
| 2026-11-05 | minor | Compound Shapes add-on (beyond Illustrator): a menu for Make, Release and Expand Compound Shape and a setting, off by default, that makes the Pathfinder panel's Shape Mode buttons create compound shapes on a click. Scores and estimates unchanged. |
|
||||
| 2026-11-04 | minor | Contour add-on (beyond Illustrator): drag a selected shape's edge outward or inward for a live, editable offset copy, with a floating bar for distance and Sharp, Round or Square corners; Bake turns it into paths. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Paint: new gradients use the document's color model for default stops, including Gradient tool creation. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | UI: Window → Help Bar toggles the tool-help strip separately from the status bar and saves visibility with workspaces. Scores and estimates unchanged. |
|
||||
| 2026-11-03 | minor | Docked Tools stays between left-side panels and the canvas; redocking accepts canvas and window edges. Requested layout choice; scores and estimates unchanged. |
|
||||
| 2026-11-02 | minor | Selection QoL: saved marquee modes, screen drag direction with a 3 px dead zone, and translated mode/E feedback (#5313); VectorCraft addition, scores and estimates unchanged. |
|
||||
| 2026-11-06 | minor | Hindi (`hi`): 7,877-row machine-assisted catalog, zero/one singular plurals, desktop font fallback and optional Devanagari craft-fonts fallback; all 55 translation tests pass; native review and a web font remain; scores and estimates unchanged |
|
||||
| 2026-11-05 | minor | Progress log row for the Type Tools add-on's Release Type on a Path (#5532); headline numbers unchanged. |
|
||||
@@ -262,10 +254,14 @@ The full earlier record of what landed is kept in [docs/roadmap.md](docs/roadmap
|
||||
| 2026-10-13 | minor | Bounded synthetic control input (#1709): counts and queued events validated before mutation; control-channel bursts yield between frames. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Progress log row for the merges of 2026-10-14 (#1641–#1808 as listed there) and Image Trace's #1647 and #1657; the 2026-10-13 row now says that a guide's position in the Transform panel (#1659) is not established by the documentation found and is kept as a convenience pending the owner's decision; headline numbers unchanged. |
|
||||
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
|
||||
| 2026-10-13 | minor | PDF.R1: editable PDF import uses the shared reader, scans page content in windows, and rejects known incomplete input before returning a document. |
|
||||
| 2026-10-13 | minor | PDF.W1: PDF export and print composition use the shared optional writer for configuration, metadata, font preparation and guarded finalization; drawing and output policy remain app-owned. |
|
||||
| 2026-10-10 | minor | Method changed, no new evidence: full ready for real work is the additive weighted sum, ~62%; hours per audience; beta ~13 points away |
|
||||
| 2026-10-10 | minor | Added the essentials-user readiness score (~75%) |
|
||||
| 2026-10-10 | minor | Ready for real work re-examined against the issue tracker and user feedback: 50% → 55% (full), 65% mainstream added; see target-app-parity.md |
|
||||
| 2026-10-10 | minor | Applied the core-workflow gate (docs/roadmap.md › Alpha gate): passes, stage stays alpha |
|
||||
| 2026-10-10 | major | Re-measured against Illustrator 2026 (30.x) from code counts, 71 open issues and public docs; stage set to alpha; moved the parity estimate, honest assessment and "Shipped so far" to `docs/target-app-parity.md`, the gap list to `docs/gaps.md`, milestones to `docs/roadmap.md`, the `.ai` scope to `docs/file-format-parity.md` |
|
||||
| 2026-10-10 | minor | UI.5: Shared docking adds tab reordering, docking into other groups, dock splits and resizable floating panel groups. Panel positions and return locations persist with workspaces; the native icon rail and Tools panel remain available. |
|
||||
| 2026-10-10 | minor | UI.4: Dock and floating-panel headers use shared accessible tabs with bounded overflow. Widget-state theme application uses `craft-ui`; all four palettes, font stacks and preferences remain app-owned. |
|
||||
| 2026-10-05 | major | Honest assessment by dimension and the prioritized gap list |
|
||||
| 2026-10-02 | major | First 22-area weighted parity estimate |
|
||||
|
||||
@@ -236,11 +236,13 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
/// A headless session that remembers what the user keeps between runs: the PDF presets saved, the
|
||||
/// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541) and the custom dictionary's words (#4936), in the folders
|
||||
/// and files next to the preferences that the desktop app uses too. `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply
|
||||
/// as they do there.
|
||||
/// ICC profiles loaded, the plug-ins installed (#2168, #2174, #2541), the custom dictionary's words (#4936) and the Color Settings (#6335), in the folders
|
||||
/// and files next to the preferences that the desktop app uses too, and lists the scripts of the Scripts folder there
|
||||
/// (`script.listInstalled`). `VECTORCRAFT_NO_PREFS` and `VECTORCRAFT_CONFIG_DIR` apply as they do there.
|
||||
fn with_stores(mut h: Headless) -> Headless {
|
||||
prefs_dir::attach_scripts_dir(&mut h.session);
|
||||
prefs_dir::attach_profile_store(&mut h.session);
|
||||
prefs_dir::attach_color_settings(&mut h.session);
|
||||
prefs_dir::attach_plugin_store(&mut h.session);
|
||||
prefs_dir::attach_pdf_preset_store(&mut h.session);
|
||||
prefs_dir::attach_custom_dictionary(&mut h.session);
|
||||
|
||||
@@ -597,7 +597,7 @@ fn exercise_mcp_core(
|
||||
assert!(r["result"]["completion"]["values"].as_array().is_some_and(|v| v.contains(&json!("svg"))), "{r}");
|
||||
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":5,"method":"resources/list","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 3));
|
||||
assert!(mcp_recv(lines, notes, 5)["result"]["resources"].as_array().is_some_and(|v| v.len() == 4));
|
||||
mcp_send(stdin, json!({"jsonrpc":"2.0","id":6,"method":"resources/templates/list","params":{}}));
|
||||
assert!(mcp_recv(lines, notes, 6)["result"]["resourceTemplates"].as_array().is_some_and(|v| v.len() >= 4));
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@
|
||||
mod cli;
|
||||
mod fileio;
|
||||
mod mcp_e2e;
|
||||
mod script;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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", ¶ms.to_string()]).output().unwrap()
|
||||
}
|
||||
|
||||
/// `run` runs a script headless: the reply holds the report, with what the script logged and the
|
||||
/// documents it changed.
|
||||
#[test]
|
||||
fn run_gives_the_report_of_a_script() {
|
||||
let source = "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); vc.log(vc.args.n * 2);";
|
||||
let out = run_script(&json!({"source": source, "name": "double.js", "args": {"n": 21}}));
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(lines.len(), 2, "{lines:?}");
|
||||
assert_eq!(lines[1]["command"], "script.run");
|
||||
let report = &lines[1]["result"];
|
||||
assert_eq!(report["ok"], true, "{report}");
|
||||
assert_eq!(report["name"], "double.js");
|
||||
assert_eq!(report["log"], json!([42]), "{report}");
|
||||
assert_eq!(report["changed"].as_array().map(Vec::len), Some(1), "{report}");
|
||||
}
|
||||
|
||||
/// A script that throws fails its step with the script's message, and the steps before it ran.
|
||||
#[test]
|
||||
fn run_fails_the_step_of_a_script_that_throws() {
|
||||
let out = run_script(&json!({"source": "vc.run('shape.rectangle', {x: 0, y: 0, width: 10, height: 10}); throw new Error('no margin given');"}));
|
||||
assert!(!out.status.success());
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("step 1 (script.run)") && stderr.contains("no margin given"), "{stderr}");
|
||||
let printed: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(printed.len(), 1, "only file.new printed: {printed:?}");
|
||||
assert_eq!(printed[0]["command"], "file.new");
|
||||
}
|
||||
@@ -26,7 +26,7 @@ log = { workspace = true }
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
rfd = { workspace = true }
|
||||
rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen = "0.2"
|
||||
serde_json = { workspace = true }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
|
||||
@@ -9,6 +9,9 @@ use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{FILE_HEAD, Flavour, PASTE_ORDER, file_flavour, is_address};
|
||||
use vectorcraft_engine::cmd::fileio;
|
||||
use vectorcraft_engine::cmd::recovery::{self, Hold, RecoveryStore};
|
||||
use vectorcraft_engine::cmd::scripts::RUN_EXTS;
|
||||
use vectorcraft_engine::script::MAX_SOURCE_BYTES;
|
||||
use vectorcraft_ui_egui::dialogs::script_run::ScriptInbox;
|
||||
use vectorcraft_ui_egui::graphics::GraphicsLoss;
|
||||
use vectorcraft_ui_egui::i18n;
|
||||
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
|
||||
@@ -47,6 +50,7 @@ struct Host {
|
||||
runner: eframe::WebRunner,
|
||||
inbox: Inbox,
|
||||
place_inbox: PlaceInbox,
|
||||
script_inbox: ScriptInbox,
|
||||
drag: DragPos,
|
||||
pasted: Rc<RefCell<Pasted>>,
|
||||
/// Pen and finger input read from the page's Pointer Events (`crate::pencil`).
|
||||
@@ -75,6 +79,7 @@ pub fn start() {
|
||||
runner: eframe::WebRunner::new(),
|
||||
inbox: Arc::default(),
|
||||
place_inbox: Arc::default(),
|
||||
script_inbox: Arc::default(),
|
||||
drag: DragPos::default(),
|
||||
pasted: Rc::default(),
|
||||
pointers: SharedPointers::default(),
|
||||
@@ -120,7 +125,8 @@ async fn run(host: Host, canvas: web_sys::HtmlCanvasElement, moving: Option<Movi
|
||||
}
|
||||
host.pasted.borrow_mut().ctx = Some(ctx.clone());
|
||||
let stylus = host.pointers.borrow().stylus();
|
||||
let services = services(host.inbox.clone(), host.place_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
|
||||
let services =
|
||||
services(host.inbox.clone(), host.place_inbox.clone(), host.script_inbox.clone(), ctx.clone(), host.locks.clone(), stylus);
|
||||
let app = match taken.borrow_mut().take() {
|
||||
Some(mut app) => {
|
||||
// The old services asked the old context for frames.
|
||||
@@ -425,12 +431,35 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context, locks: Option<WebLocks>, stylus: StylusFeed) -> Services {
|
||||
fn services(
|
||||
inbox: Inbox,
|
||||
place_inbox: PlaceInbox,
|
||||
script_inbox: ScriptInbox,
|
||||
ctx: egui::Context,
|
||||
locks: Option<WebLocks>,
|
||||
stylus: StylusFeed,
|
||||
) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let picked = place_inbox.clone();
|
||||
let place_ctx = ctx.clone();
|
||||
let screen_color = eye_dropper(ctx.clone());
|
||||
let (script_picked, script_ctx) = (script_inbox.clone(), ctx.clone());
|
||||
Services {
|
||||
// File › Scripts › Other Script…: the picked file goes to the warning before it runs.
|
||||
script_async: Some(Box::new(move || {
|
||||
let inbox = script_picked.clone();
|
||||
let ctx = script_ctx.clone();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let filter = vectorcraft_ui_egui::i18n::tr_ctx(vectorcraft_ui_egui::i18n::current(), "file-filter", "Scripts");
|
||||
let Some(file) = rfd::AsyncFileDialog::new().add_filter(filter, RUN_EXTS).pick_file().await else {
|
||||
return;
|
||||
};
|
||||
let bytes = if file.inner().size() > MAX_SOURCE_BYTES as f64 { None } else { Some(file.read().await) };
|
||||
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((file.file_name(), bytes));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
})),
|
||||
script_inbox: Some(script_inbox),
|
||||
// File → Place…: the picked files go to the Place dialog.
|
||||
place_async: Some(Box::new(move || {
|
||||
let inbox = picked.clone();
|
||||
|
||||
@@ -456,11 +456,15 @@ fn main() -> std::process::ExitCode {
|
||||
let mut session = Session::new();
|
||||
// The profiles loaded before come back before the preferences name any of them.
|
||||
prefs_dir::attach_profile_store(&mut session);
|
||||
// Then Edit › Color Settings, which may name one of those profiles.
|
||||
prefs_dir::attach_color_settings(&mut session);
|
||||
// Before any plug-in loads: this launch's marker in the Launch folder next to the
|
||||
// preferences (none for runs without preferences), and Safe Mode.
|
||||
let launch_dir = prefs_path().filter(|_| prefs_enabled()).and_then(|p| Some(p.parent()?.join("Launch")));
|
||||
let stopped = safe_mode::begin_launch(launch_dir.as_deref(), safe_mode_flag);
|
||||
prefs_dir::attach_plugin_store(&mut session);
|
||||
// File › Scripts lists the scripts of the Scripts folder next to the preferences.
|
||||
prefs_dir::attach_scripts_dir(&mut session);
|
||||
let mut app = VectorcraftApp::new(session, services(cc.winit_window().cloned(), cc.egui_ctx.clone()));
|
||||
app.set_startup_update_check(true);
|
||||
load_prefs(&mut app, saved);
|
||||
|
||||
@@ -41,6 +41,14 @@ pub fn attach_profile_store(session: &mut vectorcraft_engine::Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Make Edit › Color Settings kept in `Color Settings.json` the active ones (#6335), and keep them
|
||||
/// there when they change. After [`attach_profile_store`]: they may name a loaded profile.
|
||||
pub fn attach_color_settings(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(path) = store_dir("Color Settings.json") {
|
||||
session.attach_color_settings(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the plug-ins Install Plug-in kept in the `Plug-ins` folder (#2541), and keep the ones
|
||||
/// installed from now on there.
|
||||
pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
|
||||
@@ -49,6 +57,14 @@ pub fn attach_plugin_store(session: &mut vectorcraft_engine::Session) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Have File › Scripts and `script.listInstalled` list the scripts of the `Scripts` folder. The
|
||||
/// folder is only read: the user makes it.
|
||||
pub fn attach_scripts_dir(session: &mut vectorcraft_engine::Session) {
|
||||
if let Some(dir) = store_dir("Scripts") {
|
||||
session.attach_scripts_dir(dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Take the saved PDF presets from the `PDF Presets` folder (#2168), and keep the ones saved from
|
||||
/// now on there. After the preferences were restored: the folder is the one place the presets live.
|
||||
pub fn attach_pdf_preset_store(session: &mut vectorcraft_engine::Session) {
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "vectorcraft-apischema"
|
||||
description = "Machine-readable parameter schemas for VectorCraft commands, with a validator and a params text writer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,174 @@
|
||||
//! For tests: a check of JSON Schema 2020-12 documents, and a validator of instances against them,
|
||||
//! written from the keyword rules of the Core and Validation specifications
|
||||
//! (<https://json-schema.org/draft/2020-12>). It reads only the JSON and shares no code with the
|
||||
//! writer it checks. It covers the keywords the writer uses and reports any other keyword that
|
||||
//! isn't an `x-` annotation.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const TYPES: &[&str] = &["null", "boolean", "object", "array", "number", "string", "integer"];
|
||||
|
||||
fn unique(items: &[Value]) -> bool {
|
||||
items.iter().enumerate().all(|(i, x)| items.iter().take(i).all(|y| y != x))
|
||||
}
|
||||
|
||||
fn schema_list(items: &Value, at: &str, key: &str) -> Result<(), String> {
|
||||
let list = items.as_array().filter(|l| !l.is_empty()).ok_or_else(|| format!("{at}: `{key}` must be a nonempty array"))?;
|
||||
for (i, x) in list.iter().enumerate() {
|
||||
check_schema(x, &format!("{at}/{key}/{i}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether `schema` follows the 2020-12 rules for the keywords it uses, with where it doesn't.
|
||||
pub fn check_schema(schema: &Value, at: &str) -> Result<(), String> {
|
||||
let object = match schema {
|
||||
Value::Bool(_) => return Ok(()),
|
||||
Value::Object(o) => o,
|
||||
other => return Err(format!("{at}: a schema must be an object or a boolean, not {other}")),
|
||||
};
|
||||
for (key, value) in object {
|
||||
let number = || if value.is_number() { Ok(()) } else { Err(format!("{at}: `{key}` must be a number")) };
|
||||
match key.as_str() {
|
||||
"$schema" => {
|
||||
let uri = value.as_str().ok_or_else(|| format!("{at}: `$schema` must be a string"))?;
|
||||
if !uri.contains("://") || uri.ends_with('#') {
|
||||
return Err(format!("{at}: `$schema` must be a normalized URI"));
|
||||
}
|
||||
}
|
||||
"type" => {
|
||||
let known = |v: &Value| v.as_str().is_some_and(|s| TYPES.contains(&s));
|
||||
let ok = known(value) || value.as_array().is_some_and(|l| !l.is_empty() && unique(l) && l.iter().all(known));
|
||||
if !ok {
|
||||
return Err(format!("{at}: `type` must name JSON types, not {value}"));
|
||||
}
|
||||
}
|
||||
"enum" => {
|
||||
if !value.as_array().is_some_and(|l| !l.is_empty() && unique(l)) {
|
||||
return Err(format!("{at}: `enum` must be a nonempty array of unique values"));
|
||||
}
|
||||
}
|
||||
"properties" => {
|
||||
let props = value.as_object().ok_or_else(|| format!("{at}: `properties` must be an object"))?;
|
||||
for (name, x) in props {
|
||||
check_schema(x, &format!("{at}/properties/{name}"))?;
|
||||
}
|
||||
}
|
||||
"required" => {
|
||||
if !value.as_array().is_some_and(|l| unique(l) && l.iter().all(Value::is_string)) {
|
||||
return Err(format!("{at}: `required` must be an array of unique strings"));
|
||||
}
|
||||
}
|
||||
"additionalProperties" | "items" => check_schema(value, &format!("{at}/{key}"))?,
|
||||
"prefixItems" | "anyOf" => schema_list(value, at, key)?,
|
||||
"minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" => number()?,
|
||||
"minItems" => {
|
||||
if value.as_u64().is_none() {
|
||||
return Err(format!("{at}: `minItems` must be a non-negative integer"));
|
||||
}
|
||||
}
|
||||
"description" => {
|
||||
if !value.is_string() {
|
||||
return Err(format!("{at}: `description` must be a string"));
|
||||
}
|
||||
}
|
||||
"deprecated" => {
|
||||
if !value.is_boolean() {
|
||||
return Err(format!("{at}: `deprecated` must be a boolean"));
|
||||
}
|
||||
}
|
||||
"default" => {
|
||||
if !instance_valid(schema, value) {
|
||||
return Err(format!("{at}: `default` {value} does not validate against its schema"));
|
||||
}
|
||||
}
|
||||
k if k.starts_with("x-") => {}
|
||||
other => return Err(format!("{at}: unexpected keyword `{other}`")),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_type(v: &Value, name: &str) -> bool {
|
||||
match name {
|
||||
"null" => v.is_null(),
|
||||
"boolean" => v.is_boolean(),
|
||||
"object" => v.is_object(),
|
||||
"array" => v.is_array(),
|
||||
"string" => v.is_string(),
|
||||
"number" => v.is_number(),
|
||||
"integer" => v.is_i64() || v.is_u64() || v.as_f64().is_some_and(|x| x.fract() == 0.0),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the instance `v` is valid against `schema`.
|
||||
pub fn instance_valid(schema: &Value, v: &Value) -> bool {
|
||||
let s = match schema {
|
||||
Value::Bool(b) => return *b,
|
||||
Value::Object(o) => o,
|
||||
_ => return false,
|
||||
};
|
||||
let type_ok = match s.get("type") {
|
||||
None => true,
|
||||
Some(Value::String(t)) => has_type(v, t),
|
||||
Some(Value::Array(l)) => l.iter().filter_map(Value::as_str).any(|t| has_type(v, t)),
|
||||
Some(_) => false,
|
||||
};
|
||||
if !type_ok {
|
||||
return false;
|
||||
}
|
||||
if let Some(list) = s.get("enum").and_then(Value::as_array)
|
||||
&& !list.contains(v)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(any) = s.get("anyOf").and_then(Value::as_array)
|
||||
&& !any.iter().any(|x| instance_valid(x, v))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let Some(x) = v.as_f64() {
|
||||
let bound = |key: &str| s.get(key).and_then(Value::as_f64);
|
||||
if bound("minimum").is_some_and(|m| x < m)
|
||||
|| bound("maximum").is_some_and(|m| x > m)
|
||||
|| bound("exclusiveMinimum").is_some_and(|m| x <= m)
|
||||
|| bound("exclusiveMaximum").is_some_and(|m| x >= m)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(items) = v.as_array() {
|
||||
if s.get("minItems").and_then(Value::as_u64).is_some_and(|n| (items.len() as u64) < n) {
|
||||
return false;
|
||||
}
|
||||
let prefix = s.get("prefixItems").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
if items.iter().zip(prefix).any(|(x, p)| !instance_valid(p, x)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(rest) = s.get("items")
|
||||
&& items.iter().skip(prefix.len()).any(|x| !instance_valid(rest, x))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(object) = v.as_object() {
|
||||
let required = s.get("required").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
|
||||
if required.iter().filter_map(Value::as_str).any(|k| !object.contains_key(k)) {
|
||||
return false;
|
||||
}
|
||||
let props = s.get("properties").and_then(Value::as_object);
|
||||
for (key, x) in object {
|
||||
match props.and_then(|p| p.get(key)) {
|
||||
Some(p) if !instance_valid(p, x) => return false,
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if s.get("additionalProperties").is_some_and(|a| !instance_valid(a, x)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -0,0 +1,940 @@
|
||||
//! Machine-readable parameter schemas for VectorCraft commands. A [`ParamSchema`] lists what each
|
||||
//! parameter of a command is ([`Param`], [`Type`]): its type, whether it is required, what an
|
||||
//! omitted value means, whether null counts as omitted, and the API levels it was added and
|
||||
//! deprecated at. From one schema come:
|
||||
//!
|
||||
//! - [`ParamSchema::validate`], which checks a params object and names the parameter of the first
|
||||
//! problem it finds ([`ParamError`]), and fills in the defaults of omitted values;
|
||||
//! - [`ParamSchema::definition_problem`], which checks the schema itself;
|
||||
//! - [`ParamSchema::params_text`], the params text a command lists;
|
||||
//! - [`ParamSchema::to_json`], the schema as a JSON object in the shape of a JSON Schema 2020-12
|
||||
//! object schema (<https://json-schema.org/draft/2020-12>), with VectorCraft's own fields as
|
||||
//! `x-` keywords;
|
||||
//! - [`hash`], a hash of a set of command schemas, which changes whenever any of them changes.
|
||||
//!
|
||||
//! Checking a value walks the schema, so its depth is the schema's, at most [`MAX_DEPTH`]. An error
|
||||
//! shows a bounded part of a refused value and of a refused key, and the value is not written out
|
||||
//! first, so a huge or deeply nested value costs no more to refuse than a small one.
|
||||
//!
|
||||
//! The crate knows no command and depends on no other VectorCraft crate.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The JSON Schema dialect [`ParamSchema::to_json`] names in `$schema`.
|
||||
pub const DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/// How many objects and lists may nest inside a schema's params object.
|
||||
pub const MAX_DEPTH: usize = 16;
|
||||
|
||||
/// The largest object id [`Type::Id`] takes: 2^53 - 1, the largest integer every JSON reader holds
|
||||
/// exactly.
|
||||
pub const MAX_ID: u64 = (1 << 53) - 1;
|
||||
|
||||
/// How many characters of a refused value an error shows.
|
||||
const SHOWN_CHARS: usize = 40;
|
||||
|
||||
/// How many characters of a key an error shows.
|
||||
const KEY_CHARS: usize = 64;
|
||||
|
||||
/// One end of a number's range.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Bound {
|
||||
/// The value may equal the bound.
|
||||
Inclusive(f64),
|
||||
/// The value must be past the bound.
|
||||
Exclusive(f64),
|
||||
}
|
||||
|
||||
/// The type of a parameter's value.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Type {
|
||||
/// A JSON number in the range the bounds give, measured in `unit` (`"pt"`, `"deg"`) when set.
|
||||
Number { min: Option<Bound>, max: Option<Bound>, unit: Option<&'static str> },
|
||||
/// A JSON number with no fractional part, in the range given. [`ParamSchema::validate`] gives
|
||||
/// the command `2.0` as `2`.
|
||||
Integer { min: Option<i64>, max: Option<i64> },
|
||||
/// `true` or `false`.
|
||||
Boolean,
|
||||
/// Any string.
|
||||
String,
|
||||
/// One of the strings listed.
|
||||
Enum(&'static [&'static str]),
|
||||
/// An object id: an integer from 0 to [`MAX_ID`], read as [`Type::Integer`] reads one.
|
||||
Id,
|
||||
/// A list of object ids.
|
||||
Ids,
|
||||
/// A color: a string (`"#rrggbb"`, a color key), `[r, g, b]` or an object. The command reads
|
||||
/// which color it is.
|
||||
Color,
|
||||
/// A point: `[x, y]`, two numbers.
|
||||
Point,
|
||||
/// An object with the parameters listed and no other key.
|
||||
Object(&'static [Param]),
|
||||
/// A list of values of one type.
|
||||
Array(&'static Type),
|
||||
}
|
||||
|
||||
impl Type {
|
||||
/// A number with no range and no unit.
|
||||
pub const NUMBER: Type = Type::Number { min: None, max: None, unit: None };
|
||||
}
|
||||
|
||||
/// What an omitted value means.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Omitted {
|
||||
/// The parameter is required, or the command describes the omitted case in its summary.
|
||||
Nothing,
|
||||
/// The value, as JSON text, that [`ParamSchema::validate`] fills in.
|
||||
Value(&'static str),
|
||||
/// A default the command works out itself (`"toggle"`, `"the selected text"`).
|
||||
Note(&'static str),
|
||||
}
|
||||
|
||||
/// What a command changes when it runs.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Writes {
|
||||
/// Nothing: the command only reads.
|
||||
Nothing,
|
||||
/// State of the running app outside the document and the preferences (view state, the Caps
|
||||
/// Lock state).
|
||||
Session,
|
||||
/// The preferences.
|
||||
Preferences,
|
||||
/// The document, with an undo step.
|
||||
Document,
|
||||
}
|
||||
|
||||
impl Writes {
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Writes::Nothing => "nothing",
|
||||
Writes::Session => "session",
|
||||
Writes::Preferences => "preferences",
|
||||
Writes::Document => "document",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One parameter of a command.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Param {
|
||||
pub name: &'static str,
|
||||
pub ty: Type,
|
||||
pub required: bool,
|
||||
pub omitted: Omitted,
|
||||
/// A null value is read as if the parameter were omitted. Otherwise null is refused as a value
|
||||
/// of the wrong type.
|
||||
pub null_omitted: bool,
|
||||
/// What the params text says about the value, in parentheses after its type.
|
||||
pub doc: &'static str,
|
||||
/// The API level the parameter was added at.
|
||||
pub since: u32,
|
||||
/// The API level the parameter was deprecated at.
|
||||
pub deprecated: Option<u32>,
|
||||
/// A value of the wrong type or out of range is not refused here: the command reads it as it
|
||||
/// did before it had a schema. [`ParamSchema::to_json`] still writes the documented type, so a
|
||||
/// JSON Schema validator refuses a value this passes.
|
||||
pub lenient: bool,
|
||||
}
|
||||
|
||||
impl Param {
|
||||
/// A required parameter, added at API level 1.
|
||||
pub const fn new(name: &'static str, ty: Type) -> Param {
|
||||
Param { name, ty, required: true, omitted: Omitted::Nothing, null_omitted: false, doc: "", since: 1, deprecated: None, lenient: false }
|
||||
}
|
||||
/// The parameter may be omitted.
|
||||
pub const fn optional(self) -> Param {
|
||||
Param { required: false, ..self }
|
||||
}
|
||||
/// The parameter may be omitted, and then takes the value `json` (JSON text).
|
||||
pub const fn defaults_to(self, json: &'static str) -> Param {
|
||||
Param { required: false, omitted: Omitted::Value(json), ..self }
|
||||
}
|
||||
/// The parameter may be omitted, and the command then works out the value `note` describes.
|
||||
pub const fn default_note(self, note: &'static str) -> Param {
|
||||
Param { required: false, omitted: Omitted::Note(note), ..self }
|
||||
}
|
||||
/// A null value reads as an omitted one.
|
||||
pub const fn null_omitted(self) -> Param {
|
||||
Param { null_omitted: true, ..self }
|
||||
}
|
||||
pub const fn doc(self, doc: &'static str) -> Param {
|
||||
Param { doc, ..self }
|
||||
}
|
||||
pub const fn since(self, level: u32) -> Param {
|
||||
Param { since: level, ..self }
|
||||
}
|
||||
pub const fn deprecated(self, level: u32) -> Param {
|
||||
Param { deprecated: Some(level), ..self }
|
||||
}
|
||||
pub const fn lenient(self) -> Param {
|
||||
Param { lenient: true, ..self }
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of one command, and what the command does with them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ParamSchema {
|
||||
pub params: &'static [Param],
|
||||
/// What the command does, after the params object in the params text.
|
||||
pub summary: &'static str,
|
||||
/// What the command returns, after `→` in the params text; empty when the text leaves it out.
|
||||
pub returns: &'static str,
|
||||
/// The API level the command was added at.
|
||||
pub since: u32,
|
||||
/// The API level the command was deprecated at.
|
||||
pub deprecated: Option<u32>,
|
||||
/// What one run costs, in units of a light command (1), for callers that limit a script's work.
|
||||
pub cost: u32,
|
||||
pub writes: Writes,
|
||||
}
|
||||
|
||||
impl ParamSchema {
|
||||
/// A command added at API level 1, costing 1, that changes the document.
|
||||
pub const fn new(params: &'static [Param], summary: &'static str) -> ParamSchema {
|
||||
ParamSchema { params, summary, returns: "", since: 1, deprecated: None, cost: 1, writes: Writes::Document }
|
||||
}
|
||||
pub const fn returns(self, returns: &'static str) -> ParamSchema {
|
||||
ParamSchema { returns, ..self }
|
||||
}
|
||||
pub const fn writes(self, writes: Writes) -> ParamSchema {
|
||||
ParamSchema { writes, ..self }
|
||||
}
|
||||
pub const fn cost(self, cost: u32) -> ParamSchema {
|
||||
ParamSchema { cost, ..self }
|
||||
}
|
||||
pub const fn since(self, level: u32) -> ParamSchema {
|
||||
ParamSchema { since: level, ..self }
|
||||
}
|
||||
pub const fn deprecated(self, level: u32) -> ParamSchema {
|
||||
ParamSchema { deprecated: Some(level), ..self }
|
||||
}
|
||||
|
||||
/// The parameter named `name`.
|
||||
pub fn param(&self, name: &str) -> Option<&'static Param> {
|
||||
self.params.iter().find(|p| p.name == name)
|
||||
}
|
||||
|
||||
/// The names of the top-level parameters, in order.
|
||||
pub fn names(&self) -> Vec<&'static str> {
|
||||
self.params.iter().map(|p| p.name).collect()
|
||||
}
|
||||
|
||||
/// `params` checked against the schema: the first problem as an error naming the parameter,
|
||||
/// or, when it has none, `params` with the defaults of its omitted values filled in (`None`
|
||||
/// when no default was filled in, so `params` is used as it is). Null params read as `{}`.
|
||||
pub fn validate(&self, params: &Value) -> Result<Option<Value>, ParamError> {
|
||||
self.validate_as(params, false)
|
||||
}
|
||||
|
||||
/// [`ParamSchema::validate`] for saved steps recorded by this version or an older one: a key
|
||||
/// the schema doesn't name passes, at any depth, and the command does not read it. Values of
|
||||
/// the parameters the schema names are checked as `validate` checks them.
|
||||
pub fn validate_saved(&self, params: &Value) -> Result<Option<Value>, ParamError> {
|
||||
self.validate_as(params, true)
|
||||
}
|
||||
|
||||
fn validate_as(&self, params: &Value, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
|
||||
let given = match params {
|
||||
Value::Object(o) => o,
|
||||
Value::Null => EMPTY.get_or_init(Map::new),
|
||||
v => return Err(ParamError { path: String::new(), problem: Problem::NotAnObject { got: shown(v) } }),
|
||||
};
|
||||
Ok(check_object(&At::Root, 0, self.params, given, saved)?.map(Value::Object))
|
||||
}
|
||||
|
||||
/// What is wrong with the schema itself, or `None`: a parameter name that is empty, repeated
|
||||
/// or holds `.`, `[` or `]`; a required parameter with a default; a default that isn't JSON or
|
||||
/// doesn't pass its parameter's type; an enum with no names or a repeated name; a range with
|
||||
/// an end that isn't finite or that no value can be in; nesting past [`MAX_DEPTH`].
|
||||
pub fn definition_problem(&self) -> Option<String> {
|
||||
object_problem("", self.params, 0)
|
||||
}
|
||||
|
||||
/// The params text: the params object (`{name?: string, zoom: number > 0}`), then the summary,
|
||||
/// then `→` and what the command returns.
|
||||
pub fn params_text(&self) -> String {
|
||||
let mut s = object_text(self.params, 0);
|
||||
if !self.summary.is_empty() {
|
||||
s.push(' ');
|
||||
s.push_str(self.summary);
|
||||
}
|
||||
if !self.returns.is_empty() {
|
||||
s.push_str(" → ");
|
||||
s.push_str(self.returns);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// The schema as a JSON Schema 2020-12 object schema. Defaults written as JSON go in
|
||||
/// `default`, a parameter that accepts null as omitted is an `anyOf` with `{"type": "null"}`,
|
||||
/// and VectorCraft's own fields are `x-since`, `x-deprecatedSince`, `x-lenient`, `x-unit`,
|
||||
/// `x-kind`, `x-cost`, `x-writes` and `x-returns`. The root `type` is `["object", "null"]` when
|
||||
/// no parameter is required, because null params read as `{}`, and `"object"` otherwise. A JSON
|
||||
/// Schema validator accepts the values [`ParamSchema::validate`] accepts, except that an
|
||||
/// `x-lenient` parameter's schema states only the documented type.
|
||||
pub fn to_json(&self) -> Value {
|
||||
let mut o = object_schema(self.params, 0);
|
||||
if !self.params.iter().any(|p| p.required) {
|
||||
o.insert("type".into(), json!(["object", "null"]));
|
||||
}
|
||||
o.insert("$schema".into(), DIALECT.into());
|
||||
if !self.summary.is_empty() {
|
||||
o.insert("description".into(), self.summary.into());
|
||||
}
|
||||
if !self.returns.is_empty() {
|
||||
o.insert("x-returns".into(), self.returns.into());
|
||||
}
|
||||
o.insert("x-since".into(), self.since.into());
|
||||
if let Some(level) = self.deprecated {
|
||||
o.insert("deprecated".into(), true.into());
|
||||
o.insert("x-deprecatedSince".into(), level.into());
|
||||
}
|
||||
o.insert("x-cost".into(), self.cost.into());
|
||||
o.insert("x-writes".into(), self.writes.name().into());
|
||||
Value::Object(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// A problem [`ParamSchema::validate`] found, at `path`: the parameter's name, with the keys and
|
||||
/// indices leading to a value inside one (`edits[1].name`). The path is empty for the params
|
||||
/// object itself.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ParamError {
|
||||
pub path: String,
|
||||
pub problem: Problem,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Problem {
|
||||
/// The params are not an object.
|
||||
NotAnObject { got: String },
|
||||
/// The key at `path` is not a parameter of the object it is in, which takes `keys`.
|
||||
Unknown { keys: Vec<&'static str> },
|
||||
/// A required parameter is missing.
|
||||
Missing,
|
||||
/// The value is not of the parameter's type: `expected` says what it must be.
|
||||
WrongType { expected: String, got: String },
|
||||
/// The schema nests deeper than [`MAX_DEPTH`] here ([`ParamSchema::definition_problem`]).
|
||||
TooDeep,
|
||||
}
|
||||
|
||||
impl fmt::Display for ParamError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let path = &self.path;
|
||||
match &self.problem {
|
||||
Problem::NotAnObject { got } => write!(f, "the params must be an object, not {got}"),
|
||||
Problem::Unknown { keys } if keys.is_empty() => write!(f, "unknown parameter `{path}`; the object takes no keys"),
|
||||
Problem::Unknown { keys } => write!(f, "unknown parameter `{path}`; it takes: {}", keys.join(", ")),
|
||||
Problem::Missing => write!(f, "missing `{path}`"),
|
||||
Problem::WrongType { expected, got } => write!(f, "`{path}` must be {expected}, not {got}"),
|
||||
Problem::TooDeep => write!(f, "`{path}` is nested too deeply"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ParamError {}
|
||||
|
||||
/// FNV-1a over the bytes of `text`, from `h`.
|
||||
fn fnv1a(mut h: u64, text: &str) -> u64 {
|
||||
for b in text.bytes() {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// `v` as JSON text with the keys of each object in sorted order, whichever order the map keeps
|
||||
/// them in.
|
||||
fn canonical(v: &Value, out: &mut String) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
let mut keys: Vec<&String> = o.keys().collect();
|
||||
keys.sort();
|
||||
out.push('{');
|
||||
for (i, k) in keys.into_iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&Value::from(k.as_str()).to_string());
|
||||
out.push(':');
|
||||
if let Some(x) = o.get(k) {
|
||||
canonical(x, out);
|
||||
}
|
||||
}
|
||||
out.push('}');
|
||||
}
|
||||
Value::Array(a) => {
|
||||
out.push('[');
|
||||
for (i, x) in a.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
canonical(x, out);
|
||||
}
|
||||
out.push(']');
|
||||
}
|
||||
other => out.push_str(&other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-bit FNV-1a hash of the commands' schemas, taken in the order of their ids: each id and
|
||||
/// its [`ParamSchema::to_json`] text, with the keys of each object sorted. Any change to a schema,
|
||||
/// or a schema added or removed, changes it.
|
||||
pub fn hash<'a>(schemas: impl IntoIterator<Item = (&'a str, &'a ParamSchema)>) -> u64 {
|
||||
let mut all: Vec<(&str, &ParamSchema)> = schemas.into_iter().collect();
|
||||
all.sort_by_key(|(id, _)| *id);
|
||||
all.iter().fold(0xcbf2_9ce4_8422_2325, |h, (id, schema)| {
|
||||
let mut text = String::new();
|
||||
canonical(&schema.to_json(), &mut text);
|
||||
let h = fnv1a(h, id);
|
||||
let h = fnv1a(h, "\n");
|
||||
let h = fnv1a(h, &text);
|
||||
fnv1a(h, "\n")
|
||||
})
|
||||
}
|
||||
|
||||
/// `key` as an error message shows it: cut to [`KEY_CHARS`] characters.
|
||||
pub fn shown_key(key: &str) -> String {
|
||||
let mut chars = key.chars();
|
||||
let head: String = chars.by_ref().take(KEY_CHARS).collect();
|
||||
if chars.next().is_some() { format!("{head}…") } else { head }
|
||||
}
|
||||
|
||||
/// The JSON text of a value, stopping when its characters run out.
|
||||
struct Shown {
|
||||
text: String,
|
||||
left: usize,
|
||||
}
|
||||
|
||||
impl Shown {
|
||||
/// Adds `s`; false when it didn't fit.
|
||||
fn push(&mut self, s: &str) -> bool {
|
||||
for c in s.chars() {
|
||||
if self.left == 0 {
|
||||
return false;
|
||||
}
|
||||
self.left -= 1;
|
||||
self.text.push(c);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Adds the JSON text of string `s`, reading no more of it than can be shown.
|
||||
fn push_string(&mut self, s: &str) -> bool {
|
||||
let head: String = s.chars().take(self.left + 1).collect();
|
||||
self.push(&Value::from(head).to_string())
|
||||
}
|
||||
|
||||
/// Adds the JSON text of `v`; false when it didn't fit. Each object or list writes a bracket,
|
||||
/// so the characters left bound the recursion.
|
||||
fn push_value(&mut self, v: &Value) -> bool {
|
||||
match v {
|
||||
Value::String(s) => self.push_string(s),
|
||||
Value::Array(items) => {
|
||||
self.push("[") && items.iter().enumerate().all(|(i, x)| (i == 0 || self.push(",")) && self.push_value(x)) && self.push("]")
|
||||
}
|
||||
Value::Object(o) => {
|
||||
self.push("{")
|
||||
&& o.iter()
|
||||
.enumerate()
|
||||
.all(|(i, (k, x))| (i == 0 || self.push(",")) && self.push_string(k) && self.push(":") && self.push_value(x))
|
||||
&& self.push("}")
|
||||
}
|
||||
other => self.push(&other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A value as an error message shows it: its JSON, cut to [`SHOWN_CHARS`] characters. Only what is
|
||||
/// shown is read, however large the value is.
|
||||
fn shown(v: &Value) -> String {
|
||||
let mut out = Shown { text: String::new(), left: SHOWN_CHARS };
|
||||
if !out.push_value(v) {
|
||||
out.text.push('…');
|
||||
}
|
||||
out.text
|
||||
}
|
||||
|
||||
fn join(path: &str, key: &str) -> String {
|
||||
if path.is_empty() { key.to_string() } else { format!("{path}.{key}") }
|
||||
}
|
||||
|
||||
/// Where a value is, from the value up to the params object. The path's text is written only for
|
||||
/// an error.
|
||||
#[derive(Clone, Copy)]
|
||||
enum At<'a> {
|
||||
Root,
|
||||
Key(&'a At<'a>, &'a str),
|
||||
Index(&'a At<'a>, usize),
|
||||
}
|
||||
|
||||
impl At<'_> {
|
||||
/// The path, `edits[1].name`, with each key as [`shown_key`] shows it.
|
||||
fn text(&self) -> String {
|
||||
let mut steps = Vec::new();
|
||||
let mut at = self;
|
||||
while let At::Key(up, _) | At::Index(up, _) = at {
|
||||
steps.push(at);
|
||||
at = up;
|
||||
}
|
||||
let mut out = String::new();
|
||||
for step in steps.into_iter().rev() {
|
||||
match step {
|
||||
At::Key(_, key) => {
|
||||
if !out.is_empty() {
|
||||
out.push('.');
|
||||
}
|
||||
out.push_str(&shown_key(key));
|
||||
}
|
||||
At::Index(_, i) => out.push_str(&format!("[{i}]")),
|
||||
At::Root => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `v` nests no deeper than [`MAX_DEPTH`] counted from `depth`. The walk stops at the
|
||||
/// limit, so it recurses at most [`MAX_DEPTH`] levels and allocates nothing.
|
||||
fn nests_within(v: &Value, depth: usize) -> bool {
|
||||
match v {
|
||||
Value::Array(a) => depth < MAX_DEPTH && a.iter().all(|x| nests_within(x, depth + 1)),
|
||||
Value::Object(o) => depth < MAX_DEPTH && o.values().all(|x| nests_within(x, depth + 1)),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The object `given` checked against `params` (unknown keys first, then each parameter in
|
||||
/// order): with the defaults of omitted values filled in when there are any. `saved` keeps a key
|
||||
/// the schema doesn't name, unless its value nests past [`MAX_DEPTH`].
|
||||
fn check_object(
|
||||
at: &At<'_>,
|
||||
depth: usize,
|
||||
params: &'static [Param],
|
||||
given: &Map<String, Value>,
|
||||
saved: bool,
|
||||
) -> Result<Option<Map<String, Value>>, ParamError> {
|
||||
let refused = |key: &String, v: &Value| !params.iter().any(|p| p.name == key.as_str()) && (!saved || !nests_within(v, depth));
|
||||
if let Some((key, _)) = given.iter().find(|(k, v)| refused(k, v)) {
|
||||
return Err(ParamError { path: At::Key(at, key).text(), problem: Problem::Unknown { keys: params.iter().map(|p| p.name).collect() } });
|
||||
}
|
||||
// The changes are applied once every value has passed, so no refused value is ever copied.
|
||||
let mut changes: Vec<(&'static str, Value)> = Vec::new();
|
||||
for p in params {
|
||||
let here = At::Key(at, p.name);
|
||||
match given.get(p.name) {
|
||||
Some(Value::Null) if p.null_omitted => {}
|
||||
Some(v) => {
|
||||
if let Some(changed) = check_value(&here, depth, &p.ty, v, p.lenient, saved)? {
|
||||
changes.push((p.name, changed));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
None if p.required => return Err(ParamError { path: here.text(), problem: Problem::Missing }),
|
||||
None => {}
|
||||
}
|
||||
// Omitted, or null read as omitted.
|
||||
if let Omitted::Value(json) = p.omitted
|
||||
&& let Ok(v) = serde_json::from_str::<Value>(json)
|
||||
{
|
||||
changes.push((p.name, v));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut filled = given.clone();
|
||||
for (name, v) in changes {
|
||||
filled.insert(name.to_string(), v);
|
||||
}
|
||||
Ok(Some(filled))
|
||||
}
|
||||
|
||||
/// What a value of type `ty` must be, as an error message says it.
|
||||
fn expected(ty: &Type) -> String {
|
||||
match ty {
|
||||
Type::Number { min, max, unit } => {
|
||||
format!("a number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" ({u})")).unwrap_or_default())
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
format!("an integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
|
||||
}
|
||||
Type::Boolean => "true or false".into(),
|
||||
Type::String => "a string".into(),
|
||||
Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join(", ")),
|
||||
Type::Id => "an object id (a non-negative integer)".into(),
|
||||
Type::Ids => "a list of object ids".into(),
|
||||
Type::Color => "a color (a string, [r, g, b] or an object)".into(),
|
||||
Type::Point => "[x, y] (two numbers)".into(),
|
||||
Type::Object(_) => "an object".into(),
|
||||
Type::Array(_) => "a list".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// ` > 0`, ` 0 to 100`, ` >= 1 and < 10`: the range text after a number's type.
|
||||
fn range_text(min: Option<&Bound>, max: Option<&Bound>) -> String {
|
||||
match (min, max) {
|
||||
(Some(Bound::Inclusive(a)), Some(Bound::Inclusive(b))) => format!(" {a} to {b}"),
|
||||
(Some(a), Some(b)) => format!("{} and{}", low_text(a), high_text(b)),
|
||||
(Some(a), None) => low_text(a),
|
||||
(None, Some(b)) => high_text(b),
|
||||
(None, None) => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn low_text(b: &Bound) -> String {
|
||||
match b {
|
||||
Bound::Inclusive(a) => format!(" >= {a}"),
|
||||
Bound::Exclusive(a) => format!(" > {a}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn high_text(b: &Bound) -> String {
|
||||
match b {
|
||||
Bound::Inclusive(a) => format!(" <= {a}"),
|
||||
Bound::Exclusive(a) => format!(" < {a}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn in_range(x: f64, min: Option<&Bound>, max: Option<&Bound>) -> bool {
|
||||
let low = match min {
|
||||
Some(Bound::Inclusive(a)) => x >= *a,
|
||||
Some(Bound::Exclusive(a)) => x > *a,
|
||||
None => true,
|
||||
};
|
||||
let high = match max {
|
||||
Some(Bound::Inclusive(b)) => x <= *b,
|
||||
Some(Bound::Exclusive(b)) => x < *b,
|
||||
None => true,
|
||||
};
|
||||
low && high
|
||||
}
|
||||
|
||||
/// The integer a JSON number with no fractional part stands for, as an integer value when it fits
|
||||
/// in one (`2.0` is `2`); `None` for anything else.
|
||||
fn whole(v: &Value) -> Option<Value> {
|
||||
if v.is_i64() || v.is_u64() {
|
||||
return Some(v.clone());
|
||||
}
|
||||
const TWO_63: f64 = 9_223_372_036_854_775_808.0;
|
||||
const TWO_64: f64 = 18_446_744_073_709_551_616.0;
|
||||
let x = v.as_f64().filter(|x| x.fract() == 0.0)?;
|
||||
Some(if (-TWO_63..TWO_63).contains(&x) {
|
||||
json!(x as i64)
|
||||
} else if (0.0..TWO_64).contains(&x) {
|
||||
json!(x as u64)
|
||||
} else {
|
||||
v.clone()
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the [`whole`] value `w` is in the range.
|
||||
fn whole_in_range(w: &Value, min: Option<i64>, max: Option<i64>) -> bool {
|
||||
match (w.as_i64(), w.as_u64(), w.as_f64()) {
|
||||
(Some(x), _, _) => min.is_none_or(|m| x >= m) && max.is_none_or(|m| x <= m),
|
||||
// Past i64::MAX: above any i64 bound.
|
||||
(None, Some(_), _) => max.is_none(),
|
||||
// Past what u64 holds: above any i64 bound.
|
||||
(None, None, Some(x)) if x > 0.0 => max.is_none(),
|
||||
// Below what i64 holds: under any i64 bound, so it meets every maximum and no minimum.
|
||||
(None, None, Some(_)) => min.is_none(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_number(v: &Value) -> bool {
|
||||
v.as_f64().is_some_and(f64::is_finite)
|
||||
}
|
||||
|
||||
/// `v` checked against `ty` at `at`: with the defaults of omitted values inside it filled in, and
|
||||
/// whole numbers written with a fraction made integers, when there are any. A lenient value of
|
||||
/// the wrong type passes as it is, unless it nests past [`MAX_DEPTH`].
|
||||
fn check_value(at: &At<'_>, depth: usize, ty: &Type, v: &Value, lenient: bool, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
let done = |ok: bool| {
|
||||
if ok || (lenient && nests_within(v, depth)) {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(ParamError { path: at.text(), problem: Problem::WrongType { expected: expected(ty), got: shown(v) } })
|
||||
}
|
||||
};
|
||||
let too_deep = || ParamError { path: at.text(), problem: Problem::TooDeep };
|
||||
if matches!(ty, Type::Ids | Type::Color | Type::Point) && v.is_array() && depth >= MAX_DEPTH {
|
||||
return Err(too_deep());
|
||||
}
|
||||
match ty {
|
||||
Type::Number { min, max, .. } => done(v.as_f64().is_some_and(|x| x.is_finite() && in_range(x, min.as_ref(), max.as_ref()))),
|
||||
Type::Integer { min, max } => match whole(v).filter(|w| whole_in_range(w, *min, *max)) {
|
||||
Some(w) => Ok((w != *v).then_some(w)),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Boolean => done(v.is_boolean()),
|
||||
Type::String => done(v.is_string()),
|
||||
Type::Enum(names) => done(v.as_str().is_some_and(|s| names.contains(&s))),
|
||||
Type::Id => match whole(v).filter(|w| w.as_u64().is_some_and(|x| x <= MAX_ID)) {
|
||||
Some(w) => Ok((w != *v).then_some(w)),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Ids => match v.as_array() {
|
||||
Some(items) => check_items(at, depth, &Type::Id, items, lenient, saved),
|
||||
None => done(false),
|
||||
},
|
||||
Type::Color => done(match v {
|
||||
Value::String(_) => true,
|
||||
Value::Object(_) => nests_within(v, depth),
|
||||
Value::Array(a) => a.len() >= 3 && a.iter().all(is_number),
|
||||
_ => false,
|
||||
}),
|
||||
Type::Point => done(v.as_array().is_some_and(|a| a.len() == 2 && a.iter().all(is_number))),
|
||||
Type::Object(params) => match v {
|
||||
Value::Object(_) if depth >= MAX_DEPTH => Err(too_deep()),
|
||||
Value::Object(o) => Ok(check_object(at, depth + 1, params, o, saved)?.map(Value::Object)),
|
||||
_ => done(false),
|
||||
},
|
||||
Type::Array(item) => match v.as_array() {
|
||||
Some(_) if depth >= MAX_DEPTH => Err(too_deep()),
|
||||
Some(items) => check_items(at, depth + 1, item, items, lenient, saved),
|
||||
None => done(false),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Each of `items` checked against `item`: the list with what [`check_value`] changed, when it
|
||||
/// changed anything.
|
||||
fn check_items(at: &At<'_>, depth: usize, item: &Type, items: &[Value], lenient: bool, saved: bool) -> Result<Option<Value>, ParamError> {
|
||||
let mut changes: Vec<(usize, Value)> = Vec::new();
|
||||
for (i, x) in items.iter().enumerate() {
|
||||
if let Some(changed) = check_value(&At::Index(at, i), depth, item, x, lenient, saved)? {
|
||||
changes.push((i, changed));
|
||||
}
|
||||
}
|
||||
if changes.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut filled = items.to_vec();
|
||||
for (i, v) in changes {
|
||||
if let Some(slot) = filled.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
}
|
||||
Ok(Some(Value::Array(filled)))
|
||||
}
|
||||
|
||||
/// What is wrong with `params`, the object at `path` nested `depth` objects and lists deep.
|
||||
fn object_problem(path: &str, params: &[Param], depth: usize) -> Option<String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Some(format!("`{path}` nests deeper than {MAX_DEPTH} levels"));
|
||||
}
|
||||
for (i, p) in params.iter().enumerate() {
|
||||
let at = join(path, p.name);
|
||||
if p.name.is_empty() || p.name.contains(['.', '[', ']']) {
|
||||
return Some(format!("the parameter name `{at}` is empty or holds a path character"));
|
||||
}
|
||||
if params.iter().take(i).any(|q| q.name == p.name) {
|
||||
return Some(format!("`{at}` is listed twice"));
|
||||
}
|
||||
if p.required && p.omitted != Omitted::Nothing {
|
||||
return Some(format!("`{at}` is required and has a default"));
|
||||
}
|
||||
if let Some(problem) = type_problem(&at, &p.ty, depth) {
|
||||
return Some(problem);
|
||||
}
|
||||
if let Omitted::Value(text) = p.omitted {
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return Some(format!("the default of `{at}` isn't JSON: {text}")) };
|
||||
if let Err(e) = check_value(&At::Root, 0, &p.ty, &v, false, false) {
|
||||
return Some(format!("the default of `{at}` doesn't pass its type: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn type_problem(at: &str, ty: &Type, depth: usize) -> Option<String> {
|
||||
let bound = |b: &Bound| match *b {
|
||||
Bound::Inclusive(x) => (x, false),
|
||||
Bound::Exclusive(x) => (x, true),
|
||||
};
|
||||
match ty {
|
||||
Type::Number { min, max, .. } => {
|
||||
let (low, high) = (min.as_ref().map(bound), max.as_ref().map(bound));
|
||||
if low.iter().chain(high.iter()).any(|(x, _)| !x.is_finite()) {
|
||||
return Some(format!("the range of `{at}` has an end that isn't finite"));
|
||||
}
|
||||
match (low, high) {
|
||||
(Some((a, open_a)), Some((b, open_b))) if a > b || (a == b && (open_a || open_b)) => {
|
||||
Some(format!("no value can be in the range of `{at}`"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
Type::Integer { min: Some(a), max: Some(b) } if a > b => Some(format!("no value can be in the range of `{at}`")),
|
||||
Type::Enum([]) => Some(format!("the enum of `{at}` has no names")),
|
||||
Type::Enum(names) if names.iter().enumerate().any(|(i, n)| names.iter().take(i).any(|m| m == n)) => {
|
||||
Some(format!("the enum of `{at}` repeats a name"))
|
||||
}
|
||||
Type::Object(params) => object_problem(at, params, depth + 1),
|
||||
Type::Array(_) if depth >= MAX_DEPTH => Some(format!("`{at}` nests deeper than {MAX_DEPTH} levels")),
|
||||
Type::Array(item) => type_problem(&format!("{at}[]"), item, depth + 1),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `{a: number, b?: bool}`: the params object of a params text, `depth` objects and lists deep.
|
||||
fn object_text(params: &[Param], depth: usize) -> String {
|
||||
format!("{{{}}}", params.iter().map(|p| param_text(p, depth)).collect::<Vec<_>>().join(", "))
|
||||
}
|
||||
|
||||
fn param_text(p: &Param, depth: usize) -> String {
|
||||
let mut s = format!("{}{}: {}", p.name, if p.required { "" } else { "?" }, type_text(&p.ty, depth));
|
||||
match p.omitted {
|
||||
Omitted::Value(v) | Omitted::Note(v) => s.push_str(&format!(" (default: {v})")),
|
||||
Omitted::Nothing => {}
|
||||
}
|
||||
if !p.doc.is_empty() {
|
||||
s.push_str(&format!(" ({})", p.doc));
|
||||
}
|
||||
if p.deprecated.is_some() {
|
||||
s.push_str(" (deprecated)");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn type_text(ty: &Type, depth: usize) -> String {
|
||||
match ty {
|
||||
Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => "…".into(),
|
||||
Type::Number { min, max, unit } => {
|
||||
format!("number{}{}", range_text(min.as_ref(), max.as_ref()), unit.map(|u| format!(" {u}")).unwrap_or_default())
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
format!("integer{}", range_text(min.map(|m| Bound::Inclusive(m as f64)).as_ref(), max.map(|m| Bound::Inclusive(m as f64)).as_ref()))
|
||||
}
|
||||
Type::Boolean => "bool".into(),
|
||||
Type::String => "string".into(),
|
||||
Type::Enum(names) => names.iter().map(|n| format!("\"{n}\"")).collect::<Vec<_>>().join("|"),
|
||||
Type::Id => "id".into(),
|
||||
Type::Ids => "[id…]".into(),
|
||||
Type::Color => "color".into(),
|
||||
Type::Point => "[x, y]".into(),
|
||||
Type::Object(params) => object_text(params, depth + 1),
|
||||
Type::Array(item) => format!("[{}…]", type_text(item, depth + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `{"type": "object", "properties": …, "required": […], "additionalProperties": false}`.
|
||||
fn object_schema(params: &[Param], depth: usize) -> Map<String, Value> {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "object".into());
|
||||
o.insert("properties".into(), Value::Object(params.iter().map(|p| (p.name.to_string(), param_schema(p, depth))).collect()));
|
||||
let required: Vec<Value> = params.iter().filter(|p| p.required).map(|p| p.name.into()).collect();
|
||||
if !required.is_empty() {
|
||||
o.insert("required".into(), required.into());
|
||||
}
|
||||
o.insert("additionalProperties".into(), false.into());
|
||||
o
|
||||
}
|
||||
|
||||
fn param_schema(p: &Param, depth: usize) -> Value {
|
||||
let inner = type_schema(&p.ty, depth);
|
||||
let mut o = if p.null_omitted {
|
||||
let mut o = Map::new();
|
||||
o.insert("anyOf".into(), json!([inner, {"type": "null"}]));
|
||||
o
|
||||
} else {
|
||||
match inner {
|
||||
Value::Object(o) => o,
|
||||
_ => Map::new(),
|
||||
}
|
||||
};
|
||||
if !p.doc.is_empty() {
|
||||
o.insert("description".into(), p.doc.into());
|
||||
}
|
||||
match p.omitted {
|
||||
Omitted::Value(json) => {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(json) {
|
||||
o.insert("default".into(), v);
|
||||
}
|
||||
}
|
||||
Omitted::Note(note) => {
|
||||
o.insert("x-default".into(), note.into());
|
||||
}
|
||||
Omitted::Nothing => {}
|
||||
}
|
||||
o.insert("x-since".into(), p.since.into());
|
||||
if let Some(level) = p.deprecated {
|
||||
o.insert("deprecated".into(), true.into());
|
||||
o.insert("x-deprecatedSince".into(), level.into());
|
||||
}
|
||||
if p.lenient {
|
||||
o.insert("x-lenient".into(), true.into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
fn bound_keywords(o: &mut Map<String, Value>, min: Option<&Bound>, max: Option<&Bound>) {
|
||||
let low = match min {
|
||||
Some(Bound::Inclusive(a)) => Some(("minimum", a)),
|
||||
Some(Bound::Exclusive(a)) => Some(("exclusiveMinimum", a)),
|
||||
None => None,
|
||||
};
|
||||
let high = match max {
|
||||
Some(Bound::Inclusive(b)) => Some(("maximum", b)),
|
||||
Some(Bound::Exclusive(b)) => Some(("exclusiveMaximum", b)),
|
||||
None => None,
|
||||
};
|
||||
for (key, x) in low.into_iter().chain(high) {
|
||||
o.insert(key.into(), json!(x));
|
||||
}
|
||||
}
|
||||
|
||||
fn type_schema(ty: &Type, depth: usize) -> Value {
|
||||
match ty {
|
||||
Type::Object(_) | Type::Array(_) if depth >= MAX_DEPTH => Value::Bool(true),
|
||||
Type::Number { min, max, unit } => {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "number".into());
|
||||
bound_keywords(&mut o, min.as_ref(), max.as_ref());
|
||||
if let Some(u) = unit {
|
||||
o.insert("x-unit".into(), (*u).into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
Type::Integer { min, max } => {
|
||||
let mut o = Map::new();
|
||||
o.insert("type".into(), "integer".into());
|
||||
if let Some(m) = min {
|
||||
o.insert("minimum".into(), (*m).into());
|
||||
}
|
||||
if let Some(m) = max {
|
||||
o.insert("maximum".into(), (*m).into());
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
Type::Boolean => json!({"type": "boolean"}),
|
||||
Type::String => json!({"type": "string"}),
|
||||
Type::Enum(names) => json!({"enum": names}),
|
||||
Type::Id => json!({"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}),
|
||||
Type::Ids => json!({"type": "array", "items": {"type": "integer", "minimum": 0, "maximum": MAX_ID, "x-kind": "id"}}),
|
||||
Type::Color => json!({
|
||||
"anyOf": [{"type": "string"}, {"type": "array", "items": {"type": "number"}, "minItems": 3}, {"type": "object"}],
|
||||
"x-kind": "color",
|
||||
}),
|
||||
Type::Point => json!({
|
||||
"type": "array",
|
||||
"prefixItems": [{"type": "number"}, {"type": "number"}],
|
||||
"items": false,
|
||||
"minItems": 2,
|
||||
"x-kind": "point",
|
||||
}),
|
||||
Type::Object(params) => Value::Object(object_schema(params, depth + 1)),
|
||||
Type::Array(item) => json!({"type": "array", "items": type_schema(item, depth + 1)}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conformance;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,536 @@
|
||||
//! Tests of the validator, the params text and the JSON form, on schemas written here.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// The schema with the one parameter `x` of type `ty`.
|
||||
fn one(ty: Type) -> ParamSchema {
|
||||
let params: &'static [Param] = Box::leak(Box::new([Param::new("x", ty)]));
|
||||
ParamSchema::new(params, "")
|
||||
}
|
||||
|
||||
/// Whether `{x: v}` passes the schema of `ty`; on a refusal, the error must name `x`.
|
||||
fn passes(ty: Type, v: Value) -> bool {
|
||||
match one(ty).validate(&json!({ "x": v })) {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
assert!(e.path.starts_with('x'), "{e}");
|
||||
assert!(e.to_string().contains("`x"), "{e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_are_checked_against_their_range() {
|
||||
let any = Type::NUMBER;
|
||||
assert!(passes(any, json!(-3.5)) && passes(any, json!(7)));
|
||||
assert!(!passes(any, json!("7")) && !passes(any, json!(null)) && !passes(any, json!(true)));
|
||||
let positive = Type::Number { min: Some(Bound::Exclusive(0.0)), max: None, unit: None };
|
||||
assert!(passes(positive, json!(0.001)));
|
||||
assert!(!passes(positive, json!(0)) && !passes(positive, json!(-1)));
|
||||
let percent = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(100.0)), unit: Some("%") };
|
||||
assert!(passes(percent, json!(0)) && passes(percent, json!(100)));
|
||||
assert!(!passes(percent, json!(100.5)));
|
||||
let below = Type::Number { min: None, max: Some(Bound::Exclusive(1.0)), unit: None };
|
||||
assert!(passes(below, json!(0.99)) && !passes(below, json!(1)));
|
||||
assert_eq!(one(positive).validate(&json!({"x": 0})).unwrap_err().to_string(), "`x` must be a number > 0, not 0");
|
||||
assert_eq!(one(percent).validate(&json!({"x": 101})).unwrap_err().to_string(), "`x` must be a number 0 to 100 (%), not 101");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integers_take_no_fraction_and_keep_their_range() {
|
||||
let any = Type::Integer { min: None, max: None };
|
||||
assert!(passes(any, json!(-4)) && passes(any, json!(u64::MAX)));
|
||||
assert!(!passes(any, json!(1.5)) && !passes(any, json!("2")));
|
||||
let small = Type::Integer { min: Some(1), max: Some(25) };
|
||||
assert!(passes(small, json!(1)) && passes(small, json!(25)));
|
||||
assert!(!passes(small, json!(0)) && !passes(small, json!(26)) && !passes(small, json!(u64::MAX)));
|
||||
assert!(passes(small, json!(25.0)) && !passes(small, json!(26.0)) && !passes(small, json!(1e300)) && !passes(small, json!(-1e300)));
|
||||
let negative = Type::Integer { min: None, max: Some(-1_000_000_000_000_000_000) };
|
||||
assert!(passes(negative, json!(-1e20)) && !passes(negative, json!(1e20)));
|
||||
let lowest = Type::Integer { min: Some(i64::MIN), max: None };
|
||||
assert!(!passes(lowest, json!(-1e20)) && passes(lowest, json!(1e20)));
|
||||
}
|
||||
|
||||
/// JSON Schema counts a number with no fractional part as an integer: `2.0` passes, and the
|
||||
/// command gets `2`.
|
||||
#[test]
|
||||
fn a_whole_number_written_with_a_fraction_is_an_integer() {
|
||||
let any = Type::Integer { min: None, max: None };
|
||||
assert_eq!(one(any).validate(&json!({"x": 2.0})).unwrap(), Some(json!({"x": 2})));
|
||||
assert_eq!(one(any).validate(&json!({"x": 2})).unwrap(), None);
|
||||
assert!(passes(any, json!(1e300)));
|
||||
assert_eq!(one(Type::Id).validate(&json!({"x": 7.0})).unwrap(), Some(json!({"x": 7})));
|
||||
assert_eq!(one(Type::Ids).validate(&json!({"x": [1, 2.0]})).unwrap(), Some(json!({"x": [1, 2]})));
|
||||
assert!(passes(Type::Id, json!(MAX_ID)) && !passes(Type::Id, json!(MAX_ID + 1)) && !passes(Type::Id, json!(1e300)));
|
||||
assert_eq!(one(Type::Ids).validate(&json!({"x": [1, "2"]})).unwrap_err().path, "x[1]");
|
||||
}
|
||||
|
||||
/// A value with no finite number in it reads as null in JSON, and a number refuses it; the largest
|
||||
/// finite numbers pass a number with no range.
|
||||
#[test]
|
||||
fn numbers_at_the_edges() {
|
||||
assert!(!passes(Type::NUMBER, json!(f64::NAN)) && !passes(Type::NUMBER, json!(f64::INFINITY)));
|
||||
assert!(passes(Type::NUMBER, json!(f64::MAX)) && passes(Type::NUMBER, json!(f64::MIN)));
|
||||
let unit = Type::Number { min: Some(Bound::Inclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None };
|
||||
assert!(passes(unit, json!(0.0)) && passes(unit, json!(1.0)) && !passes(unit, json!(-1e-300)) && !passes(unit, json!(1.0000001)));
|
||||
}
|
||||
|
||||
/// A list too long to name one path per entry costs one pass, and the error names the entry.
|
||||
#[test]
|
||||
fn a_huge_list_is_checked_once_and_names_the_entry() {
|
||||
let mut ids: Vec<Value> = (0..200_000u64).map(Value::from).collect();
|
||||
assert_eq!(one(Type::Ids).validate(&json!({ "x": ids })).unwrap(), None);
|
||||
ids.push(json!("no"));
|
||||
let e = one(Type::Ids).validate(&json!({ "x": ids })).unwrap_err();
|
||||
assert_eq!(e.path, "x[200000]");
|
||||
assert!(e.to_string().chars().count() < 120, "{e}");
|
||||
}
|
||||
|
||||
/// A list nested far past anything the schema can take: dropping or writing it out recurses per
|
||||
/// level, which is why the test leaks it rather than dropping it.
|
||||
fn nested_lists(levels: usize) -> Value {
|
||||
(0..levels).fold(json!(1), |inner, _| Value::Array(vec![inner]))
|
||||
}
|
||||
|
||||
/// A refused value is shown from its start only, however deep or long it is, so an error costs
|
||||
/// the same for a hostile value as for a small one.
|
||||
#[test]
|
||||
fn a_refused_value_is_read_only_as_far_as_it_is_shown() {
|
||||
let deep = nested_lists(500_000);
|
||||
let params = json!({});
|
||||
let mut map = params.as_object().unwrap().clone();
|
||||
map.insert("x".into(), deep);
|
||||
let params = Value::Object(map);
|
||||
let e = one(Type::String).validate(¶ms).unwrap_err();
|
||||
assert!(e.to_string().starts_with("`x` must be a string, not [[[[") && e.to_string().ends_with('…'), "{e}");
|
||||
assert!(e.to_string().chars().count() <= 100, "{e}");
|
||||
std::mem::forget(params);
|
||||
|
||||
let huge = "x".repeat(5_000_000);
|
||||
let e = one(Type::Boolean).validate(&json!({ "x": huge })).unwrap_err();
|
||||
assert!(e.to_string().chars().count() <= 100, "{}", e.to_string().len());
|
||||
assert!(e.to_string().ends_with('…'));
|
||||
|
||||
let e = one(Type::Boolean).validate(&json!({"x": {"a": 1, "b": [1, 2]}})).unwrap_err();
|
||||
assert_eq!(e.to_string(), "`x` must be true or false, not {\"a\":1,\"b\":[1,2]}");
|
||||
let e = ParamSchema::new(&[], "").validate(&nested_lists(3)).unwrap_err();
|
||||
assert_eq!(e.to_string(), "the params must be an object, not [[[1]]]");
|
||||
}
|
||||
|
||||
/// A list entry made an integer, and a default filled in, don't copy a value that is still to be
|
||||
/// checked: copying recurses per level.
|
||||
#[test]
|
||||
fn a_list_with_a_change_is_not_copied_before_all_of_it_is_checked() {
|
||||
let params = Value::Object([("x".to_string(), Value::Array(vec![json!(2.0), nested_lists(500_000)]))].into_iter().collect());
|
||||
assert_eq!(one(Type::Ids).validate(¶ms).unwrap_err().path, "x[1]");
|
||||
std::mem::forget(params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_object_with_a_default_is_not_copied_before_all_of_it_is_checked() {
|
||||
const FILLED_FIRST: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::String)];
|
||||
let params = Value::Object([("b".to_string(), nested_lists(500_000))].into_iter().collect());
|
||||
assert_eq!(ParamSchema::new(FILLED_FIRST, "").validate(¶ms).unwrap_err().path, "b");
|
||||
std::mem::forget(params);
|
||||
}
|
||||
|
||||
/// A value the schema doesn't look inside is measured before a default copies the object it is in.
|
||||
const WITH_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Color).optional()];
|
||||
const LENIENT_AFTER_DEFAULT: &[Param] = &[Param::new("a", Type::NUMBER).defaults_to("0"), Param::new("b", Type::Boolean).optional().lenient()];
|
||||
|
||||
fn holding(key: &str, v: Value) -> Value {
|
||||
Value::Object([(key.to_string(), v)].into_iter().collect())
|
||||
}
|
||||
|
||||
/// The lists of fixed shape count toward the nesting limit as the other lists do.
|
||||
#[test]
|
||||
fn id_lists_points_and_color_lists_count_toward_the_nesting_limit() {
|
||||
let check = |depth, ty: &Type, v: &Value| check_value(&At::Root, depth, ty, v, false, false);
|
||||
for (ty, v) in [(Type::Ids, json!([1])), (Type::Point, json!([1, 2])), (Type::Color, json!([1, 0, 0]))] {
|
||||
assert!(check(MAX_DEPTH - 1, &ty, &v).is_ok(), "{ty:?}");
|
||||
assert_eq!(check(MAX_DEPTH, &ty, &v).unwrap_err().problem, Problem::TooDeep, "{ty:?}");
|
||||
}
|
||||
assert!(check(MAX_DEPTH, &Type::Color, &json!("#ff0000")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_retired_key_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("older", nested_lists(500_000));
|
||||
let e = ParamSchema::new(WITH_DEFAULT, "").validate_saved(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "older");
|
||||
assert!(matches!(e.problem, Problem::Unknown { .. }));
|
||||
std::mem::forget(params);
|
||||
let kept = ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH))).unwrap().unwrap();
|
||||
assert_eq!(kept["a"], json!(0));
|
||||
assert_eq!(kept["older"], nested_lists(MAX_DEPTH));
|
||||
assert!(ParamSchema::new(WITH_DEFAULT, "").validate_saved(&holding("older", nested_lists(MAX_DEPTH + 1))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_color_object_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("b", holding("k", nested_lists(500_000)));
|
||||
let e = ParamSchema::new(WITH_DEFAULT, "").validate(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "b");
|
||||
assert!(matches!(e.problem, Problem::WrongType { .. }));
|
||||
std::mem::forget(params);
|
||||
assert!(ParamSchema::new(WITH_DEFAULT, "").validate(&json!({"b": {"r": 1, "g": 0, "b": 0}})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lenient_value_past_the_nesting_limit_is_refused() {
|
||||
let params = holding("b", nested_lists(500_000));
|
||||
let e = ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(¶ms).unwrap_err();
|
||||
assert_eq!(e.path, "b");
|
||||
assert!(matches!(e.problem, Problem::WrongType { .. }));
|
||||
std::mem::forget(params);
|
||||
assert_eq!(ParamSchema::new(LENIENT_AFTER_DEFAULT, "").validate(&json!({"b": [1]})).unwrap().unwrap()["b"], json!([1]));
|
||||
}
|
||||
|
||||
/// A key is cut in the error, and an unknown key is refused whatever it holds.
|
||||
#[test]
|
||||
fn a_hostile_key_is_cut_and_refused() {
|
||||
let key = "k".repeat(1_000_000);
|
||||
let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err();
|
||||
assert_eq!(e.path.chars().count(), KEY_CHARS + 1);
|
||||
assert!(e.path.ends_with('…') && e.to_string().chars().count() < 200, "{}", e.path.len());
|
||||
for key in ["", "a.b", "a[0]", "`", "\u{0}", "名前"] {
|
||||
let e = one(Type::Boolean).validate(&json!({ key: true, "x": true })).unwrap_err();
|
||||
assert_eq!(e.path, key);
|
||||
assert!(matches!(e.problem, Problem::Unknown { .. }));
|
||||
}
|
||||
assert_eq!(shown_key("short"), "short");
|
||||
}
|
||||
|
||||
/// A schema that refers to itself, which only a mistake can write: checking values stops at the
|
||||
/// nesting limit, and the writers finish.
|
||||
static ENDLESS: Type = Type::Array(&ENDLESS);
|
||||
|
||||
#[test]
|
||||
fn a_schema_that_refers_to_itself_stops_at_the_nesting_limit() {
|
||||
let s = one(ENDLESS);
|
||||
let e = s.validate(&json!({ "x": nested_lists(MAX_DEPTH + 2) })).unwrap_err();
|
||||
assert_eq!(e.problem, Problem::TooDeep);
|
||||
assert!(e.path.starts_with("x[0][0]"));
|
||||
assert!(s.definition_problem().is_some_and(|p| p.contains("nests deeper")));
|
||||
assert!(s.params_text().contains('…'));
|
||||
assert!(s.to_json()["properties"]["x"].is_object());
|
||||
assert_ne!(hash([("a", &s)]), 0);
|
||||
}
|
||||
|
||||
const BAD_DEFAULT: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("\"zero\"")];
|
||||
const NOT_JSON: &[Param] = &[Param::new("x", Type::NUMBER).defaults_to("{")];
|
||||
const TWICE: &[Param] = &[Param::new("x", Type::NUMBER), Param::new("x", Type::String)];
|
||||
const EMPTY_NAME: &[Param] = &[Param::new("", Type::NUMBER)];
|
||||
const PATH_NAME: &[Param] = &[Param::new("a.b", Type::NUMBER)];
|
||||
const NO_NAMES: &[Param] = &[Param::new("x", Type::Enum(&[]))];
|
||||
const SAME_NAMES: &[Param] = &[Param::new("x", Type::Enum(&["a", "a"]))];
|
||||
const EMPTY_RANGE: &[Param] = &[Param::new("x", Type::Number { min: Some(Bound::Exclusive(1.0)), max: Some(Bound::Inclusive(1.0)), unit: None })];
|
||||
const BACKWARD: &[Param] = &[Param::new("x", Type::Integer { min: Some(2), max: Some(1) })];
|
||||
const NOT_FINITE: &[Param] = &[Param::new("x", Type::Number { min: None, max: Some(Bound::Inclusive(f64::INFINITY)), unit: None })];
|
||||
const REQUIRED_DEFAULT: &[Param] = &[Param { required: true, omitted: Omitted::Value("1"), ..Param::new("x", Type::NUMBER) }];
|
||||
const DEEP_FAULT: &[Param] = &[Param::new("edits", Type::Array(&Type::Object(TWICE)))];
|
||||
|
||||
#[test]
|
||||
fn a_schema_that_is_wrong_in_itself_is_reported() {
|
||||
for (params, why) in [
|
||||
(BAD_DEFAULT, "doesn't pass its type"),
|
||||
(NOT_JSON, "isn't JSON"),
|
||||
(TWICE, "listed twice"),
|
||||
(EMPTY_NAME, "empty or holds"),
|
||||
(PATH_NAME, "empty or holds"),
|
||||
(NO_NAMES, "no names"),
|
||||
(SAME_NAMES, "repeats"),
|
||||
(EMPTY_RANGE, "no value can be"),
|
||||
(BACKWARD, "no value can be"),
|
||||
(NOT_FINITE, "isn't finite"),
|
||||
(REQUIRED_DEFAULT, "required"),
|
||||
(DEEP_FAULT, "`edits[].x` is listed twice"),
|
||||
] {
|
||||
let problem = ParamSchema::new(params, "").definition_problem();
|
||||
assert!(problem.as_deref().is_some_and(|p| p.contains(why)), "{params:?}: {problem:?}");
|
||||
}
|
||||
assert_eq!(ParamSchema::new(VIEW, "").definition_problem(), None);
|
||||
assert_eq!(ParamSchema::new(&[], "").definition_problem(), None);
|
||||
}
|
||||
|
||||
/// Saved steps keep keys the schema doesn't name, at any depth; the values it names are checked.
|
||||
#[test]
|
||||
fn saved_steps_pass_retired_keys_and_check_their_values() {
|
||||
let edits = one(Type::Array(&Type::Object(EDIT)));
|
||||
let retired = json!({"x": [{"name": "A", "old": 1}], "older": true});
|
||||
assert!(edits.validate(&retired).is_err());
|
||||
let filled = edits.validate_saved(&retired).unwrap().unwrap();
|
||||
assert_eq!(filled["x"][0]["delete"], json!(false), "defaults are filled in");
|
||||
assert_eq!(filled["older"], json!(true), "the keys stay");
|
||||
assert_eq!(edits.validate_saved(&json!({"x": [{"name": 5}], "older": true})).unwrap_err().path, "x[0].name");
|
||||
assert_eq!(edits.validate_saved(&json!({"older": true})).unwrap_err().problem, Problem::Missing);
|
||||
}
|
||||
|
||||
/// Every type's JSON form follows the 2020-12 rules, and an independent validator of those rules
|
||||
/// agrees with [`ParamSchema::validate`] on each value.
|
||||
#[test]
|
||||
fn the_json_form_and_the_validator_agree_with_the_2020_12_rules() {
|
||||
const NULLABLE: &[Param] = &[Param::new("a", Type::String).optional().null_omitted(), Param::new("b", Type::Boolean).defaults_to("true")];
|
||||
let types = [
|
||||
Type::NUMBER,
|
||||
Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: Some("pt") },
|
||||
Type::Number { min: Some(Bound::Inclusive(-1.0)), max: Some(Bound::Exclusive(1.0)), unit: None },
|
||||
Type::Integer { min: None, max: None },
|
||||
Type::Integer { min: Some(1), max: Some(25) },
|
||||
Type::Boolean,
|
||||
Type::String,
|
||||
Type::Enum(&["a", "b"]),
|
||||
Type::Id,
|
||||
Type::Ids,
|
||||
Type::Color,
|
||||
Type::Point,
|
||||
Type::Object(EDIT),
|
||||
Type::Object(NULLABLE),
|
||||
Type::Object(&[]),
|
||||
Type::Array(&Type::NUMBER),
|
||||
Type::Array(&Type::Object(EDIT)),
|
||||
];
|
||||
let values = [
|
||||
json!(null),
|
||||
json!(true),
|
||||
json!(0),
|
||||
json!(1),
|
||||
json!(2.0),
|
||||
json!(25.5),
|
||||
json!(-1),
|
||||
json!(-1.0),
|
||||
json!(1e300),
|
||||
json!(MAX_ID),
|
||||
json!(MAX_ID + 1),
|
||||
json!(u64::MAX),
|
||||
json!(""),
|
||||
json!("a"),
|
||||
json!("c"),
|
||||
json!([]),
|
||||
json!([1, 2]),
|
||||
json!([1, 2, 3]),
|
||||
json!([1, "2"]),
|
||||
json!([[1]]),
|
||||
json!({}),
|
||||
json!({"name": "A"}),
|
||||
json!({"name": "A", "delete": false}),
|
||||
json!({"name": "A", "delete": null}),
|
||||
json!({"name": "A", "other": 1}),
|
||||
json!({"a": null, "b": false}),
|
||||
json!({"a": 5}),
|
||||
json!([{"name": "A"}]),
|
||||
json!([{"name": 1}]),
|
||||
];
|
||||
for ty in types {
|
||||
let schema = one(ty);
|
||||
let written = schema.to_json();
|
||||
conformance::check_schema(&written, "#").unwrap_or_else(|e| panic!("{ty:?}: {e}\n{written:#}"));
|
||||
assert_eq!(written["$schema"], "https://json-schema.org/draft/2020-12/schema");
|
||||
for v in &values {
|
||||
let params = json!({ "x": v });
|
||||
let local = schema.validate(¶ms).is_ok();
|
||||
assert_eq!(local, conformance::instance_valid(&written, ¶ms), "{ty:?} on {v}: {written}");
|
||||
}
|
||||
}
|
||||
for params in [VIEW, EDIT, NULLABLE] {
|
||||
let schema = ParamSchema::new(params, "").deprecated(1);
|
||||
conformance::check_schema(&schema.to_json(), "#").unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// The independent checker refuses what the 2020-12 rules refuse, so its passes above mean
|
||||
/// something.
|
||||
#[test]
|
||||
fn the_2020_12_checker_refuses_invalid_schemas() {
|
||||
for bad in [
|
||||
json!(5),
|
||||
json!({"type": "float"}),
|
||||
json!({"enum": []}),
|
||||
json!({"enum": [1, 1]}),
|
||||
json!({"required": ["a", "a"]}),
|
||||
json!({"required": [1]}),
|
||||
json!({"properties": []}),
|
||||
json!({"properties": {"a": 5}}),
|
||||
json!({"items": 1}),
|
||||
json!({"prefixItems": []}),
|
||||
json!({"anyOf": []}),
|
||||
json!({"minimum": "0"}),
|
||||
json!({"minItems": -1}),
|
||||
json!({"description": 5}),
|
||||
json!({"deprecated": "yes"}),
|
||||
json!({"type": "number", "default": "zero"}),
|
||||
json!({"$schema": "not a uri"}),
|
||||
json!({"minimun": 0}),
|
||||
] {
|
||||
assert!(conformance::check_schema(&bad, "#").is_err(), "{bad}");
|
||||
}
|
||||
assert!(conformance::instance_valid(&json!({"type": "integer"}), &json!(2.0)));
|
||||
assert!(!conformance::instance_valid(&json!({"type": "integer"}), &json!(2.5)));
|
||||
assert!(!conformance::instance_valid(&json!({"prefixItems": [{"type": "number"}], "items": false}), &json!([1, 2])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_and_strings_take_only_their_own_type() {
|
||||
assert!(passes(Type::Boolean, json!(true)) && passes(Type::Boolean, json!(false)));
|
||||
assert!(!passes(Type::Boolean, json!("true")) && !passes(Type::Boolean, json!(1)) && !passes(Type::Boolean, json!(null)));
|
||||
assert!(passes(Type::String, json!("")) && passes(Type::String, json!("View 1")));
|
||||
assert!(!passes(Type::String, json!(5)) && !passes(Type::String, json!(["a"])));
|
||||
assert_eq!(one(Type::Boolean).validate(&json!({"x": "yes"})).unwrap_err().to_string(), "`x` must be true or false, not \"yes\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_enum_takes_one_of_its_names() {
|
||||
let modes = Type::Enum(&["rgb", "cmyk"]);
|
||||
assert!(passes(modes, json!("rgb")) && passes(modes, json!("cmyk")));
|
||||
assert!(!passes(modes, json!("RGB")) && !passes(modes, json!("lab")) && !passes(modes, json!(0)));
|
||||
assert_eq!(one(modes).validate(&json!({"x": "lab"})).unwrap_err().to_string(), "`x` must be one of \"rgb\", \"cmyk\", not \"lab\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_non_negative_integers() {
|
||||
assert!(passes(Type::Id, json!(0)) && passes(Type::Id, json!(42)));
|
||||
assert!(!passes(Type::Id, json!(-1)) && !passes(Type::Id, json!(1.5)) && !passes(Type::Id, json!("42")));
|
||||
assert!(passes(Type::Ids, json!([])) && passes(Type::Ids, json!([3, 4])));
|
||||
assert!(!passes(Type::Ids, json!(3)) && !passes(Type::Ids, json!([3, "4"])) && !passes(Type::Ids, json!([-3])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_and_points_have_their_shapes() {
|
||||
assert!(passes(Type::Color, json!("#ff0000")) && passes(Type::Color, json!([1, 0, 0])) && passes(Type::Color, json!({"gray": 50})));
|
||||
assert!(!passes(Type::Color, json!(5)) && !passes(Type::Color, json!([1, 0])) && !passes(Type::Color, json!([1, 0, "x"])));
|
||||
assert!(passes(Type::Point, json!([0, -2.5])));
|
||||
assert!(!passes(Type::Point, json!([1])) && !passes(Type::Point, json!([1, 2, 3])) && !passes(Type::Point, json!([1, "2"])));
|
||||
assert!(!passes(Type::Point, json!({"x": 1, "y": 2})));
|
||||
}
|
||||
|
||||
const EDIT: &[Param] = &[Param::new("name", Type::String), Param::new("delete", Type::Boolean).defaults_to("false")];
|
||||
|
||||
#[test]
|
||||
fn objects_and_arrays_name_the_path_of_the_value_inside() {
|
||||
let edits = Type::Array(&Type::Object(EDIT));
|
||||
assert!(passes(edits, json!([])) && passes(edits, json!([{"name": "A"}, {"name": "B", "delete": true}])));
|
||||
let err = |v: Value| one(edits).validate(&json!({ "x": v })).unwrap_err();
|
||||
assert_eq!(err(json!([{"name": "A"}, {"name": "B", "delete": "no"}])).path, "x[1].delete");
|
||||
assert_eq!(err(json!([{"name": "A"}, {"delete": true}])).to_string(), "missing `x[1].name`");
|
||||
let unknown = err(json!([{"name": "A", "nmae": "B"}]));
|
||||
assert_eq!((unknown.path.as_str(), unknown.problem), ("x[0].nmae", Problem::Unknown { keys: vec!["name", "delete"] }));
|
||||
assert_eq!(err(json!({"name": "A"})).to_string(), "`x` must be a list, not {\"name\":\"A\"}");
|
||||
assert_eq!(err(json!(["A"])).to_string(), "`x[0]` must be an object, not \"A\"");
|
||||
// Defaults inside are filled in too.
|
||||
let filled = one(edits).validate(&json!({"x": [{"name": "A"}]})).unwrap();
|
||||
assert_eq!(filled, Some(json!({"x": [{"name": "A", "delete": false}]})));
|
||||
}
|
||||
|
||||
const VIEW: &[Param] = &[
|
||||
Param::new("name", Type::String).default_note("\"View N\"").null_omitted(),
|
||||
Param::new("center", Type::Point),
|
||||
Param::new("rotation", Type::Number { min: None, max: None, unit: Some("deg") }).defaults_to("0"),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn keys_requirements_nulls_and_defaults() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
|
||||
// Unknown keys are refused first, naming the key and the keys the command takes.
|
||||
let e = s.validate(&json!({"centre": [0, 0]})).unwrap_err();
|
||||
assert_eq!((e.path.as_str(), e.problem), ("centre", Problem::Unknown { keys: vec!["name", "center", "rotation"] }));
|
||||
assert_eq!(s.validate(&json!({})).unwrap_err(), ParamError { path: "center".into(), problem: Problem::Missing });
|
||||
// Null counts as omitted only where the schema says so.
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2], "name": null, "rotation": 15})).unwrap(), None);
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2], "rotation": null})).unwrap_err().path, "rotation");
|
||||
// An omitted value with a default gets it; one with a note is left to the command.
|
||||
assert_eq!(s.validate(&json!({"center": [1, 2]})).unwrap(), Some(json!({"center": [1, 2], "rotation": 0})));
|
||||
// Params that aren't an object are refused; null params are `{}`.
|
||||
assert_eq!(s.validate(&json!(5)).unwrap_err().to_string(), "the params must be an object, not 5");
|
||||
assert_eq!(s.validate(&Value::Null).unwrap_err().path, "center");
|
||||
let none = ParamSchema::new(&[], "");
|
||||
assert_eq!(none.validate(&Value::Null).unwrap(), None);
|
||||
assert_eq!(none.validate(&json!({"a": 1})).unwrap_err().to_string(), "unknown parameter `a`; the object takes no keys");
|
||||
}
|
||||
|
||||
const LENIENT: &[Param] = &[Param::new("on", Type::Boolean).optional().lenient(), Param::new("size", Type::NUMBER).optional().lenient()];
|
||||
|
||||
#[test]
|
||||
fn lenient_parameters_pass_values_of_another_type_to_the_command() {
|
||||
let s = ParamSchema::new(LENIENT, "");
|
||||
assert_eq!(s.validate(&json!({"on": "yes", "size": "12"})).unwrap(), None);
|
||||
// Unknown keys are still refused.
|
||||
assert!(s.validate(&json!({"of": true})).is_err());
|
||||
// The JSON form states the documented type, so a validator of that form refuses what passes here.
|
||||
let written = s.to_json();
|
||||
assert_eq!(written["properties"]["on"]["x-lenient"], true);
|
||||
assert!(!conformance::instance_valid(&written, &json!({"on": "yes"})));
|
||||
assert!(conformance::instance_valid(&written, &json!({"on": true})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_params_text_lists_each_parameter_with_its_type() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}");
|
||||
assert_eq!(s.params_text(), "{name?: string (default: \"View N\"), center: [x, y], rotation?: number deg (default: 0)} save a view → {name}");
|
||||
const ALL: &[Param] = &[
|
||||
Param::new("a", Type::Integer { min: Some(1), max: Some(25) }),
|
||||
Param::new("b", Type::Enum(&["x", "y"])).optional(),
|
||||
Param::new("c", Type::Ids).optional().doc("instead of the selection"),
|
||||
Param::new("d", Type::Color).optional().deprecated(2),
|
||||
Param::new("e", Type::Array(&Type::Object(EDIT))).optional(),
|
||||
Param::new("f", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(10.0)), unit: Some("pt") }).optional(),
|
||||
];
|
||||
assert_eq!(
|
||||
ParamSchema::new(ALL, "").params_text(),
|
||||
"{a: integer 1 to 25, b?: \"x\"|\"y\", c?: [id…] (instead of the selection), d?: color (deprecated), \
|
||||
e?: [{name: string, delete?: bool (default: false)}…], f?: number > 0 and <= 10 pt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_form_is_an_object_schema() {
|
||||
let s = ParamSchema::new(VIEW, "save a view").returns("{name}").writes(Writes::Document).cost(2);
|
||||
let j = s.to_json();
|
||||
assert_eq!(j["$schema"], DIALECT);
|
||||
assert_eq!(j["type"], "object");
|
||||
assert_eq!(j["additionalProperties"], false);
|
||||
assert_eq!(j["required"], json!(["center"]));
|
||||
assert_eq!(j["properties"]["name"]["anyOf"], json!([{"type": "string"}, {"type": "null"}]));
|
||||
assert_eq!(j["properties"]["name"]["x-default"], "\"View N\"");
|
||||
assert_eq!(j["properties"]["rotation"], json!({"type": "number", "x-unit": "deg", "default": 0, "x-since": 1}));
|
||||
assert_eq!(j["properties"]["center"]["prefixItems"], json!([{"type": "number"}, {"type": "number"}]));
|
||||
assert_eq!((j["x-cost"].clone(), j["x-writes"].clone(), j["x-returns"].clone()), (json!(2), json!("document"), json!("{name}")));
|
||||
let range = one(Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(1.0)), unit: None }).to_json();
|
||||
assert_eq!(range["properties"]["x"], json!({"type": "number", "exclusiveMinimum": 0.0, "maximum": 1.0, "x-since": 1}));
|
||||
}
|
||||
|
||||
/// Null params read as `{}`, so the root of the JSON form allows null exactly when `{}` passes.
|
||||
/// A nested object does not read null as `{}`.
|
||||
#[test]
|
||||
fn the_json_form_allows_null_params_when_no_parameter_is_required() {
|
||||
const OPTIONAL: &[Param] = &[Param::new("inner", Type::Object(&[Param::new("y", Type::Boolean).optional()])).optional()];
|
||||
let optional = ParamSchema::new(OPTIONAL, "");
|
||||
let written = optional.to_json();
|
||||
conformance::check_schema(&written, "#").unwrap();
|
||||
assert_eq!(written["type"], json!(["object", "null"]));
|
||||
assert_eq!(written["properties"]["inner"]["type"], "object");
|
||||
let required = one(Type::Boolean);
|
||||
assert_eq!(required.to_json()["type"], "object");
|
||||
for schema in [optional, required] {
|
||||
let written = schema.to_json();
|
||||
for params in [json!(null), json!({}), json!(5), json!([]), json!("x"), json!({"inner": null}), json!({"inner": {}})] {
|
||||
assert_eq!(schema.validate(¶ms).is_ok(), conformance::instance_valid(&written, ¶ms), "{params} against {written}");
|
||||
}
|
||||
}
|
||||
assert!(optional.validate(&json!(null)).is_ok());
|
||||
assert!(required.validate(&json!(null)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hash_follows_every_schema_but_not_their_order() {
|
||||
let a = ParamSchema::new(VIEW, "save a view");
|
||||
let b = ParamSchema::new(LENIENT, "");
|
||||
let h = hash([("view.a", &a), ("view.b", &b)]);
|
||||
assert_eq!(h, hash([("view.b", &b), ("view.a", &a)]));
|
||||
assert_ne!(h, hash([("view.a", &a)]));
|
||||
assert_ne!(h, hash([("view.a", &a), ("view.c", &b)]));
|
||||
let changed = ParamSchema::new(VIEW, "save a view").since(2);
|
||||
assert_ne!(h, hash([("view.a", &changed), ("view.b", &b)]));
|
||||
}
|
||||
@@ -8,7 +8,6 @@ mod layers;
|
||||
mod profile;
|
||||
mod stroke_style;
|
||||
|
||||
pub(crate) use layers::HEAD_REACH;
|
||||
pub use layers::{Effect, FillLayer, StrokeGradientMode, StrokeLayer, stroke_paint_bounds};
|
||||
pub use profile::{AngleProfile, PressureProfile, ProfilePreset, SavedProfile, WidthProfile};
|
||||
pub use stroke_style::{ArrowAlign, Arrowhead, Dash, LineCap, LineJoin, StrokeAlign};
|
||||
|
||||
@@ -139,10 +139,6 @@ impl StrokeGradientMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far any arrowhead reaches from its tip, in units of its weight (the open arrow's arms are
|
||||
/// cut square a little behind its 4 × 4 box).
|
||||
pub(crate) const HEAD_REACH: f64 = 4.6;
|
||||
|
||||
fn ten() -> f64 {
|
||||
10.0
|
||||
}
|
||||
@@ -188,7 +184,8 @@ impl StrokeLayer {
|
||||
}
|
||||
}
|
||||
/// Weight of the start (`end == false`) or end arrowhead: stroke weight × its scale, at least
|
||||
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide.
|
||||
/// a quarter point. A head of weight `hw` fits a box about `4·hw` long and wide (see
|
||||
/// [`Arrowhead::reach`] and [`Arrowhead::half_width`]).
|
||||
pub fn arrow_weight(&self, end: bool) -> f64 {
|
||||
let pct = if end { self.arrow_scale.1 } else { self.arrow_scale.0 };
|
||||
(self.width * pct / 100.0).max(0.25)
|
||||
@@ -197,7 +194,7 @@ impl StrokeLayer {
|
||||
/// without one): the head's diagonal, or with [`ArrowAlign::Extend`] its length, plus the cap.
|
||||
pub fn head_reach(&self, end: bool) -> f64 {
|
||||
let head = if end { self.end_arrow } else { self.start_arrow };
|
||||
head.map_or(0.0, |_| HEAD_REACH * self.arrow_weight(end) + self.width / 2.0)
|
||||
head.map_or(0.0, |k| k.reach() * self.arrow_weight(end) + self.width / 2.0)
|
||||
}
|
||||
/// How far the arrowheads can reach from the path's end points (0 without heads).
|
||||
pub fn arrow_reach(&self) -> f64 {
|
||||
|
||||
@@ -49,6 +49,10 @@ impl Dash {
|
||||
}
|
||||
}
|
||||
|
||||
/// How far the arrowheads drawn in the 4 × 4 box reach from their tip, in units of their weight
|
||||
/// (the open arrow's arms are cut square a little behind the box).
|
||||
const HEAD_REACH: f64 = 4.6;
|
||||
|
||||
/// An arrowhead shape. Saved by variant name: new shapes are appended, never renamed or reordered.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Arrowhead {
|
||||
@@ -94,13 +98,18 @@ pub enum Arrowhead {
|
||||
Kite,
|
||||
TriangleBar,
|
||||
Oval,
|
||||
/// Two arms of the head's weight (the line's weight at 100 % scale, for lines of 0.25 pt or
|
||||
/// more) meeting at right angles in a sharp tip, cut square at their ends. Twice as wide as
|
||||
/// the other heads.
|
||||
LineArrow,
|
||||
}
|
||||
|
||||
impl Arrowhead {
|
||||
/// Every arrowhead, in menu order (arrows, then outlined shapes, then bars and marks).
|
||||
pub const ALL: [Arrowhead; 40] = [
|
||||
pub const ALL: [Arrowhead; 41] = [
|
||||
Arrowhead::Arrow,
|
||||
Arrowhead::ArrowOpen,
|
||||
Arrowhead::LineArrow,
|
||||
Arrowhead::Barbed,
|
||||
Arrowhead::Concave,
|
||||
Arrowhead::DoubleArrow,
|
||||
@@ -141,6 +150,22 @@ impl Arrowhead {
|
||||
Arrowhead::Fork,
|
||||
];
|
||||
|
||||
/// How far any part of the head can be from its tip, in units of its weight.
|
||||
pub fn reach(self) -> f64 {
|
||||
match self {
|
||||
// Its farthest corners are 4 + ½√2 back and 4 − ½√2 out.
|
||||
Arrowhead::LineArrow => 5.75,
|
||||
_ => HEAD_REACH,
|
||||
}
|
||||
}
|
||||
/// How far the head spreads either side of its line, in units of its weight.
|
||||
pub fn half_width(self) -> f64 {
|
||||
match self {
|
||||
Arrowhead::LineArrow => 4.0,
|
||||
_ => 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The name the arrowhead menus show.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
@@ -184,6 +209,7 @@ impl Arrowhead {
|
||||
Arrowhead::Kite => "Kite",
|
||||
Arrowhead::TriangleBar => "Triangle to Bar",
|
||||
Arrowhead::Oval => "Oval",
|
||||
Arrowhead::LineArrow => "Line Arrow",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ fn profile_presets_round_trip_their_ids() {
|
||||
#[test]
|
||||
fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
|
||||
// Saved files store these names: they may only ever be appended to.
|
||||
const SAVED: [&str; 40] = [
|
||||
const SAVED: [&str; 41] = [
|
||||
"Triangle",
|
||||
"TriangleOpen",
|
||||
"Circle",
|
||||
@@ -260,6 +260,7 @@ fn arrowhead_names_are_stable_and_every_one_is_listed_once() {
|
||||
"Kite",
|
||||
"TriangleBar",
|
||||
"Oval",
|
||||
"LineArrow",
|
||||
];
|
||||
for name in SAVED {
|
||||
let a: Arrowhead = serde_json::from_value(serde_json::json!(name)).unwrap();
|
||||
@@ -293,9 +294,15 @@ fn outset_covers_arrowheads() {
|
||||
st.arrow_scale = (100.0, 200.0);
|
||||
assert_eq!(st.arrow_weight(false), 4.0);
|
||||
assert_eq!(st.arrow_weight(true), 8.0);
|
||||
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, HEAD_REACH * 8.0 + 2.0));
|
||||
let reach = Arrowhead::Triangle.reach();
|
||||
assert_eq!((st.head_reach(false), st.head_reach(true)), (0.0, reach * 8.0 + 2.0));
|
||||
// A 32 pt head whose tip sits up to its length (plus the cap) past the end point.
|
||||
assert_eq!(a.outset(), HEAD_REACH * 8.0 + 2.0);
|
||||
assert_eq!(a.outset(), reach * 8.0 + 2.0);
|
||||
// The line arrow's arms spread twice as wide, so it reaches further from its tip.
|
||||
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::LineArrow);
|
||||
assert!(Arrowhead::LineArrow.reach() > reach);
|
||||
assert_eq!(a.outset(), Arrowhead::LineArrow.reach() * 8.0 + 2.0);
|
||||
a.stroke_mut().unwrap().end_arrow = Some(Arrowhead::Triangle);
|
||||
a.stroke_mut().unwrap().width = 0.01;
|
||||
assert_eq!(a.stroke().unwrap().arrow_weight(true), 0.25, "heads keep a minimum size");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, Deserialize, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip};
|
||||
use super::{Arc, Deserialize, LayerColor, Node, NodeId, NodeKind, Rect, Serialize, SymbolBoxes, skip};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Artboard {
|
||||
@@ -39,6 +39,18 @@ pub struct Artboard {
|
||||
/// it behind the art ([`super::Document::with_artboard_fills`]).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color: Option<vectorcraft_color::Color>,
|
||||
/// Guide label (Artboard Options): the color the canvas draws the artboard's own ruler guides
|
||||
/// in (a guide whose [`super::Guide::artboard`] is this artboard's id), a [`LayerColor`] preset
|
||||
/// or a custom RGB. None (No label) draws them in the guide color preference, as canvas guides
|
||||
/// are. Only the canvas uses it: the paper, exports and print don't. Written only when set; a
|
||||
/// file's preset outside [`super::LAYER_COLORS`] reads as no label.
|
||||
#[serde(default, deserialize_with = "label_within_palette", skip_serializing_if = "Option::is_none")]
|
||||
pub guide_label: Option<LayerColor>,
|
||||
}
|
||||
|
||||
/// A file's guide label: a preset index outside [`super::LAYER_COLORS`] reads as no label.
|
||||
fn label_within_palette<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<LayerColor>, D::Error> {
|
||||
Ok(Option::<LayerColor>::deserialize(d)?.filter(|c| !matches!(c, LayerColor::Preset(i) if usize::from(*i) >= super::LAYER_COLORS.len())))
|
||||
}
|
||||
|
||||
/// The Video Ruler Pixel Aspect Ratios an artboard takes (Artboard Options): from a pixel ten times
|
||||
|
||||
@@ -750,6 +750,7 @@ fn lerp_text(o: &mut TextObject, x: &TextObject, y: &TextObject, t: f64, tc: f64
|
||||
if resized {
|
||||
o.cached_bounds = None;
|
||||
o.cached_baselines.clear();
|
||||
o.cached_ink = None;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+436
-15
@@ -1,7 +1,7 @@
|
||||
//! Persistent dimension recipes. Children are ordinary paths and type for all renderers/exporters.
|
||||
use crate::{Document, NodeId, Unit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use vectorcraft_geom::{Affine, Point, Vec2};
|
||||
use vectorcraft_geom::{Affine, Anchor, PathData, Point, Vec2};
|
||||
|
||||
/// What a dimension measures.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -25,6 +25,17 @@ impl DimensionMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction a linear dimension measures in: along the line through its two points, or only
|
||||
/// their horizontal or vertical distance.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Orientation {
|
||||
#[default]
|
||||
Aligned,
|
||||
Horizontal,
|
||||
Vertical,
|
||||
}
|
||||
|
||||
/// The unit a length is labelled in.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -295,6 +306,12 @@ pub enum DimensionSource {
|
||||
a: Option<PointRef>,
|
||||
b: Option<PointRef>,
|
||||
vertex: Option<PointRef>,
|
||||
/// The first point when it is not an anchor, held against a path's anchors.
|
||||
#[serde(default, rename = "freeA", skip_serializing_if = "Option::is_none")]
|
||||
free_a: Option<FreePoint>,
|
||||
/// The second point when it is not an anchor.
|
||||
#[serde(default, rename = "freeB", skip_serializing_if = "Option::is_none")]
|
||||
free_b: Option<FreePoint>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -304,7 +321,11 @@ impl DimensionSource {
|
||||
Self::Segment { edge } | Self::Arc { edge } => vec![edge.node],
|
||||
Self::Angle { first, second } => vec![first.node, second.node],
|
||||
Self::Circle { node } => vec![*node],
|
||||
Self::Points { a, b, vertex } => [a, b, vertex].into_iter().filter_map(|p| p.as_ref().map(|r| r.node)).collect(),
|
||||
Self::Points { a, b, vertex, free_a, free_b } => [a, b, vertex]
|
||||
.into_iter()
|
||||
.filter_map(|p| p.as_ref().map(|r| r.node))
|
||||
.chain([free_a, free_b].into_iter().filter_map(|p| p.as_ref().map(FreePoint::node)))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
/// Anchor indexes cannot safely follow a topology replacement. Ordinary moves and handle
|
||||
@@ -321,6 +342,34 @@ impl DimensionSource {
|
||||
old.is_some() && old != counts(after)
|
||||
})
|
||||
}
|
||||
/// The map that took the measured art from `before` to `after`, when every path it measures
|
||||
/// went through one [`art_transform`] that reflects it (a negative determinant). None when a
|
||||
/// measured path is unchanged, or when two of them went through different maps.
|
||||
pub fn reflection(&self, before: &Document, after: &Document) -> Option<Affine> {
|
||||
let mut first: Option<Affine> = None;
|
||||
for id in self.nodes() {
|
||||
let (old, new) = (before.node(id)?, after.node(id)?);
|
||||
if std::ptr::eq(old, new) || old.path_data() == new.path_data() {
|
||||
return None;
|
||||
}
|
||||
let xf = fitted(before, after, id, |xf| xf.determinant() < 0.0)?;
|
||||
match first {
|
||||
None => first = Some(xf),
|
||||
Some(f) => {
|
||||
// The linear parts and the translations, each against its own size.
|
||||
let (a, b) = (f.as_coeffs(), xf.as_coeffs());
|
||||
let differ = |range: std::ops::Range<usize>, tolerance: f64| {
|
||||
let size = a[range.clone()].iter().map(|c| c.abs()).fold(1.0, f64::max);
|
||||
a[range.clone()].iter().zip(&b[range]).any(|(p, q)| (p - q).abs() > tolerance * size)
|
||||
};
|
||||
if differ(0..4, 1e-9) || differ(4..6, 1e-9) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
first
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -337,6 +386,235 @@ impl PointRef {
|
||||
}
|
||||
}
|
||||
|
||||
/// The point of an anchor that an [`AnchorWeight`] names: the anchor, or one of its handles.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AnchorPart {
|
||||
#[default]
|
||||
Point,
|
||||
In,
|
||||
Out,
|
||||
}
|
||||
|
||||
impl AnchorPart {
|
||||
fn of(self, a: &Anchor) -> Point {
|
||||
match self {
|
||||
Self::Point => a.p,
|
||||
Self::In => a.h_in,
|
||||
Self::Out => a.h_out,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One anchor's share of a [`FreePoint`].
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AnchorWeight {
|
||||
pub subpath: usize,
|
||||
pub anchor: usize,
|
||||
/// The anchor itself, or its in or out handle.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub part: AnchorPart,
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
/// A measured point that is not an anchor (a point on a segment, or a point a Smart Guide gave),
|
||||
/// linked to one path so that it moves with it.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FreePoint {
|
||||
/// A point on a segment at curve parameter `t`, 0 at the segment's first anchor and 1 at its
|
||||
/// last. It stays on the segment when the segment's anchors or handles are edited.
|
||||
OnSegment { edge: SegmentRef, t: f64 },
|
||||
/// A weighted sum of two or three anchors or handles of one path, the weights adding up to 1,
|
||||
/// plus `across` times the vector from the first anchor to the second turned a quarter turn,
|
||||
/// from (x, y) to (-y, x), so that a vector pointing right in the y-down document space
|
||||
/// turns to point down (two anchors only). When the
|
||||
/// path is moved, scaled, rotated, reflected or sheared, a point held by three moves with
|
||||
/// it. A point held by two and `across`, beside a straight path, moves, turns and scales
|
||||
/// uniformly with it, and a reflection or a non-uniform scale moves it as a half turn or a
|
||||
/// uniform scale would.
|
||||
Anchors {
|
||||
node: NodeId,
|
||||
anchors: Vec<AnchorWeight>,
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
across: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A point of a path: (subpath, anchor, part, place).
|
||||
type PathPoint = (usize, usize, AnchorPart, Point);
|
||||
|
||||
/// Every anchor of `path` in order, each followed by its in and out handles when `handles` is set.
|
||||
fn path_points(path: &PathData, handles: bool) -> impl Iterator<Item = PathPoint> + Clone + '_ {
|
||||
let parts: &'static [AnchorPart] = if handles { &[AnchorPart::Point, AnchorPart::In, AnchorPart::Out] } else { &[AnchorPart::Point] };
|
||||
path.subpaths
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(move |(si, sp)| sp.anchors.iter().enumerate().flat_map(move |(ai, a)| parts.iter().map(move |&part| (si, ai, part, part.of(a)))))
|
||||
}
|
||||
|
||||
/// Three points that span `points`: the first, the one farthest from it, and the one farthest
|
||||
/// from the line through those two. None when every point is in one place.
|
||||
fn spanning(points: impl Iterator<Item = PathPoint> + Clone) -> Option<[PathPoint; 3]> {
|
||||
let first = points.clone().next()?;
|
||||
let o = first.3;
|
||||
let second = points.clone().max_by(|x, y| x.3.distance(o).total_cmp(&y.3.distance(o)))?;
|
||||
let u = second.3 - o;
|
||||
let span = u.hypot();
|
||||
if !span.is_finite() || span <= 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let third = points.max_by(|x, y| u.cross(x.3 - o).abs().total_cmp(&u.cross(y.3 - o).abs()))?;
|
||||
Some([first, second, third])
|
||||
}
|
||||
|
||||
/// The map that takes the anchors and handles of path `node` in `before` to where they are in
|
||||
/// `after`, when one affine map does that for every one of them: the path was moved, scaled,
|
||||
/// rotated, reflected or sheared as a whole. None when its anchors changed in number, moved in
|
||||
/// another way, or lie on one line with all their handles, as on a straight line: a reflection
|
||||
/// across a line through such a path puts its points where a turn can put them too.
|
||||
pub fn art_transform(before: &Document, after: &Document, node: NodeId) -> Option<Affine> {
|
||||
fitted(before, after, node, |_| true)
|
||||
}
|
||||
|
||||
/// [`art_transform`] when the map passes `wanted`. The map is found from three points, and
|
||||
/// `wanted` is asked before every other point is checked against it.
|
||||
fn fitted(before: &Document, after: &Document, node: NodeId, wanted: impl Fn(&Affine) -> bool) -> Option<Affine> {
|
||||
let (old, new) = (before.node(node)?.path_data()?, after.node(node)?.path_data()?);
|
||||
if old.subpaths.len() != new.subpaths.len() || old.subpaths.iter().zip(&new.subpaths).any(|(o, n)| o.anchors.len() != n.anchors.len()) {
|
||||
return None;
|
||||
}
|
||||
let [o, f, g] = spanning(path_points(old, true))?;
|
||||
let place = |q: PathPoint| new.subpaths.get(q.0).and_then(|sp| sp.anchors.get(q.1)).map(|a| q.2.of(a));
|
||||
let (o2, f2, g2) = (place(o)?, place(f)?, place(g)?);
|
||||
let (u, v) = (f.3 - o.3, g.3 - o.3);
|
||||
let span = u.hypot();
|
||||
if u.cross(v).abs() <= 1e-9 * span * span {
|
||||
return None;
|
||||
}
|
||||
let basis = |u: Vec2, v: Vec2| Affine::new([u.x, u.y, v.x, v.y, 0.0, 0.0]);
|
||||
let linear = basis(f2 - o2, g2 - o2) * basis(u, v).inverse();
|
||||
let xf = Affine::translate(o2.to_vec2()) * linear * Affine::translate(-o.3.to_vec2());
|
||||
if !xf.as_coeffs().iter().all(|c| c.is_finite()) || !wanted(&xf) {
|
||||
return None;
|
||||
}
|
||||
let tolerance = 1e-6 * span.max(1.0);
|
||||
path_points(old, true).zip(path_points(new, true)).all(|(o, n)| (xf * o.3).distance(n.3) <= tolerance).then_some(xf)
|
||||
}
|
||||
|
||||
impl FreePoint {
|
||||
/// Largest weight: a point far outside the anchors that hold it is not held.
|
||||
const MAX_WEIGHT: f64 = 1e4;
|
||||
|
||||
/// The path it is linked to.
|
||||
pub fn node(&self) -> NodeId {
|
||||
match self {
|
||||
Self::OnSegment { edge, .. } => edge.node,
|
||||
Self::Anchors { node, .. } => *node,
|
||||
}
|
||||
}
|
||||
|
||||
/// `p` held against path `node`: three anchors that span the path; when all its anchors lie
|
||||
/// on one line or in one place, as on an arch, three of its anchors and handles; when its
|
||||
/// handles lie on that line too, or so nearly that the weights would be too large, two anchors
|
||||
/// and `p`'s distance across that line. None when none of these holds `p`.
|
||||
pub fn on(doc: &Document, node: NodeId, p: Point) -> Option<Self> {
|
||||
let path = doc.node(node)?.path_data()?;
|
||||
let weight = |(subpath, anchor, part, _): PathPoint, weight| AnchorWeight { subpath, anchor, part, weight };
|
||||
for handles in [false, true] {
|
||||
let Some([o, f, g]) = spanning(path_points(path, handles)) else { continue };
|
||||
let (u, v) = (f.3 - o.3, g.3 - o.3);
|
||||
let span = u.hypot();
|
||||
let det = u.cross(v);
|
||||
if det.abs() > 1e-9 * span * span {
|
||||
let s = (p - o.3).cross(v) / det;
|
||||
let t = u.cross(p - o.3) / det;
|
||||
let anchors = vec![weight(o, 1.0 - s - t), weight(f, s), weight(g, t)];
|
||||
// A nearly flat set of points gives weights too large to keep: the next fit is tried.
|
||||
if let Some(held) = (Self::Anchors { node, anchors, across: 0.0 }).holding(doc, p, span) {
|
||||
return Some(held);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Every anchor and handle on one line, or nearly: along it from the first anchor, and across it.
|
||||
let [o, f, _] = spanning(path_points(path, false))?;
|
||||
let u = f.3 - o.3;
|
||||
let span = u.hypot();
|
||||
let (t, across) = ((p - o.3).dot(u) / (span * span), u.cross(p - o.3) / (span * span));
|
||||
Self::Anchors { node, anchors: vec![weight(o, 1.0 - t), weight(f, t)], across }.holding(doc, p, span)
|
||||
}
|
||||
|
||||
/// `p` on the segment `edge`, at the curve parameter of the segment's point nearest to it.
|
||||
/// None when the segment is gone or `p` is not on it.
|
||||
pub fn on_segment(doc: &Document, edge: &SegmentRef, p: Point) -> Option<Self> {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
|
||||
if edge.segment >= sp.segment_count() {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = edge.endpoints(doc)?;
|
||||
let t = sp.segment(edge.segment).nearest(p, 1e-12).t;
|
||||
Self::OnSegment { edge: edge.clone(), t }.holding(doc, p, a.distance(b))
|
||||
}
|
||||
|
||||
/// Itself when it is [`Self::valid`] and puts the point at `p`, within a tolerance that grows
|
||||
/// with `span`, the size of the art that holds it.
|
||||
fn holding(self, doc: &Document, p: Point, span: f64) -> Option<Self> {
|
||||
let at = self.position(doc)?;
|
||||
(at.distance(p) <= 1e-6 * span.max(1.0)).then_some(self)
|
||||
}
|
||||
|
||||
/// Whether its numbers can be used: a finite `t` from 0 to 1 on a segment; or two or three
|
||||
/// weights that add up to 1, with `across` set only for two, each finite and at most
|
||||
/// [`Self::MAX_WEIGHT`] across.
|
||||
pub fn valid(&self) -> bool {
|
||||
match self {
|
||||
Self::OnSegment { t, .. } => (0.0..=1.0).contains(t),
|
||||
Self::Anchors { anchors, across, .. } => {
|
||||
(2..=3).contains(&anchors.len())
|
||||
&& anchors.iter().all(|w| w.weight.is_finite() && w.weight.abs() <= Self::MAX_WEIGHT)
|
||||
&& (anchors.iter().map(|w| w.weight).sum::<f64>() - 1.0).abs() <= 1e-9
|
||||
&& across.is_finite()
|
||||
&& across.abs() <= Self::MAX_WEIGHT
|
||||
&& (*across == 0.0 || anchors.len() == 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the point is now. None when its segment or an anchor is gone or the point is not
|
||||
/// [`Self::valid`].
|
||||
pub fn position(&self, doc: &Document) -> Option<Point> {
|
||||
use vectorcraft_geom::kurbo::ParamCurve;
|
||||
if !self.valid() {
|
||||
return None;
|
||||
}
|
||||
let at = match self {
|
||||
Self::OnSegment { edge, t } => {
|
||||
let sp = doc.node(edge.node)?.path_data()?.subpaths.get(edge.subpath)?;
|
||||
if edge.segment >= sp.segment_count() {
|
||||
return None;
|
||||
}
|
||||
sp.segment(edge.segment).eval(*t)
|
||||
}
|
||||
Self::Anchors { node, anchors, across } => {
|
||||
let path = doc.node(*node)?.path_data()?;
|
||||
let place = |w: &AnchorWeight| path.subpaths.get(w.subpath).and_then(|sp| sp.anchors.get(w.anchor)).map(|a| w.part.of(a).to_vec2());
|
||||
let mut at = Vec2::ZERO;
|
||||
for w in anchors {
|
||||
at += place(w)? * w.weight;
|
||||
}
|
||||
if *across != 0.0 {
|
||||
let u = place(anchors.get(1)?)? - place(anchors.first()?)?;
|
||||
at += Vec2::new(-u.y, u.x) * *across;
|
||||
}
|
||||
at.to_point()
|
||||
}
|
||||
};
|
||||
(at.x.is_finite() && at.y.is_finite()).then_some(at)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Dimension {
|
||||
@@ -349,6 +627,10 @@ pub struct Dimension {
|
||||
pub length: f64,
|
||||
pub options: DimensionOptions,
|
||||
pub source: Option<DimensionSource>,
|
||||
/// What a linear dimension measures: the distance between its points (aligned), or only its
|
||||
/// horizontal or vertical part. Other modes ignore it.
|
||||
#[serde(default, skip_serializing_if = "crate::skip::is_default")]
|
||||
pub orientation: Orientation,
|
||||
/// How far a linked dimension was moved on its own since the last refresh: the refresh turns
|
||||
/// it into a new placement unless the measured art moved too.
|
||||
#[serde(skip)]
|
||||
@@ -359,16 +641,137 @@ impl Dimension {
|
||||
/// A point on the drawn dimension line (linear), arc (angular) or radius line.
|
||||
pub fn handle(&self) -> Point {
|
||||
match self.mode {
|
||||
DimensionMode::Linear => self.a.midpoint(self.b) + self.normal() * self.offset,
|
||||
DimensionMode::Linear => {
|
||||
let (a, b) = self.line_ends();
|
||||
a.midpoint(b)
|
||||
}
|
||||
DimensionMode::Angular => self.vertex + self.bisector() * self.offset.abs().max(12.0),
|
||||
DimensionMode::Radius | DimensionMode::Diameter => self.b,
|
||||
}
|
||||
}
|
||||
/// The left normal of a → b.
|
||||
/// The unit direction a linear dimension measures along: a → b when aligned, else the x axis
|
||||
/// (horizontal) or the y axis (vertical), whichever way the points lie.
|
||||
pub fn direction(&self) -> Vec2 {
|
||||
match self.orientation {
|
||||
Orientation::Horizontal if self.mode == DimensionMode::Linear => Vec2::new(1.0, 0.0),
|
||||
Orientation::Vertical if self.mode == DimensionMode::Linear => Vec2::new(0.0, 1.0),
|
||||
_ => {
|
||||
let u = self.b - self.a;
|
||||
let len = u.hypot();
|
||||
if len > 1e-9 { u / len } else { Vec2::new(1.0, 0.0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The left normal of [`Self::direction`].
|
||||
pub fn normal(&self) -> Vec2 {
|
||||
let u = self.b - self.a;
|
||||
let len = u.hypot();
|
||||
if len > 1e-9 { Vec2::new(-u.y, u.x) / len } else { Vec2::new(0.0, 1.0) }
|
||||
let u = self.direction();
|
||||
Vec2::new(-u.y, u.x)
|
||||
}
|
||||
/// The length a linear dimension shows: the distance between its points along
|
||||
/// [`Self::direction`].
|
||||
pub fn measured(&self) -> f64 {
|
||||
(self.b - self.a).dot(self.direction()).abs()
|
||||
}
|
||||
/// The two ends of a linear dimension's line: the measured points moved onto one line along
|
||||
/// [`Self::direction`], `offset` from a along the normal.
|
||||
pub fn line_ends(&self) -> (Point, Point) {
|
||||
let (u, n) = (self.direction(), self.normal());
|
||||
let a = self.a + n * self.offset;
|
||||
(a, a + u * (self.b - self.a).dot(u))
|
||||
}
|
||||
/// A horizontal or vertical dimension whose points moved from `old`'s keeps its line as far
|
||||
/// outside them as it was, on the same side: the gap is measured from the outermost point
|
||||
/// on the line's side, so the line stays clear of art that turns. A line that lay between
|
||||
/// the two points keeps its distance from the first point.
|
||||
pub fn keep_gap(&mut self, old: &Dimension) {
|
||||
if self.mode != DimensionMode::Linear
|
||||
|| self.orientation == Orientation::Aligned
|
||||
|| old.orientation != self.orientation
|
||||
|| (self.a, self.b) == (old.a, old.b)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let n = self.normal();
|
||||
let across = |p: Point| p.to_vec2().dot(n);
|
||||
let line = across(old.a) + old.offset;
|
||||
let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b)));
|
||||
let (new_low, new_high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b)));
|
||||
let at = if line >= high {
|
||||
new_high + (line - high)
|
||||
} else if line <= low {
|
||||
new_low + (line - low)
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
let offset = at - across(self.a);
|
||||
if offset.is_finite() && offset.abs() <= 1e6 {
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
/// A linear dimension whose art was reflected, its points now moved from `old`'s: its line goes
|
||||
/// to the side of the points that `image` (where the reflection put `old`'s line) is on, as far
|
||||
/// past the outermost point on that side as `old`'s line was. A line that lay between the two
|
||||
/// points is left as it is.
|
||||
pub fn mirror_line(&mut self, old: &Dimension, image: Point) {
|
||||
if self.mode != DimensionMode::Linear || old.mode != DimensionMode::Linear {
|
||||
return;
|
||||
}
|
||||
let gap = {
|
||||
let n = old.normal();
|
||||
let across = |p: Point| p.to_vec2().dot(n);
|
||||
let line = across(old.a) + old.offset;
|
||||
let (low, high) = (across(old.a).min(across(old.b)), across(old.a).max(across(old.b)));
|
||||
if line >= high {
|
||||
line - high
|
||||
} else if line <= low {
|
||||
low - line
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let n = self.normal();
|
||||
let across = |p: Point| p.to_vec2().dot(n);
|
||||
let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b)));
|
||||
let at = if 2.0 * across(image) >= low + high { high + gap } else { low - gap };
|
||||
let offset = at - across(self.a);
|
||||
if offset.is_finite() && offset.abs() <= 1e6 {
|
||||
self.offset = offset;
|
||||
}
|
||||
}
|
||||
/// Whether a linear dimension's line lies strictly between its two points along the normal.
|
||||
fn line_between_points(&self) -> bool {
|
||||
let n = self.normal();
|
||||
let across = |p: Point| p.to_vec2().dot(n);
|
||||
let line = across(self.a) + self.offset;
|
||||
across(self.a).min(across(self.b)) < line && line < across(self.a).max(across(self.b))
|
||||
}
|
||||
/// Makes a linear dimension measure in `orientation`, with its line on the side of the points
|
||||
/// it was on. The line goes through the middle of the old line when that point lies outside
|
||||
/// the two points along the new normal; otherwise it lies as far past the outermost point on
|
||||
/// that side as the old line lay from its points.
|
||||
pub fn set_orientation(&mut self, orientation: Orientation) {
|
||||
if self.mode != DimensionMode::Linear || self.orientation == orientation {
|
||||
self.orientation = orientation;
|
||||
return;
|
||||
}
|
||||
let (handle, gap) = (self.handle(), self.offset.abs());
|
||||
self.orientation = orientation;
|
||||
let n = self.normal();
|
||||
let across = |p: Point| p.to_vec2().dot(n);
|
||||
let (low, high) = (across(self.a).min(across(self.b)), across(self.a).max(across(self.b)));
|
||||
let h = across(handle);
|
||||
let at = if h >= high || h <= low {
|
||||
h
|
||||
} else if 2.0 * h >= low + high {
|
||||
high + gap
|
||||
} else {
|
||||
low - gap
|
||||
};
|
||||
let offset = at - across(self.a);
|
||||
if offset.is_finite() && offset.abs() <= 1e6 {
|
||||
self.offset = offset;
|
||||
}
|
||||
self.length = self.measured();
|
||||
}
|
||||
fn bisector(&self) -> Vec2 {
|
||||
let start = (self.a - self.vertex).atan2();
|
||||
@@ -397,28 +800,46 @@ impl Dimension {
|
||||
self.moved = Some((xf * at) - at + self.moved.unwrap_or_default());
|
||||
return;
|
||||
}
|
||||
// A horizontal or vertical dimension keeps measuring along its axis, between the points
|
||||
// where they now are, and its line goes through where the transform puts it. When that
|
||||
// puts the line between the points (a quarter turn), the line keeps its gap from them.
|
||||
let before = (self.mode == DimensionMode::Linear && self.orientation != Orientation::Aligned).then(|| (self.clone(), xf * self.handle()));
|
||||
self.a = xf * self.a;
|
||||
self.b = xf * self.b;
|
||||
self.vertex = xf * self.vertex;
|
||||
if let Some((old, p)) = before {
|
||||
let offset = (p - self.a).dot(self.normal());
|
||||
if offset.is_finite() && offset.abs() <= 1e6 {
|
||||
self.offset = offset;
|
||||
}
|
||||
if self.line_between_points() && !old.line_between_points() {
|
||||
self.keep_gap(&old);
|
||||
}
|
||||
}
|
||||
if self.mode == DimensionMode::Linear {
|
||||
self.length = self.a.distance(self.b);
|
||||
self.length = self.measured();
|
||||
}
|
||||
}
|
||||
pub fn resolve(&mut self, doc: &Document) -> Result<(), String> {
|
||||
match &self.source {
|
||||
Some(DimensionSource::Points { a, b, vertex }) => {
|
||||
for (reference, point) in [(a, &mut self.a), (b, &mut self.b), (vertex, &mut self.vertex)] {
|
||||
if let Some(r) = reference {
|
||||
*point = r.position(doc).ok_or("Measured anchor no longer exists")?;
|
||||
Some(DimensionSource::Points { a, b, vertex, free_a, free_b }) => {
|
||||
for (reference, free, point) in [(a, free_a, &mut self.a), (b, free_b, &mut self.b), (vertex, &None, &mut self.vertex)] {
|
||||
match (reference, free) {
|
||||
// An end is an anchor or a free point, never both.
|
||||
(Some(_), Some(_)) => return Err("Invalid measured point".into()),
|
||||
(Some(r), None) => *point = r.position(doc).ok_or("Measured anchor no longer exists")?,
|
||||
(None, Some(f)) if !f.valid() => return Err("Invalid measured point".into()),
|
||||
(None, Some(f)) => *point = f.position(doc).ok_or("Measured anchor no longer exists")?,
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
self.length = self.a.distance(self.b);
|
||||
self.length = if self.mode == DimensionMode::Linear { self.measured() } else { self.a.distance(self.b) };
|
||||
}
|
||||
Some(DimensionSource::Segment { edge }) => {
|
||||
let (a, b) = edge.endpoints(doc).ok_or("Measured segment no longer exists")?;
|
||||
self.a = a;
|
||||
self.b = b;
|
||||
self.length = a.distance(b);
|
||||
self.length = self.measured();
|
||||
}
|
||||
Some(DimensionSource::Angle { first, second }) => {
|
||||
for edge in [first, second] {
|
||||
@@ -486,7 +907,7 @@ impl Dimension {
|
||||
DimensionMode::Angular => self.angle().abs().to_degrees(),
|
||||
DimensionMode::Radius => self.a.distance(self.b),
|
||||
DimensionMode::Diameter => self.a.distance(self.b) * 2.0,
|
||||
DimensionMode::Linear => self.a.distance(self.b),
|
||||
DimensionMode::Linear => self.measured(),
|
||||
};
|
||||
self.options.label(value, self.mode == DimensionMode::Angular)
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ impl Document {
|
||||
dimensions,
|
||||
retired_artboard_id: _,
|
||||
opened_artboard: _,
|
||||
units_from_file: _,
|
||||
index: _,
|
||||
} = self;
|
||||
*version == other.version
|
||||
@@ -208,6 +209,7 @@ impl Document {
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
}],
|
||||
layers: vec![],
|
||||
swatches: swatches.into(),
|
||||
@@ -256,6 +258,7 @@ impl Document {
|
||||
dimensions: false,
|
||||
retired_artboard_id: Default::default(),
|
||||
opened_artboard: None,
|
||||
units_from_file: false,
|
||||
index: Default::default(),
|
||||
};
|
||||
let id = d.alloc_id();
|
||||
@@ -276,6 +279,11 @@ impl Document {
|
||||
self.next_id
|
||||
}
|
||||
|
||||
/// Makes the next id at least `id`, so ids below it are not given out again.
|
||||
pub fn raise_next_id(&mut self, id: u64) {
|
||||
self.next_id = self.next_id.max(id);
|
||||
}
|
||||
|
||||
/// Ensure `next_id` is above every id in the tree (after deserializing foreign data).
|
||||
pub fn fix_next_id(&mut self) {
|
||||
// Ids from [`MAX_NEXT_ID`] up (a damaged or hand-made file: #1718) are left out, as is a
|
||||
|
||||
@@ -123,18 +123,33 @@ impl Document {
|
||||
std::borrow::Cow::Owned(d)
|
||||
}
|
||||
|
||||
/// Does `wants` hold for a node anywhere in the document's art: the layers, symbol definitions,
|
||||
/// graph designs, pattern swatches (the one being edited included) and their opacity masks?
|
||||
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
|
||||
let roots = self
|
||||
.layers
|
||||
/// The roots of every tree of the document's art: the layers, symbol definitions, graph designs,
|
||||
/// pattern swatches and the original of the pattern being edited. Opacity masks hang off nodes
|
||||
/// in these trees.
|
||||
pub fn art_roots(&self) -> impl Iterator<Item = &Arc<Node>> {
|
||||
self.layers
|
||||
.iter()
|
||||
.chain(self.symbols.iter().map(|s| &s.art))
|
||||
.chain(self.graph_designs.iter().map(|g| &g.art))
|
||||
.chain(self.patterns.iter().flat_map(|p| p.art.iter()))
|
||||
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()));
|
||||
.chain(self.pattern_edit.iter().filter_map(|pe| pe.original.as_ref()).flat_map(|p| p.art.iter()))
|
||||
}
|
||||
|
||||
/// [`Self::art_roots`], to change.
|
||||
pub fn art_roots_mut(&mut self) -> impl Iterator<Item = &mut Arc<Node>> {
|
||||
self.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()))
|
||||
}
|
||||
|
||||
/// Does `wants` hold for a node anywhere in the document's art ([`Self::art_roots`]) or its
|
||||
/// opacity masks?
|
||||
pub fn any_node(&self, wants: impl Fn(&Node) -> bool) -> bool {
|
||||
let mut any = false;
|
||||
for root in roots {
|
||||
for root in self.art_roots() {
|
||||
crate::swatches::walk_with_masks(root, &mut |n| any |= wants(n));
|
||||
}
|
||||
any
|
||||
@@ -144,14 +159,7 @@ impl Document {
|
||||
/// [`Self::any_node`] looks). Only the trees that hold such a node are copied out of the art
|
||||
/// they share.
|
||||
pub fn edit_nodes_where(&mut self, wants: impl Fn(&Node) -> bool, mut f: impl FnMut(&mut Node)) {
|
||||
let roots = self
|
||||
.layers
|
||||
.iter_mut()
|
||||
.chain(self.symbols.iter_mut().map(|s| &mut s.art))
|
||||
.chain(self.graph_designs.iter_mut().map(|g| &mut g.art))
|
||||
.chain(self.patterns.iter_mut().flat_map(|p| p.art.iter_mut()))
|
||||
.chain(self.pattern_edit.iter_mut().filter_map(|pe| pe.original.as_mut()).flat_map(|p| p.art.iter_mut()));
|
||||
for root in roots {
|
||||
for root in self.art_roots_mut() {
|
||||
edit_branches_where(root, &wants, &mut f);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -347,6 +347,7 @@ impl Document {
|
||||
t.first_para_continues = false;
|
||||
t.cached_bounds = None;
|
||||
t.cached_baselines.clear();
|
||||
t.cached_ink = None;
|
||||
}
|
||||
let on_path = n.extras().objects_on_path.is_some();
|
||||
let mut renamed = std::collections::HashMap::new();
|
||||
|
||||
+24
-4
@@ -136,9 +136,11 @@ impl crate::Document {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character style a graph's labels keep through regeneration once formatted (Format the text in graphs): the
|
||||
/// value axis numbers, the category labels (category axis, radar spokes, pie names) and the legend labels (beside or
|
||||
/// across the top, or in pie wedges). `None` draws the default 9 pt black.
|
||||
/// The character style and anti-aliasing method a graph's labels keep through regeneration once formatted (Format
|
||||
/// the text in graphs): the value axis numbers, the category labels (category axis, radar spokes, pie names) and the
|
||||
/// legend labels (beside or across the top, or in pie wedges). `None` draws the default 9 pt black. The `*_para`
|
||||
/// fields are the paragraph attributes each kind keeps the same way (`None`: the defaults); the graph still sets each
|
||||
/// label's alignment.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LabelStyles {
|
||||
@@ -148,6 +150,22 @@ pub struct LabelStyles {
|
||||
pub categories: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend: Option<crate::CharStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_para: Option<crate::ParaStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories_para: Option<crate::ParaStyle>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_para: Option<crate::ParaStyle>,
|
||||
/// The anti-aliasing method the value axis numbers keep once set (Character panel › Anti-aliasing Method).
|
||||
/// `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub values_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
/// The anti-aliasing method the category labels keep once set. `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub categories_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
/// The anti-aliasing method the legend labels keep once set. `None` draws them Sharp.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub legend_anti_alias: Option<crate::text::TypeAntiAlias>,
|
||||
}
|
||||
|
||||
impl LabelStyles {
|
||||
@@ -525,7 +543,9 @@ impl GraphSpec {
|
||||
out.push_str(&field(self.categories.get(i).map(String::as_str).unwrap_or_default(), true));
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||||
let line = r.iter().map(|v| v.map(|v| v.to_string()).unwrap_or_default()).collect::<Vec<_>>().join(",");
|
||||
// A lone blank value would be an empty line, which readers skip; a quoted empty field keeps the row.
|
||||
out.push_str(if line.is_empty() && !labelled { "\"\"" } else { &line });
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
|
||||
@@ -870,10 +870,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
on_type_path(n, t, p, opt.tol).then_some(HitKind::Outline)
|
||||
}
|
||||
// An image is hit within its frame (on the frame's edge only with Selection by Path Only,
|
||||
// #3291), and where its own fills and strokes paint ([`image_paint`]) except in outline
|
||||
// mode.
|
||||
// #3291, and in outline mode where the frame is all that is drawn, #6437), and where its
|
||||
// own fills and strokes paint ([`image_paint`]) except in outline mode.
|
||||
NodeKind::Image(im) => {
|
||||
if opt.path_only {
|
||||
if opt.path_only || (opt.outline && !symbols.document().setup.outline_images) {
|
||||
use vectorcraft_geom::kurbo::Shape;
|
||||
let mut frame = Rect::new(0.0, 0.0, f64::from(im.width), f64::from(im.height)).to_path(0.1);
|
||||
frame.apply_affine(im.xf);
|
||||
@@ -909,6 +909,10 @@ fn hit_leaf(n: &Node, p: Point, opt: HitOptions, symbols: &SymbolBoxes) -> Optio
|
||||
if stroke_contains(&bp, 0.0, opt.tol, p) {
|
||||
return Some(HitKind::Outline);
|
||||
}
|
||||
// Outline mode draws the mesh lines, not the colours between them (#6437).
|
||||
if opt.outline {
|
||||
return stroke_contains(&m.lines().to_bezpath(), 0.0, opt.tol, p).then_some(HitKind::Outline);
|
||||
}
|
||||
(!opt.path_only && fill_contains(&bp, vectorcraft_geom::FillRule::NonZero, p)).then_some(HitKind::Fill)
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -880,6 +880,45 @@ fn path_only_picks_paths_through_images() {
|
||||
assert_eq!(hit(image_edge, path_only), Some(image), "path only: the image's frame");
|
||||
}
|
||||
|
||||
/// #6437: in outline mode an image is drawn as its frame and a gradient mesh as its mesh lines,
|
||||
/// so a click inside them, on the outline of a path behind, picks that path, and a click on the
|
||||
/// frame or a mesh line picks the image or the mesh. In Preview they cover the path as before,
|
||||
/// and an image whose pixels show in outline mode (Show Images in Outline Mode) still does.
|
||||
#[test]
|
||||
fn outline_mode_picks_paths_through_images_and_meshes() {
|
||||
let mut d = Document::new(200.0, 200.0);
|
||||
let l = d.layers[0].id;
|
||||
let (path, image, under, mesh) = (d.alloc_id(), d.alloc_id(), d.alloc_id(), d.alloc_id());
|
||||
d.insert(Some(l), 0, Node::path(path, shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)), Appearance::default_art())).unwrap();
|
||||
let xf = vectorcraft_geom::Affine::translate((10.0, 10.0));
|
||||
let im = crate::ImageObject { key: "k".into(), width: 80, height: 80, xf, link: None, placement: Default::default(), pixels: None };
|
||||
d.insert(Some(l), 1, Node::new(image, NodeKind::Image(Box::new(im)))).unwrap();
|
||||
d.insert(Some(l), 2, Node::path(under, shapes::rectangle(Rect::new(120.0, 120.0, 160.0, 160.0)), Appearance::default_art())).unwrap();
|
||||
let area = shapes::rectangle(Rect::new(110.0, 110.0, 190.0, 190.0));
|
||||
let m = crate::GradientMesh::for_path_with(&area, 2, 2, &|_| (Color::BLACK, 1.0)).unwrap();
|
||||
d.insert(Some(l), 3, Node::new(mesh, NodeKind::Mesh(m))).unwrap();
|
||||
let outline = HitOptions { outline: true, ..Default::default() };
|
||||
let hit = |d: &Document, p: Point, opt| hit_test(d, p, opt).map(|h| h.leaf);
|
||||
// The image: the path's edge and the inside, then the frame.
|
||||
let (path_edge, inside, frame) = (Point::new(20.5, 40.0), Point::new(80.0, 80.0), Point::new(89.5, 50.0));
|
||||
assert_eq!(hit(&d, path_edge, HitOptions::default()), Some(image), "Preview: the image covers the path");
|
||||
assert_eq!(hit(&d, inside, HitOptions::default()), Some(image));
|
||||
assert_eq!(hit(&d, path_edge, outline), Some(path), "outline: the path through the image");
|
||||
assert_eq!(hit(&d, inside, outline), None, "outline: not inside the image");
|
||||
assert_eq!(hit(&d, frame, outline), Some(image), "outline: the image's frame");
|
||||
// The mesh: the path's edge and the inside, then its edge and a mesh line inside it.
|
||||
let (under_edge, inside, edge, line) = (Point::new(120.5, 130.0), Point::new(130.0, 170.0), Point::new(189.5, 130.0), Point::new(150.0, 170.0));
|
||||
assert_eq!(hit(&d, under_edge, HitOptions::default()), Some(mesh), "Preview: the mesh covers the path");
|
||||
assert_eq!(hit(&d, inside, HitOptions::default()), Some(mesh));
|
||||
assert_eq!(hit(&d, under_edge, outline), Some(under), "outline: the path through the mesh");
|
||||
assert_eq!(hit(&d, inside, outline), None, "outline: not between the mesh lines");
|
||||
assert_eq!(hit(&d, edge, outline), Some(mesh), "outline: the mesh's edge");
|
||||
assert_eq!(hit(&d, line, outline), Some(mesh), "outline: a mesh line");
|
||||
// Show Images in Outline Mode: the image's pixels are drawn, and it covers the path again.
|
||||
d.setup.outline_images = true;
|
||||
assert_eq!(hit(&d, path_edge, outline), Some(image));
|
||||
}
|
||||
|
||||
/// Type Object Selection by Path Only: point type is picked on its baseline, area type on its
|
||||
/// frame and type on a path on its path, no longer anywhere in its bounds.
|
||||
#[test]
|
||||
|
||||
+41
-5
@@ -110,8 +110,9 @@ fn visit_paint(p: &mut Paint, f: &mut ColorVisitor) {
|
||||
}
|
||||
|
||||
/// Visit every colour of a node tree (fills, strokes, gradient stops, text runs, mesh points, an
|
||||
/// image's colorize color, not opacity masks) with its swatch link and tint. Mesh points have no
|
||||
/// link: changes to it are dropped.
|
||||
/// image's colorize color, the fills and strokes a dynamic symbol instance keeps for objects of its
|
||||
/// art, not opacity masks) with its swatch link and tint. Mesh points have no link: changes to it
|
||||
/// are dropped.
|
||||
pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
for it in &mut n.appearance.items {
|
||||
match it {
|
||||
@@ -119,8 +120,13 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
AppearanceItem::Stroke(l) => visit_paint(&mut l.paint, f),
|
||||
}
|
||||
}
|
||||
if let Some(p) = n.extras.0.as_deref_mut().and_then(|e| e.colorize.as_deref_mut()) {
|
||||
visit_paint(p, f);
|
||||
if let Some(e) = n.extras.0.as_deref_mut() {
|
||||
if let Some(p) = e.colorize.as_deref_mut() {
|
||||
visit_paint(p, f);
|
||||
}
|
||||
for p in e.symbol_overrides.iter_mut().flat_map(|o| [&mut o.fill, &mut o.stroke]).flatten() {
|
||||
visit_paint(p, f);
|
||||
}
|
||||
}
|
||||
match &mut n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
@@ -144,7 +150,8 @@ pub fn visit_node_colors(n: &mut Node, f: &mut ColorVisitor) {
|
||||
}
|
||||
|
||||
/// Apply `f` to every colour of a node tree (fills, strokes, gradient stops, text runs, mesh
|
||||
/// points) given its swatch link and tint, keeping the links.
|
||||
/// points, a dynamic symbol instance's own fills and strokes) given its swatch link and tint,
|
||||
/// keeping the links.
|
||||
pub fn map_node_colors(n: &mut Node, f: &mut dyn FnMut(&Color, Link) -> Color) {
|
||||
visit_node_colors(n, &mut |c, swatch, tint| *c = f(c, swatch.as_deref().map(|s| (s, *tint))));
|
||||
}
|
||||
@@ -158,3 +165,32 @@ pub fn map_document_colors(doc: &mut Document, f: &mut dyn FnMut(&Color, Link) -
|
||||
map_node_colors(Arc::make_mut(&mut s.art), f);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{NodeId, SymbolOverride};
|
||||
use vectorcraft_geom::Affine;
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_instances_own_fills_and_strokes_are_visited() {
|
||||
let red = Color::rgb(1.0, 0.0, 0.0);
|
||||
let blue = Color::rgb(0.0, 0.0, 1.0);
|
||||
let spot = Paint::Solid { color: red, swatch: Some("Spot".into()), tint: 0.5 };
|
||||
let mut inst = Node::new(NodeId(1), NodeKind::SymbolInstance { symbol: "S".into(), xf: Affine::IDENTITY });
|
||||
inst.extras_mut().symbol_overrides = vec![
|
||||
SymbolOverride { path: vec![0], fill: Some(spot), stroke: Some(Paint::solid(red)), ..Default::default() },
|
||||
SymbolOverride { path: vec![1], opacity: Some(0.5), ..Default::default() },
|
||||
];
|
||||
let mut links = vec![];
|
||||
map_node_colors(&mut inst, &mut |_, link| {
|
||||
links.push(link.map(|(s, t)| (s.to_string(), t)));
|
||||
blue
|
||||
});
|
||||
assert_eq!(links, vec![Some(("Spot".to_string(), 0.5)), None], "each override paint is visited once, with its link");
|
||||
let o = &inst.extras().symbol_overrides;
|
||||
assert_eq!(o[0].fill, Some(Paint::Solid { color: blue, swatch: Some("Spot".into()), tint: 0.5 }), "the link is kept");
|
||||
assert_eq!(o[0].stroke, Some(Paint::solid(blue)));
|
||||
assert_eq!((o[1].fill.as_ref(), o[1].stroke.as_ref()), (None, None), "an override without paint gets none");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ impl Document {
|
||||
}
|
||||
if changed {
|
||||
t.cached_bounds = None;
|
||||
t.cached_ink = None;
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
@@ -339,6 +339,11 @@ pub struct Document {
|
||||
/// names it as the file's active artboard. `None`: the first. Native files do not keep it.
|
||||
#[serde(skip)]
|
||||
pub opened_artboard: Option<u32>,
|
||||
/// The file this document was read from recorded its units (a `.ai` file's
|
||||
/// `%AI5_RulerUnits`): it opens in them. A file that records none opens in Preferences ›
|
||||
/// Units › General (#5820). Native files do not keep it: they always keep their units.
|
||||
#[serde(skip)]
|
||||
pub units_from_file: bool,
|
||||
/// Some object is a dimension (the Dimension tool): edits refresh dimensions only while this
|
||||
/// is set, so documents without any pay nothing for them.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
|
||||
@@ -643,6 +643,168 @@ mod tests {
|
||||
assert!(e.p1.distance(Point::new(30.0, 0.0)) < 1e-6 && e.p2.distance(Point::new(60.0, 0.0)) < 1e-6, "{e:?}");
|
||||
}
|
||||
|
||||
/// The polylines of `bp` flattened to 0.01 pt.
|
||||
fn flattened(bp: &BezPath) -> Vec<Vec<Point>> {
|
||||
let mut out: Vec<Vec<Point>> = vec![];
|
||||
vectorcraft_geom::kurbo::flatten(bp.iter(), 0.01, |el| match el {
|
||||
vectorcraft_geom::PathEl::MoveTo(p) => out.push(vec![p]),
|
||||
vectorcraft_geom::PathEl::LineTo(p) => out.last_mut().into_iter().for_each(|v| v.push(p)),
|
||||
vectorcraft_geom::PathEl::ClosePath => out.last_mut().into_iter().for_each(|v| v.push(v[0])),
|
||||
_ => {}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// The distance from `p` to the nearest of `lines`.
|
||||
fn off_lines(lines: &[Vec<Point>], p: Point) -> f64 {
|
||||
lines
|
||||
.iter()
|
||||
.flat_map(|v| v.windows(2))
|
||||
.map(|w| {
|
||||
let d = w[1] - w[0];
|
||||
let t = if d.hypot2() > 0.0 { ((p - w[0]).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
p.distance(w[0] + d * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
}
|
||||
|
||||
/// How far apart the border of `m` and the outline of `path` are, in points: the larger of the
|
||||
/// farthest a point of either lies from the other.
|
||||
fn mesh_border_off_path(m: &GradientMesh, path: &PathData) -> f64 {
|
||||
let (border, outline) = (flattened(&m.outline().to_bezpath()), flattened(&path.to_bezpath()));
|
||||
let one = border.iter().flatten().map(|p| off_lines(&outline, *p)).fold(0.0, f64::max);
|
||||
let other = outline.iter().flatten().map(|p| off_lines(&border, *p)).fold(0.0, f64::max);
|
||||
one.max(other)
|
||||
}
|
||||
|
||||
/// #2752: a gradient mesh keeps the exact outline of the path it is made from, sharp corners
|
||||
/// included, whether made by Create Gradient Mesh (1×1 and 4×4) or by a Mesh tool click at a
|
||||
/// point inside. Before, the border of each mesh was one cubic per edge between four corners
|
||||
/// near the bounding box's: up to 49 pt off a five-point star, 54 pt off a notched rectangle.
|
||||
#[test]
|
||||
fn a_mesh_keeps_the_outline_of_every_shape() {
|
||||
const OFF: f64 = 0.1;
|
||||
let poly = |pts: &[(f64, f64)]| PathData::single(SubPath::polyline(&pts.iter().map(|&(x, y)| Point::new(x, y)).collect::<Vec<_>>(), true));
|
||||
let curves = |start: (f64, f64), cubics: &[[(f64, f64); 3]]| {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(start);
|
||||
for [a, b, c] in cubics {
|
||||
bp.curve_to(*a, *b, *c);
|
||||
}
|
||||
bp.close_path();
|
||||
PathData::from_bezpath(&bp)
|
||||
};
|
||||
let box_ = Rect::new(100.0, 100.0, 500.0, 400.0);
|
||||
let heart = curves(
|
||||
(300.0, 400.0),
|
||||
&[
|
||||
[(200.0, 320.0), (100.0, 250.0), (100.0, 170.0)],
|
||||
[(100.0, 100.0), (180.0, 70.0), (230.0, 90.0)],
|
||||
[(270.0, 105.0), (290.0, 130.0), (300.0, 150.0)],
|
||||
[(310.0, 130.0), (330.0, 105.0), (370.0, 90.0)],
|
||||
[(420.0, 70.0), (500.0, 100.0), (500.0, 170.0)],
|
||||
[(500.0, 250.0), (400.0, 320.0), (300.0, 400.0)],
|
||||
],
|
||||
);
|
||||
let teardrop = curves(
|
||||
(300.0, 100.0),
|
||||
&[
|
||||
[(350.0, 200.0), (420.0, 250.0), (420.0, 320.0)],
|
||||
[(420.0, 386.0), (366.0, 440.0), (300.0, 440.0)],
|
||||
[(234.0, 440.0), (180.0, 386.0), (180.0, 320.0)],
|
||||
[(180.0, 250.0), (250.0, 200.0), (300.0, 100.0)],
|
||||
],
|
||||
);
|
||||
let shapes: [(&str, PathData, Point); 14] = [
|
||||
("rectangle", shapes::rectangle(box_), Point::new(260.0, 230.0)),
|
||||
("rotated rectangle", shapes::rectangle(box_).transformed(Affine::rotate_about(0.5, box_.center())), Point::new(280.0, 240.0)),
|
||||
("triangle", poly(&[(300.0, 100.0), (500.0, 400.0), (100.0, 400.0)]), Point::new(300.0, 320.0)),
|
||||
("five-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0), Point::new(300.0, 300.0)),
|
||||
("twelve-point star", shapes::star(Point::new(300.0, 300.0), 200.0, 140.0, 12, 0.0), Point::new(300.0, 300.0)),
|
||||
(
|
||||
"L-shape",
|
||||
poly(&[(100.0, 100.0), (200.0, 100.0), (200.0, 300.0), (400.0, 300.0), (400.0, 400.0), (100.0, 400.0)]),
|
||||
Point::new(150.0, 350.0),
|
||||
),
|
||||
("rounded rectangle", shapes::rounded_rectangle(box_, 40.0), Point::new(260.0, 230.0)),
|
||||
("heart", heart, Point::new(300.0, 250.0)),
|
||||
("teardrop", teardrop, Point::new(300.0, 330.0)),
|
||||
("ellipse", shapes::ellipse(box_), Point::new(200.0, 330.0)),
|
||||
("thin rectangle", shapes::rectangle(Rect::new(100.0, 100.0, 500.0, 110.0)), Point::new(260.0, 104.0)),
|
||||
(
|
||||
"notched rectangle",
|
||||
poly(&[(100.0, 100.0), (250.0, 100.0), (300.0, 200.0), (350.0, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]),
|
||||
Point::new(260.0, 300.0),
|
||||
),
|
||||
// A notch shorter than the steps along the outline an edge is checked at, and deeper
|
||||
// than the tolerance.
|
||||
(
|
||||
"rectangle with a tiny notch",
|
||||
poly(&[(100.0, 100.0), (301.1, 100.0), (301.2, 100.3), (301.3, 100.0), (500.0, 100.0), (500.0, 400.0), (100.0, 400.0)]),
|
||||
Point::new(260.0, 300.0),
|
||||
),
|
||||
// An outline that crosses itself, whose two lobes' areas cancel.
|
||||
("bow tie", poly(&[(100.0, 100.0), (500.0, 400.0), (500.0, 100.0), (100.0, 400.0)]), Point::new(150.0, 250.0)),
|
||||
];
|
||||
// Every shape and way of making the mesh is checked, and all that stray are named.
|
||||
let mut strays = vec![];
|
||||
for (name, path, click) in shapes {
|
||||
for (rows, cols) in [(1, 1), (4, 4)] {
|
||||
let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
let off = mesh_border_off_path(&m, &path);
|
||||
if off >= OFF {
|
||||
strays.push(format!("{name}, {rows}×{cols}: border {off:.2} pt off the outline"));
|
||||
}
|
||||
}
|
||||
let mut m = GradientMesh::for_path(&path, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
assert!(m.add_lines_at(click, 0.0).is_some(), "{name}: the click at {click:?} is outside the mesh");
|
||||
let off = mesh_border_off_path(&m, &path);
|
||||
if off >= OFF {
|
||||
strays.push(format!("{name}, Mesh tool click: border {off:.2} pt off the outline"));
|
||||
}
|
||||
}
|
||||
assert!(strays.is_empty(), "{strays:#?}");
|
||||
}
|
||||
|
||||
/// The lines a mesh takes to keep a path's outline are as few as do it: a rectangle's and an
|
||||
/// ellipse's meshes keep the rows and columns asked for, and a star's 1×1 mesh takes lines
|
||||
/// through its points, at most one each way per anchor. Paths without an outline to follow
|
||||
/// still give a mesh (an open path closes straight; a line there and back maps onto its box),
|
||||
/// and a point that isn't finite gives none.
|
||||
#[test]
|
||||
fn a_mesh_takes_lines_only_where_the_outline_needs_them() {
|
||||
let box_ = Rect::new(100.0, 100.0, 500.0, 400.0);
|
||||
for path in [shapes::rectangle(box_), shapes::ellipse(box_)] {
|
||||
for (rows, cols) in [(1, 1), (4, 4), (2, 5)] {
|
||||
let m = GradientMesh::for_path(&path, rows, cols, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
assert_eq!((m.rows, m.cols), (rows, cols));
|
||||
}
|
||||
}
|
||||
let star = shapes::star(Point::new(300.0, 300.0), 200.0, 80.0, 5, 0.0);
|
||||
let m = GradientMesh::for_path(&star, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
assert!(m.rows > 1 && m.cols > 1 && m.rows <= 10 && m.cols <= 10 && m.is_valid(), "{}×{}", m.rows, m.cols);
|
||||
// Every anchor of the star is a point of the mesh.
|
||||
for a in &star.subpaths[0].anchors {
|
||||
assert!(m.points.iter().any(|q| q.p.distance(a.p) < 1e-6), "{:?} is not a mesh point", a.p);
|
||||
}
|
||||
// An open path closes with a straight segment.
|
||||
let open = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], false));
|
||||
let m = GradientMesh::for_path(&open, 2, 2, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
let closed = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 80.0)], true));
|
||||
assert!(mesh_border_off_path(&m, &closed) < 0.1);
|
||||
// A line there and back.
|
||||
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(100.0, 50.0)], true));
|
||||
let m = GradientMesh::for_path(&line, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
assert!(m.is_valid() && m.points.iter().all(|q| q.p.is_finite()));
|
||||
let mut nan = shapes::rectangle(box_);
|
||||
nan.subpaths[0].anchors[1].p.x = f64::NAN;
|
||||
assert!(GradientMesh::for_path(&nan, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).is_none());
|
||||
// A path of many anchors stays within the line limit and keeps its outline.
|
||||
let many = shapes::star(Point::new(300.0, 300.0), 200.0, 190.0, 400, 0.0);
|
||||
let m = GradientMesh::for_path(&many, 1, 1, Color::BLACK, MeshAppearance::Flat, 0.0).unwrap();
|
||||
assert!(m.is_valid() && m.rows as usize <= mesh::MAX_BORDER_LINES && m.cols as usize <= mesh::MAX_BORDER_LINES, "{}×{}", m.rows, m.cols);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesh_insert_and_remove_lines() {
|
||||
let mut m =
|
||||
|
||||
@@ -236,6 +236,7 @@ impl TypeInks {
|
||||
text.xf = Affine::new([a, b, c, d, 0.0, 0.0]);
|
||||
text.cached_bounds = None;
|
||||
text.cached_baselines.clear();
|
||||
text.cached_ink = None;
|
||||
let content = content_hash(&text).map(|hash| (hash, by_node.1, inputs));
|
||||
match content.and_then(|key| self.content.get(&key)) {
|
||||
Some(ink) => {
|
||||
|
||||
+481
-8
@@ -470,6 +470,384 @@ fn fit_edge(side: &[Point], span: (f64, f64), ends: (Point, Point)) -> Option<(V
|
||||
fit
|
||||
}
|
||||
|
||||
/// The cubic from `p0` to `p3` leaving `p0` along `d0` and reaching `p3` against `d1` (unit
|
||||
/// vectors; `d1` points from `p3` back into the curve) that comes closest (least squares) to the
|
||||
/// points `qs`, which lie along the way in order; `None` when no such cubic has handles of positive
|
||||
/// length. Each point is matched to the curve's nearest point, refined over a few rounds, as in
|
||||
/// [`fit_edge`].
|
||||
fn fit_tangents(qs: &[Point], (p0, d0): (Point, Vec2), (p3, d1): (Point, Vec2)) -> Option<CubicBez> {
|
||||
const ROUNDS: usize = 6;
|
||||
let n = qs.len() + 1;
|
||||
let mut ts: Vec<f64> = (1..n).map(|i| i as f64 / n as f64).collect();
|
||||
let mut fit = None;
|
||||
for _ in 0..ROUNDS {
|
||||
let (mut c11, mut c12, mut c22, mut x1, mut x2) = (0.0, 0.0, 0.0, 0.0, 0.0);
|
||||
for (q, &t) in qs.iter().zip(&ts) {
|
||||
let s = 1.0 - t;
|
||||
let (b0, b1, b2, b3) = (s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t);
|
||||
let (a1, a2) = (d0 * b1, d1 * b2);
|
||||
let r = q.to_vec2() - (b0 + b1) * p0.to_vec2() - (b2 + b3) * p3.to_vec2();
|
||||
c11 += a1.dot(a1);
|
||||
c12 += a1.dot(a2);
|
||||
c22 += a2.dot(a2);
|
||||
x1 += r.dot(a1);
|
||||
x2 += r.dot(a2);
|
||||
}
|
||||
let det = c11 * c22 - c12 * c12;
|
||||
if det.abs() < 1e-12 {
|
||||
break;
|
||||
}
|
||||
let (l1, l2) = ((c22 * x1 - c12 * x2) / det, (c11 * x2 - c12 * x1) / det);
|
||||
if !(l1.is_finite() && l2.is_finite() && l1 > 0.0 && l2 > 0.0) {
|
||||
break;
|
||||
}
|
||||
let c = CubicBez::new(p0, p0 + d0 * l1, p3 + d1 * l2, p3);
|
||||
fit = Some(c);
|
||||
let (a, b, k) = (c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2);
|
||||
for (q, t) in qs.iter().zip(ts.iter_mut()) {
|
||||
let s = 1.0 - *t;
|
||||
let v = 3.0 * (s * s * a + 2.0 * s * *t * b + *t * *t * k);
|
||||
let acc = 6.0 * (s * (b - a) + *t * (k - b));
|
||||
let e = c.eval(*t) - *q;
|
||||
let den = v.hypot2() + e.dot(acc);
|
||||
if den.abs() > 1e-12 {
|
||||
*t = (*t - e.dot(v) / den).clamp(0.0, 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
fit
|
||||
}
|
||||
|
||||
/// How far (in points) a border edge of a mesh made from a path may stray from the path's outline
|
||||
/// before the mesh takes a line through an anchor of the outline there; a thousandth of the outline's
|
||||
/// size when that is less.
|
||||
const BORDER_TOLERANCE: f64 = 0.1;
|
||||
|
||||
/// The most lines each way a mesh made from a path takes to keep its border on the outline
|
||||
/// ([`BORDER_TOLERANCE`]); past that, each border edge keeps its closest fit.
|
||||
pub(super) const MAX_BORDER_LINES: usize = 256;
|
||||
|
||||
/// The most rounds of new lines a mesh made from a path takes to keep its border on the outline.
|
||||
const MAX_BORDER_ROUNDS: usize = 64;
|
||||
|
||||
/// An anchor whose directions in and out differ by more than this (radians) is a corner: a border
|
||||
/// edge that can't follow the outline takes a mesh line through its sharpest corner first.
|
||||
const SHARP_TURN: f64 = 0.01;
|
||||
|
||||
/// Steps of each segment's parameter at which [`Outline`] measures the segment's length.
|
||||
const LENGTH_STEPS: usize = 32;
|
||||
|
||||
/// Points of the outline each border edge of a mesh made from a path is fitted to
|
||||
/// ([`Outline::fit`]).
|
||||
const FIT_POINTS: usize = 32;
|
||||
|
||||
/// Points of the outline, and of the fitted edge, at which [`Outline::fit`] measures how far the
|
||||
/// two stray from each other.
|
||||
const CHECK_POINTS: usize = 128;
|
||||
|
||||
/// The outline a gradient mesh is made from: the largest subpath of a path, running clockwise on
|
||||
/// screen, an open one closed by a straight segment, without segments of no length. A place on it
|
||||
/// is a length from its first anchor, measured along a polyline through even steps of each
|
||||
/// segment's parameter; the same length always gives the same point.
|
||||
struct Outline {
|
||||
segs: Vec<CubicBez>,
|
||||
/// Where each segment starts, and the whole length last.
|
||||
starts: Vec<f64>,
|
||||
/// For each segment, the length from its start at each of [`LENGTH_STEPS`] + 1 even steps of
|
||||
/// its parameter.
|
||||
table: Vec<[f64; LENGTH_STEPS + 1]>,
|
||||
/// Lengths closer than this are the same place.
|
||||
eps: f64,
|
||||
}
|
||||
|
||||
/// One side of the patch a mesh made from a path spans: the stretch of the outline from `start`,
|
||||
/// `len` long, run backwards when `reversed` (the bottom and left sides run against the outline).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Side {
|
||||
start: f64,
|
||||
len: f64,
|
||||
reversed: bool,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The place on the outline at `f` (0 to 1) along the side.
|
||||
fn at(&self, f: f64) -> f64 {
|
||||
self.start + if self.reversed { (1.0 - f) * self.len } else { f * self.len }
|
||||
}
|
||||
}
|
||||
|
||||
/// The direction a cubic leaves its start in (`false`) or reaches its end in (`true`), from the
|
||||
/// first handle or point that is not at the end; zero when all its points are together.
|
||||
fn cubic_tangent(c: &CubicBez, end: bool) -> Vec2 {
|
||||
let near = 1e-12 * (c.p0.to_vec2().hypot() + c.p3.to_vec2().hypot() + 1.0);
|
||||
let options = if end { [c.p3 - c.p2, c.p3 - c.p1, c.p3 - c.p0] } else { [c.p1 - c.p0, c.p2 - c.p0, c.p3 - c.p0] };
|
||||
options.into_iter().find(|v| v.hypot() > near).unwrap_or(Vec2::ZERO)
|
||||
}
|
||||
|
||||
impl Outline {
|
||||
/// The outline of `path`; `None` when it has no subpath of two anchors or more, a point that
|
||||
/// isn't finite, or all its points on one line (a line there and back).
|
||||
fn new(path: &PathData) -> Option<Self> {
|
||||
let sp = path.subpaths.iter().filter(|s| s.anchors.len() >= 2).max_by(|a, b| a.area().abs().total_cmp(&b.area().abs()))?;
|
||||
if !sp.anchors.iter().all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let mut sp = sp.clone();
|
||||
if sp.signed_area() < 0.0 {
|
||||
sp.reverse();
|
||||
}
|
||||
// A straight segment's parameter runs evenly along it.
|
||||
let line = |a: Point, b: Point| CubicBez::new(a, a.lerp(b, 1.0 / 3.0), a.lerp(b, 2.0 / 3.0), b);
|
||||
let mut segs: Vec<CubicBez> =
|
||||
(0..sp.segment_count()).map(|i| if sp.segment_is_line(i) { line(sp.segment(i).p0, sp.segment(i).p3) } else { sp.segment(i) }).collect();
|
||||
if !sp.closed
|
||||
&& let (Some(a), Some(b)) = (sp.anchors.last(), sp.anchors.first())
|
||||
{
|
||||
segs.push(line(a.p, b.p));
|
||||
}
|
||||
segs.retain(|c| [c.p1, c.p2, c.p3].iter().any(|p| p.distance(c.p0) > 1e-9));
|
||||
if segs.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let mut starts = Vec::with_capacity(segs.len() + 1);
|
||||
let mut table = Vec::with_capacity(segs.len());
|
||||
let mut total = 0.0;
|
||||
for c in &segs {
|
||||
let mut lens = [0.0; LENGTH_STEPS + 1];
|
||||
let mut prev = c.p0;
|
||||
for (k, len) in lens.iter_mut().enumerate().skip(1) {
|
||||
let p = c.eval(k as f64 / LENGTH_STEPS as f64);
|
||||
*len = prev.distance(p);
|
||||
prev = p;
|
||||
}
|
||||
for k in 1..=LENGTH_STEPS {
|
||||
lens[k] += lens[k - 1];
|
||||
}
|
||||
starts.push(total);
|
||||
total += lens[LENGTH_STEPS];
|
||||
table.push(lens);
|
||||
}
|
||||
starts.push(total);
|
||||
if !(total.is_finite() && total > 0.0) {
|
||||
return None;
|
||||
}
|
||||
// An outline whose points all lie on one line encloses nothing; one that crosses itself
|
||||
// (a bow tie) may have areas that cancel, and is still followed.
|
||||
let pts: Vec<Point> = segs.iter().flat_map(|c| [c.p0, c.p1, c.p2, c.p3]).collect();
|
||||
let first = pts.first().copied()?;
|
||||
let far = pts.iter().copied().max_by(|a, b| a.distance_squared(first).total_cmp(&b.distance_squared(first)))?;
|
||||
let dir = far - first;
|
||||
let width = pts.iter().map(|p| ((*p - first).cross(dir) / dir.hypot()).abs()).fold(0.0, f64::max);
|
||||
if !(dir.hypot() > 0.0 && width > 1e-9 * total) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { segs, starts, table, eps: total * 1e-9 })
|
||||
}
|
||||
|
||||
fn total(&self) -> f64 {
|
||||
self.starts.last().copied().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The bounding box of the outline.
|
||||
fn bounds(&self) -> Option<Rect> {
|
||||
self.segs.iter().map(|c| c.bounding_box()).reduce(|a, b| a.union(b))
|
||||
}
|
||||
|
||||
/// The parameter of segment `i` `local` along it (clamped to the segment).
|
||||
fn seg_t(&self, i: usize, local: f64) -> f64 {
|
||||
let Some(lens) = self.table.get(i) else { return 0.0 };
|
||||
let k = lens.partition_point(|x| *x <= local).clamp(1, LENGTH_STEPS) - 1;
|
||||
let (l0, l1) = (lens[k], lens[k + 1]);
|
||||
let f = if l1 > l0 { ((local - l0) / (l1 - l0)).clamp(0.0, 1.0) } else { 0.0 };
|
||||
(k as f64 + f) / LENGTH_STEPS as f64
|
||||
}
|
||||
|
||||
/// The segment and its parameter at place `s` (wrapping around); a place at the very end of a
|
||||
/// segment is the start of the next.
|
||||
fn locate(&self, s: f64) -> (usize, f64) {
|
||||
let n = self.segs.len();
|
||||
let s = s.rem_euclid(self.total());
|
||||
let mut i = self.starts.partition_point(|x| *x <= s).clamp(1, n) - 1;
|
||||
if self.starts.get(i + 1).is_some_and(|end| end - s <= self.eps) {
|
||||
i = (i + 1) % n;
|
||||
return (i, 0.0);
|
||||
}
|
||||
(i, self.seg_t(i, s - self.starts[i]))
|
||||
}
|
||||
|
||||
fn point(&self, s: f64) -> Point {
|
||||
let (i, t) = self.locate(s);
|
||||
self.segs.get(i).map_or(Point::ZERO, |c| c.eval(t))
|
||||
}
|
||||
|
||||
/// The anchors strictly inside the stretch from `a`, `len` long: each one's distance from `a`
|
||||
/// and the segment it starts.
|
||||
fn anchors_in(&self, a: f64, len: f64) -> Vec<(f64, usize)> {
|
||||
let (total, n) = (self.total(), self.segs.len());
|
||||
let a = a.rem_euclid(total);
|
||||
let (lo, hi) = (a + self.eps, a + len - self.eps);
|
||||
let mut out = vec![];
|
||||
for lap in [0.0, total] {
|
||||
let from = self.starts[..n].partition_point(|x| x + lap <= lo);
|
||||
for (i, start) in self.starts[..n].iter().enumerate().skip(from) {
|
||||
let p = start + lap;
|
||||
if p >= hi {
|
||||
break;
|
||||
}
|
||||
out.push((p - a, i));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The piece of the outline from `a`, `len` long, which no anchor lies inside.
|
||||
fn piece(&self, a: f64, len: f64) -> CubicBez {
|
||||
let (i, t0) = self.locate(a);
|
||||
let Some(c) = self.segs.get(i) else { return CubicBez::new(Point::ZERO, Point::ZERO, Point::ZERO, Point::ZERO) };
|
||||
// At a segment's start (a place at the end of the one before counts as that), none of it is behind.
|
||||
let behind = if t0 == 0.0 { 0.0 } else { a.rem_euclid(self.total()) - self.starts[i] };
|
||||
let t1 = self.seg_t(i, behind + len);
|
||||
c.subsegment(t0..t1.max(t0))
|
||||
}
|
||||
|
||||
/// The direction the outline leaves place `s` in (`into` false) or reaches it in (`into` true).
|
||||
fn tangent(&self, s: f64, into: bool) -> Vec2 {
|
||||
let n = self.segs.len();
|
||||
let (i, t) = self.locate(s);
|
||||
let c = match (into, t <= 0.0) {
|
||||
(true, true) => self.segs[(i + n - 1) % n],
|
||||
(true, false) => self.segs[i].subsegment(0.0..t),
|
||||
(false, _) => self.segs[i].subsegment(t..1.0),
|
||||
};
|
||||
cubic_tangent(&c, into)
|
||||
}
|
||||
|
||||
/// How sharply the outline turns at the anchor starting segment `i` (radians, 0 to π).
|
||||
fn turn(&self, i: usize) -> f64 {
|
||||
let s = self.starts[i];
|
||||
let (din, dout) = (self.tangent(s, true), self.tangent(s, false));
|
||||
din.cross(dout).atan2(din.dot(dout)).abs()
|
||||
}
|
||||
|
||||
/// The place on the outline nearest `q`.
|
||||
fn nearest(&self, q: Point) -> f64 {
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let mut best = (f64::INFINITY, 0.0);
|
||||
for (i, c) in self.segs.iter().enumerate() {
|
||||
let near = c.nearest(q, 1e-9);
|
||||
if near.distance_sq < best.0 {
|
||||
// The length at that parameter, from the table.
|
||||
let lens = &self.table[i];
|
||||
let x = near.t * LENGTH_STEPS as f64;
|
||||
let k = (x.floor() as usize).min(LENGTH_STEPS - 1);
|
||||
let local = lens[k] + (lens[k + 1] - lens[k]) * (x - k as f64);
|
||||
best = (near.distance_sq, self.starts[i] + local);
|
||||
}
|
||||
}
|
||||
best.1
|
||||
}
|
||||
|
||||
/// The cubic standing for the stretch from `a`, `len` long, and how far it strays from it: the
|
||||
/// piece of the segment when no anchor lies inside, otherwise a cubic fitted to the outline with
|
||||
/// its ends along the outline's own directions there.
|
||||
fn fit(&self, a: f64, len: f64) -> (CubicBez, f64) {
|
||||
let (p0, p3) = (self.point(a), self.point(a + len));
|
||||
if len <= self.eps {
|
||||
return (CubicBez::new(p0, p0, p0, p0), 0.0);
|
||||
}
|
||||
let anchors = self.anchors_in(a, len);
|
||||
if anchors.is_empty() {
|
||||
return (self.piece(a, len), 0.0);
|
||||
}
|
||||
let qs: Vec<Point> = (1..FIT_POINTS).map(|i| self.point(a + len * i as f64 / FIT_POINTS as f64)).collect();
|
||||
let (d0, d1) = (self.tangent(a, false), -self.tangent(a + len, true));
|
||||
let unit = |v: Vec2| if v.hypot() > 0.0 { Some(v / v.hypot()) } else { None };
|
||||
let straight = CubicBez::new(p0, p0.lerp(p3, 1.0 / 3.0), p0.lerp(p3, 2.0 / 3.0), p3);
|
||||
let c = match (unit(d0), unit(d1)) {
|
||||
(Some(d0), Some(d1)) => fit_tangents(&qs, (p0, d0), (p3, d1)),
|
||||
_ => None,
|
||||
}
|
||||
.unwrap_or(straight);
|
||||
// Both ways: the outline's points from the cubic, and the cubic's points from the outline.
|
||||
// The outline is checked at even steps and at every anchor inside, so a spike or a notch
|
||||
// narrower than a step is not missed.
|
||||
use vectorcraft_geom::kurbo::ParamCurveNearest;
|
||||
let mut at: Vec<f64> = (0..=CHECK_POINTS).map(|i| len * i as f64 / CHECK_POINTS as f64).chain(anchors.iter().map(|(o, _)| *o)).collect();
|
||||
at.sort_by(f64::total_cmp);
|
||||
let line: Vec<Point> = at.iter().map(|o| self.point(a + o)).collect();
|
||||
let off_outline = line.iter().map(|q| c.nearest(*q, 1e-9).distance_sq.sqrt()).fold(0.0, f64::max);
|
||||
let off_cubic = (0..=CHECK_POINTS)
|
||||
.map(|i| {
|
||||
let p = c.eval(i as f64 / CHECK_POINTS as f64);
|
||||
line.windows(2)
|
||||
.map(|w| {
|
||||
let (u, v) = (w[0], w[1]);
|
||||
let d = v - u;
|
||||
let t = if d.hypot2() > 0.0 { ((p - u).dot(d) / d.hypot2()).clamp(0.0, 1.0) } else { 0.0 };
|
||||
p.distance(u + d * t)
|
||||
})
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
})
|
||||
.fold(0.0, f64::max);
|
||||
(c, off_outline.max(off_cubic))
|
||||
}
|
||||
|
||||
/// The border edge of `side` from `f0` to `f1` (0 to 1 along it, rising), run the way the side
|
||||
/// runs, and when it strays from the outline by more than `tol`, where along the side (0 to 1)
|
||||
/// a mesh line through an anchor would help: the sharpest corner inside it, or the anchor
|
||||
/// nearest its middle when none is sharp.
|
||||
fn edge(&self, side: &Side, f0: f64, f1: f64, tol: f64) -> (CubicBez, Option<f64>) {
|
||||
let len = (f1 - f0) * side.len;
|
||||
let a = side.at(if side.reversed { f1 } else { f0 });
|
||||
let (c, off) = self.fit(a, len);
|
||||
let c = if side.reversed { CubicBez::new(c.p3, c.p2, c.p1, c.p0) } else { c };
|
||||
if off <= tol || side.len <= 0.0 {
|
||||
return (c, None);
|
||||
}
|
||||
let anchors = self.anchors_in(a, len);
|
||||
let sharpest = anchors.iter().map(|&(o, i)| (self.turn(i), o)).max_by(|x, y| x.0.total_cmp(&y.0));
|
||||
let at = match sharpest {
|
||||
Some((turn, o)) if turn > SHARP_TURN => Some(o),
|
||||
_ => anchors.iter().map(|&(o, _)| o).min_by(|x, y| (x - len / 2.0).abs().total_cmp(&(y - len / 2.0).abs())),
|
||||
};
|
||||
let split = at.map(|o| if side.reversed { f1 - o / side.len } else { f0 + o / side.len });
|
||||
(c, split.filter(|f| *f > f0 && *f < f1))
|
||||
}
|
||||
|
||||
/// The cuts (0 to 1) of the lines across sides `one` and `other`: `even`, and as many more as
|
||||
/// keep every border edge along them within `tol` of the outline (see [`Self::edge`]), at most
|
||||
/// [`MAX_BORDER_LINES`] in all.
|
||||
fn cuts(&self, one: &Side, other: &Side, even: Vec<f64>, tol: f64) -> Vec<f64> {
|
||||
let mut cuts = even;
|
||||
for _ in 0..MAX_BORDER_ROUNDS {
|
||||
let mut add: Vec<f64> =
|
||||
[one, other].iter().flat_map(|s| cuts.windows(2).filter_map(|w| self.edge(s, w[0], w[1], tol).1).collect::<Vec<_>>()).collect();
|
||||
add.retain(|f| cuts.iter().all(|c| (c - f).abs() > 1e-9));
|
||||
add.truncate((MAX_BORDER_LINES + 1).saturating_sub(cuts.len()));
|
||||
if add.is_empty() {
|
||||
break;
|
||||
}
|
||||
cuts.extend(add);
|
||||
cuts.sort_by(f64::total_cmp);
|
||||
cuts.dedup_by(|x, y| (*x - *y).abs() <= 1e-9);
|
||||
}
|
||||
cuts
|
||||
}
|
||||
|
||||
/// The points along `side` at `knots` (rising, 0 to 1).
|
||||
fn side_points(&self, side: &Side, knots: &[f64]) -> Vec<Point> {
|
||||
knots.iter().map(|f| self.point(side.at(*f))).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The sorted union of `cuts` and `SIDE` even steps.
|
||||
fn side_knots(cuts: &[f64]) -> Vec<f64> {
|
||||
let mut k: Vec<f64> = (0..SIDE).map(|i| i as f64 / (SIDE - 1) as f64).chain(cuts.iter().copied()).collect();
|
||||
k.sort_by(f64::total_cmp);
|
||||
k.dedup_by(|x, y| (*x - *y).abs() <= 1e-9);
|
||||
k
|
||||
}
|
||||
|
||||
/// The handles of one cubic standing for two edges that meet at point `mid.0`, whose handles
|
||||
/// towards them are `mid.1` (towards `a`) and `mid.2` (towards `b`), as offsets from their outer
|
||||
/// ends: each outer handle grown back by the share of the curve its edge had, which undoes the
|
||||
@@ -599,19 +977,104 @@ impl GradientMesh {
|
||||
|
||||
/// Create Gradient Mesh for a path: the outline split at its corners, coloured per `appearance`.
|
||||
pub fn for_path(path: &PathData, rows: u32, cols: u32, base: Color, appearance: MeshAppearance, highlight: f64) -> Option<Self> {
|
||||
let mut m = Self::for_path_with(path, rows, cols, &|_| (base, 1.0))?;
|
||||
m.highlight(appearance, highlight);
|
||||
Self::for_path_shaded(path, rows, cols, &|_| (base, 1.0), appearance, highlight)
|
||||
}
|
||||
|
||||
/// As [`Self::for_path_with`], then lightened per `appearance` ([`Self::highlight`]), measured
|
||||
/// by where each line crosses the patch.
|
||||
pub fn for_path_shaded(
|
||||
path: &PathData,
|
||||
rows: u32,
|
||||
cols: u32,
|
||||
color_at: &dyn Fn(Point) -> (Color, f32),
|
||||
appearance: MeshAppearance,
|
||||
highlight: f64,
|
||||
) -> Option<Self> {
|
||||
let (mut m, us, vs) = Self::build_for_path(path, rows, cols, color_at)?;
|
||||
m.highlight_at(appearance, highlight, &us, &vs);
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// Create Gradient Mesh for a path: the outline split at its corners, each point taking the
|
||||
/// colour and opacity `color_at` gives at its position (a gradient fill's, say).
|
||||
pub fn for_path_with(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<Self> {
|
||||
let coons = Coons::from_path(path)?;
|
||||
Self::build_for_path(path, rows, cols, color_at).map(|(m, ..)| m)
|
||||
}
|
||||
|
||||
/// The mesh of [`Self::for_path_with`] and where its column and row lines cross the patch (0
|
||||
/// to 1). Its border lies on the outline of the path's largest subpath (#2752): the patch's
|
||||
/// corners are the outline's places nearest the corners of its bounding box (when those don't
|
||||
/// come in order round the outline, the top left one and the places a quarter, a half and three
|
||||
/// quarters of the way round from it), its points on the
|
||||
/// border sit on the outline, and each border edge is the piece of the outline between its
|
||||
/// points, a single cubic. Where the outline bends through an anchor between two of the `rows`
|
||||
/// × `cols` lines and no single cubic follows it within [`BORDER_TOLERANCE`], the mesh takes a
|
||||
/// line through that anchor (its sharpest corner first), as many as that needs up to
|
||||
/// [`MAX_BORDER_LINES`] each way. `None` when the path has no outline or a point that isn't
|
||||
/// finite; an outline whose points all lie on one line gives the mesh of its bounding box.
|
||||
fn build_for_path(path: &PathData, rows: u32, cols: u32, color_at: &dyn Fn(Point) -> (Color, f32)) -> Option<(Self, Vec<f64>, Vec<f64>)> {
|
||||
if !path.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.is_finite() && a.h_in.is_finite() && a.h_out.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (us, vs) = (even_steps(cols), even_steps(rows));
|
||||
let Some((o, sides)) = Outline::new(path).and_then(|o| {
|
||||
let r = o.bounds()?;
|
||||
let [tl, mut tr, mut br, mut bl] = [(r.x0, r.y0), (r.x1, r.y0), (r.x1, r.y1), (r.x0, r.y1)].map(|(x, y)| o.nearest(Point::new(x, y)));
|
||||
let span = |a: f64, b: f64| (b - a).rem_euclid(o.total());
|
||||
// Corners met out of order around the outline would make sides that overlap: the
|
||||
// other three then go a quarter of the way round from the top left each.
|
||||
let around = span(tl, tr) + span(tr, br) + span(br, bl) + span(bl, tl);
|
||||
if (around - o.total()).abs() > 1e-6 * o.total() {
|
||||
let quarter = o.total() / 4.0;
|
||||
(tr, br, bl) = (tl + quarter, tl + 2.0 * quarter, tl + 3.0 * quarter);
|
||||
}
|
||||
let sides = [
|
||||
Side { start: tl, len: span(tl, tr), reversed: false },
|
||||
Side { start: tr, len: span(tr, br), reversed: false },
|
||||
Side { start: br, len: span(br, bl), reversed: true },
|
||||
Side { start: bl, len: span(bl, tl), reversed: true },
|
||||
];
|
||||
Some((o, sides))
|
||||
}) else {
|
||||
// No outline to follow: the patch of the flattened outline (its bounding box when it
|
||||
// encloses nothing), with each border edge fitted.
|
||||
let coons = Coons::from_path(path)?;
|
||||
let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p));
|
||||
m.fit_border(&coons, &us, &vs);
|
||||
return Some((m, us, vs));
|
||||
};
|
||||
let [top, right, bottom, left] = sides;
|
||||
let size = o.bounds().map_or(0.0, |r| r.width() + r.height());
|
||||
let tol = BORDER_TOLERANCE.min(size * 1e-3).max(o.eps);
|
||||
let us = o.cuts(&top, &bottom, us, tol);
|
||||
let vs = o.cuts(&left, &right, vs, tol);
|
||||
let (ku, kv) = (side_knots(&us), side_knots(&vs));
|
||||
let coons = Coons {
|
||||
top: o.side_points(&top, &ku),
|
||||
right: o.side_points(&right, &kv),
|
||||
bottom: o.side_points(&bottom, &ku),
|
||||
left: o.side_points(&left, &kv),
|
||||
knots: Some(Box::new([ku.clone(), kv.clone(), ku, kv])),
|
||||
};
|
||||
let mut m = Self::from_grid(&us, &vs, &|u, v| coons.eval(u, v), &|_, _, p| color_at(p));
|
||||
m.fit_border(&coons, &us, &vs);
|
||||
Some(m)
|
||||
let (rows, cols) = (m.rows as usize, m.cols as usize);
|
||||
let edges = [(0, &top), (rows, &bottom)]
|
||||
.map(|(r, side)| (r, side, true))
|
||||
.into_iter()
|
||||
.chain([(0, &left), (cols, &right)].map(|(c, side)| (c, side, false)));
|
||||
for (line, side, along_u) in edges {
|
||||
let steps = if along_u { &us } else { &vs };
|
||||
for (k, w) in steps.windows(2).enumerate() {
|
||||
let (a, b, out, back) =
|
||||
if along_u { (m.idx(line, k), m.idx(line, k + 1), H_RIGHT, H_LEFT) } else { (m.idx(k, line), m.idx(k + 1, line), H_DOWN, H_UP) };
|
||||
let (c, _) = o.edge(side, w[0], w[1], tol);
|
||||
m.points[a].p = c.p0;
|
||||
m.points[b].p = c.p3;
|
||||
m.points[a].handles[out] = c.p1 - c.p0;
|
||||
m.points[b].handles[back] = c.p2 - c.p3;
|
||||
}
|
||||
}
|
||||
Some((m, us, vs))
|
||||
}
|
||||
|
||||
/// Give each edge on the mesh's border the handles that make it follow the outline it stands
|
||||
@@ -657,11 +1120,21 @@ impl GradientMesh {
|
||||
/// the edges, by at most `highlight` %. Each point lightens towards white in its own colour
|
||||
/// model (paper white, no ink, for CMYK), so a CMYK or grey mesh keeps its inks (#3232).
|
||||
pub fn highlight(&mut self, appearance: MeshAppearance, highlight: f64) {
|
||||
let even = |n: u32| (0..=n).map(|i| f64::from(i) / f64::from(n)).collect::<Vec<_>>();
|
||||
let (us, vs) = (even(self.cols), even(self.rows));
|
||||
self.highlight_at(appearance, highlight, &us, &vs);
|
||||
}
|
||||
|
||||
/// As [`Self::highlight`], with the column lines at `us` and the row lines at `vs` (0 to 1
|
||||
/// across the patch); a mesh whose lines don't match them is left as it is.
|
||||
fn highlight_at(&mut self, appearance: MeshAppearance, highlight: f64, us: &[f64], vs: &[f64]) {
|
||||
let h = (highlight / 100.0).clamp(0.0, 1.0) as f32;
|
||||
let (rows, cols) = (self.rows as usize, self.cols as usize);
|
||||
for r in 0..=rows {
|
||||
for c in 0..=cols {
|
||||
let (u, v) = (c as f64 / cols as f64, r as f64 / rows as f64);
|
||||
if !self.is_valid() || us.len() != cols + 1 || vs.len() != rows + 1 {
|
||||
return;
|
||||
}
|
||||
for (r, &v) in vs.iter().enumerate() {
|
||||
for (c, &u) in us.iter().enumerate() {
|
||||
let d = ((u - 0.5).abs().max((v - 0.5).abs()) * 2.0) as f32; // 0 centre … 1 edge
|
||||
let w = match appearance {
|
||||
MeshAppearance::Flat => 0.0,
|
||||
|
||||
@@ -56,4 +56,30 @@ impl LayerColor {
|
||||
LayerColor::Custom(c) => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// The color as text: a preset's name from [`LAYER_COLORS`], else `#rrggbb` (lowercase).
|
||||
pub fn label_text(self) -> String {
|
||||
let preset = match self {
|
||||
LayerColor::Preset(i) => LAYER_COLORS.get(usize::from(i)),
|
||||
LayerColor::Custom(_) => None,
|
||||
};
|
||||
preset.map_or_else(
|
||||
|| {
|
||||
let [r, g, b] = self.rgb();
|
||||
format!("#{r:02x}{g:02x}{b:02x}")
|
||||
},
|
||||
|(name, _)| (*name).to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The color [`LayerColor::label_text`] names: a preset's name exactly as [`LAYER_COLORS`]
|
||||
/// spells it, or `#rrggbb` with six hex digits. None for anything else.
|
||||
pub fn from_label_text(s: &str) -> Option<Self> {
|
||||
if let Some(i) = LAYER_COLORS.iter().position(|(name, _)| *name == s) {
|
||||
return u8::try_from(i).ok().map(LayerColor::Preset);
|
||||
}
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let byte = |k: usize| u8::from_str_radix(&hex[k..k + 2], 16).ok();
|
||||
Some(LayerColor::Custom([byte(0)?, byte(2)?, byte(4)?]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,7 +373,10 @@ impl Node {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let b = match symbols.and_then(|s| Some((s, s.art(symbol)?))) {
|
||||
Some((s, art)) if !s.art_strokes() => xf.transform_rect_bbox(art.geometric),
|
||||
Some((_, art)) if moves_only(*xf) => xf.transform_rect_bbox(art.visual),
|
||||
// A symbol drawn at other than its natural size goes through its 9-slice guides.
|
||||
Some((s, art)) if moves_only(*xf) && s.document().symbol_slice_map_of(symbol, art.geometric, *xf).is_none() => {
|
||||
xf.transform_rect_bbox(art.visual)
|
||||
}
|
||||
Some((s, art)) => {
|
||||
let placed = xf.transform_rect_bbox(art.geometric);
|
||||
match s.placed_visual(symbol, *xf).or_else(|| s.sliced_visual(symbol, *xf)) {
|
||||
|
||||
@@ -60,6 +60,38 @@ impl ImageBlob {
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).ok()?;
|
||||
Some(Self::png(png))
|
||||
}
|
||||
|
||||
/// The image as a grayscale PNG: `f` gives each pixel's gray level from its (straight) RGB, and
|
||||
/// the PNG has an alpha channel only when some pixel isn't opaque. `f` runs once per distinct
|
||||
/// color. `None` when the image can't be decoded, or when its file stores grayscale already
|
||||
/// and no visible pixel changed.
|
||||
pub fn map_gray(&self, mut f: impl FnMut([u8; 3]) -> u8) -> Option<ImageBlob> {
|
||||
let img = rgba_within(&self.bytes, MAX_RASTER_ALLOC)?;
|
||||
let (width, height) = img.dimensions();
|
||||
let mut memo: HashMap<[u8; 3], u8> = HashMap::new();
|
||||
let mut changed = self.gray_source().is_none();
|
||||
let mut opaque = true;
|
||||
let mut gray_alpha = Vec::with_capacity(img.len() / 2);
|
||||
for px in img.pixels() {
|
||||
let rgb = [px.0[0], px.0[1], px.0[2]];
|
||||
let level = *memo.entry(rgb).or_insert_with(|| f(rgb));
|
||||
changed |= px.0[3] > 0 && [level; 3] != rgb;
|
||||
opaque &= px.0[3] == 255;
|
||||
gray_alpha.extend_from_slice(&[level, px.0[3]]);
|
||||
}
|
||||
if !changed {
|
||||
return None;
|
||||
}
|
||||
let mut png = Vec::new();
|
||||
let mut out = Cursor::new(&mut png);
|
||||
if opaque {
|
||||
let levels = gray_alpha.iter().step_by(2).copied().collect();
|
||||
image::GrayImage::from_raw(width, height, levels)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
|
||||
} else {
|
||||
image::GrayAlphaImage::from_raw(width, height, gray_alpha)?.write_to(&mut out, image::ImageFormat::Png).ok()?;
|
||||
}
|
||||
Some(Self::png(png))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a grayscale image's file stores its pixels ([`ImageBlob::gray_source`]).
|
||||
|
||||
+394
-45
@@ -1,13 +1,12 @@
|
||||
//! Where a path's strokes paint, from the path's geometry without building their outlines: the
|
||||
//! visual bounds and the clicks of a stroke take in its alignment, its width profile, miter spikes
|
||||
//! up to the miter limit, projecting caps and arrowheads.
|
||||
//! up to the miter limit, projecting caps and arrowheads. Butt caps end at the path's end points.
|
||||
|
||||
use vectorcraft_geom::bez::{CORNER_COS, has_length, kink, segments, subpaths, tangent, unit};
|
||||
use vectorcraft_geom::hit::fill_contains;
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
use vectorcraft_geom::kurbo::{ParamCurveArclen, ParamCurveExtrema, ParamCurveNearest};
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathSeg, Point, Rect, Shape, Vec2};
|
||||
|
||||
use crate::appearance::HEAD_REACH;
|
||||
use crate::{Appearance, ArrowAlign, Dash, LineCap, LineJoin, StrokeAlign, StrokeLayer, WidthProfile};
|
||||
|
||||
/// Arc-length accuracy for placing a point along a profile (document points).
|
||||
@@ -101,6 +100,82 @@ fn profile_at(profile: &WidthProfile, segs: &[PathSeg], closed: bool, i: usize,
|
||||
w
|
||||
}
|
||||
|
||||
/// Does the curvature of `seg` stay below one over `side` all along it? A curve's first derivative
|
||||
/// is a mix of its three control differences, and its second a mix of their two differences, which
|
||||
/// bound the curvature from above. Then the edges of a stroke reaching `side` from it do not fold
|
||||
/// back, and those edges reach farthest at the ends of `seg` or where its tangent is square to an
|
||||
/// axis.
|
||||
fn gentle(seg: &PathSeg, side: f64) -> bool {
|
||||
let c = seg.to_cubic();
|
||||
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2].map(|v| v * 3.0);
|
||||
let along = unit(d[0] + d[1] + d[2]);
|
||||
let slowest = d.iter().map(|v| v.dot(along)).fold(f64::INFINITY, f64::min);
|
||||
let bend = 2.0 * (d[1] - d[0]).hypot().max((d[2] - d[1]).hypot());
|
||||
slowest > 0.0 && side * bend < slowest * slowest
|
||||
}
|
||||
|
||||
/// The box a stroke reaching `side` from `seg` paints across it, from the directions its tangent
|
||||
/// can take: the tangent is a mix of the nonzero control differences, so when their directions all
|
||||
/// lie within a quarter turn of the directions' sum, every point across the stroke is in `seg`'s
|
||||
/// box moved `side` along a normal of one of those directions. `None` when they do not. A handle on
|
||||
/// its end point adds no direction, so a straight curve with one measures as a line.
|
||||
fn turning_box(seg: &PathSeg, side: f64) -> Option<Rect> {
|
||||
let c = seg.to_cubic();
|
||||
let d = [c.p1 - c.p0, c.p2 - c.p1, c.p3 - c.p2];
|
||||
let moving = || d.iter().map(|v| (*v, v.hypot())).filter(|(_, h)| *h > 0.0).map(|(v, h)| v / h);
|
||||
let along = unit(moving().fold(Vec2::ZERO, |s, v| s + v));
|
||||
if moving().any(|v| v.dot(along) <= 0.0) {
|
||||
return None;
|
||||
}
|
||||
let angle = |v: &Vec2| along.cross(*v).atan2(along.dot(*v));
|
||||
let lo = moving().min_by(|a, b| angle(a).total_cmp(&angle(b)))?;
|
||||
let hi = moving().max_by(|a, b| angle(a).total_cmp(&angle(b)))?;
|
||||
let n = |t: Vec2| (Vec2::new(t.y, -t.x) * side).to_point();
|
||||
let one_side = round_join_box(Point::ZERO, lo, hi, side).unwrap_or_else(|| Rect::from_points(n(lo), n(hi)));
|
||||
let reach = one_side.union(Rect::new(-one_side.x1, -one_side.y1, -one_side.x0, -one_side.y0));
|
||||
let b = ParamCurveExtrema::bounding_box(seg);
|
||||
Some(Rect::new(b.x0 + reach.x0, b.y0 + reach.y0, b.x1 + reach.x1, b.y1 + reach.y1))
|
||||
}
|
||||
|
||||
/// The box a stroke reaching `side` from `seg` paints when it stops square at both ends of `seg`:
|
||||
/// the corners at the ends and the points across the stroke where the tangent is square to an axis.
|
||||
/// A curve that bends tighter than `side` is halved up to `depth` times, and a piece still bending
|
||||
/// that much is measured by [`turning_box`], or grown by `side` on every side when its tangent
|
||||
/// turns too far.
|
||||
fn swept_box(seg: &PathSeg, side: f64, depth: u32) -> Rect {
|
||||
if !matches!(seg, PathSeg::Line(_)) && !gentle(seg, side) {
|
||||
if depth == 0 {
|
||||
return turning_box(seg, side).unwrap_or_else(|| ParamCurveExtrema::bounding_box(seg).inflate(side, side));
|
||||
}
|
||||
return swept_box(&seg.subsegment(0.0..0.5), side, depth - 1).union(swept_box(&seg.subsegment(0.5..1.0), side, depth - 1));
|
||||
}
|
||||
let across = |t: f64| {
|
||||
let (p, tn) = (seg.eval(t), tangent(seg, t));
|
||||
let n = Vec2::new(-tn.y, tn.x) * side;
|
||||
[p + n, p - n]
|
||||
};
|
||||
let [a, b] = across(0.0);
|
||||
seg.extrema().into_iter().chain([1.0]).flat_map(across).fold(Rect::from_points(a, b), |r, p| r.union_pt(p))
|
||||
}
|
||||
|
||||
/// The box of the arc a round join paints `side` from `at`, where the path turns from tangent
|
||||
/// `t_in` to `t_out`: the arc runs on the outer side of the turn, and its box comes from its ends
|
||||
/// and the points on it square to an axis. A joint where the path turns back on itself is given
|
||||
/// the whole disc's box, and one where it does not turn paints no arc (`None`).
|
||||
fn round_join_box(at: Point, t_in: Vec2, t_out: Vec2, side: f64) -> Option<Rect> {
|
||||
let (t_in, t_out) = (unit(t_in), unit(t_out));
|
||||
let turn = t_in.cross(t_out);
|
||||
if turn.abs() < 1e-12 {
|
||||
return (t_in.dot(t_out) < 0.0).then(|| Rect::from_center_size(at, (2.0 * side, 2.0 * side)));
|
||||
}
|
||||
let s = turn.signum();
|
||||
let outer = |t: Vec2| Vec2::new(t.y, -t.x) * s;
|
||||
let (a, b) = (outer(t_in), outer(t_out));
|
||||
let axes = [Vec2::new(1.0, 0.0), Vec2::new(-1.0, 0.0), Vec2::new(0.0, 1.0), Vec2::new(0.0, -1.0)];
|
||||
let arc = Rect::from_points(at + a * side, at + b * side);
|
||||
Some(axes.into_iter().filter(|d| a.cross(*d) * s >= 0.0 && d.cross(b) * s >= 0.0).fold(arc, |r, d| r.union_pt(at + d * side)))
|
||||
}
|
||||
|
||||
/// Is `p` inside the convex polygon `pts` (either winding), or within `tol` of it?
|
||||
fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
|
||||
let n = pts.len();
|
||||
@@ -115,39 +190,81 @@ fn in_convex(pts: &[Point], p: Point, tol: f64) -> bool {
|
||||
|
||||
impl StrokeLayer {
|
||||
/// Does bounding this stroke need its path's shape: miter spikes, projecting caps, arrowheads,
|
||||
/// or an alignment that depends on whether the path is closed? Else it reaches evenly round it.
|
||||
/// an alignment that depends on whether the path is closed, or butt caps, which stop at the
|
||||
/// ends of an open path? Else it reaches evenly round it.
|
||||
fn needs_shape(&self) -> bool {
|
||||
self.join == LineJoin::Miter
|
||||
|| self.cap == LineCap::Square
|
||||
|| self.cap != LineCap::Round
|
||||
|| self.start_arrow.is_some()
|
||||
|| self.end_arrow.is_some()
|
||||
|| self.align != StrokeAlign::Center
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
|
||||
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
|
||||
/// The box this stroke paints along `segs`, the segments with length of an open subpath, when
|
||||
/// its caps are butt caps: each segment's [`swept_box`] and, with round joins, the arc each
|
||||
/// joint between segments paints ([`round_join_box`]), a shallow one included.
|
||||
/// `None` when `segs` is empty. Butt dashes stop inside the same box. The heads and miter tips
|
||||
/// are measured apart. A segment in the middle is measured only when its own box grown by
|
||||
/// `side`, which holds everything it paints, is not inside the box so far.
|
||||
fn butt_body(&self, segs: &[PathSeg], side: f64) -> Option<Rect> {
|
||||
let (first, last) = (segs.first()?, segs.last()?);
|
||||
let ends = swept_box(first, side, 3).union(swept_box(last, side, 3));
|
||||
let body = segs.iter().skip(1).take(segs.len().saturating_sub(2)).fold(ends, |b, s| {
|
||||
if b.contains_rect(ParamCurveExtrema::bounding_box(s).inflate(side, side)) { b } else { b.union(swept_box(s, side, 3)) }
|
||||
});
|
||||
if self.join != LineJoin::Round {
|
||||
return Some(body);
|
||||
}
|
||||
Some(joints(segs, false).fold(body, |b, (at, t_in, t_out)| round_join_box(at, t_in, t_out, side).map_or(b, |j| b.union(j))))
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `bp` (geometric bounds `bounds`): an open subpath with butt
|
||||
/// caps by [`Self::butt_body`], every other subpath by the box of its segments grown by its
|
||||
/// reach, with `tips` and, when `measure_tips`, the miter spikes the limit keeps, the corners of
|
||||
/// projecting caps and the reach of the arrowheads round their end points.
|
||||
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
|
||||
fn reach_along(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>, measure_tips: bool) -> Rect {
|
||||
let els = bp.elements();
|
||||
let mut out = bounds;
|
||||
let subs = subpaths(bp);
|
||||
let mut out = tips.map_or(bounds, |t| bounds.union(t));
|
||||
let mut side_max: f64 = 0.0;
|
||||
// Every subpath reaches evenly unless one is open and butt-capped; only then is each
|
||||
// subpath's own box needed.
|
||||
let even_only = self.cap != LineCap::Butt || subs.iter().all(|s| s.1);
|
||||
let mut even = even_only.then_some(bounds);
|
||||
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
|
||||
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
for (r, closed) in subpaths(bp) {
|
||||
for (r, closed) in subs {
|
||||
let side = self.side_reach(closed);
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
let butt = self.cap == LineCap::Butt && !closed;
|
||||
if !butt {
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
}
|
||||
if side <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let square = self.cap == LineCap::Square && !closed;
|
||||
let miters = measure_tips && self.join == LineJoin::Miter;
|
||||
if !(butt || square || miters || !even_only) {
|
||||
continue;
|
||||
}
|
||||
let segs = drawn(&els[r]);
|
||||
if self.join == LineJoin::Miter {
|
||||
if miters {
|
||||
for (at, t_in, t_out) in joints(&segs, closed) {
|
||||
if let Some([_, tip, _]) = miter(at, t_in, t_out, side, self.miter_limit) {
|
||||
out = out.union_pt(tip);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.cap == LineCap::Square && !closed {
|
||||
if butt {
|
||||
if let Some(body) = self.butt_body(&segs, side) {
|
||||
out = out.union(body);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !even_only {
|
||||
even = vectorcraft_geom::union_opt(even, segs.iter().map(ParamCurveExtrema::bounding_box).reduce(|a, b| a.union(b)));
|
||||
}
|
||||
if square {
|
||||
for (at, dir) in ends(&segs).into_iter().flatten() {
|
||||
let n = Vec2::new(-dir.y, dir.x) * side;
|
||||
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
|
||||
@@ -158,7 +275,15 @@ impl StrokeLayer {
|
||||
let r = self.head_reach(end);
|
||||
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
|
||||
}
|
||||
out.union(bounds.inflate(side_max, side_max))
|
||||
even.map_or(out, |e| out.union(e.inflate(side_max, side_max)))
|
||||
}
|
||||
|
||||
/// The box this stroke paints along `bp`, whose geometric bounds are `bounds`: the bounds grown
|
||||
/// by the body's reach, with the tips of the miter spikes the limit keeps, the corners of
|
||||
/// projecting caps and the reach of the arrowheads round their end points. An open subpath
|
||||
/// with butt caps does not reach past its ends ([`Self::butt_body`]).
|
||||
pub fn bounds_on(&self, bp: &BezPath, bounds: Rect) -> Rect {
|
||||
self.reach_along(bp, bounds, None, true)
|
||||
}
|
||||
|
||||
/// The tip of the miter spike this stroke paints where segment `b` follows segment `a` in a
|
||||
@@ -176,30 +301,7 @@ impl StrokeLayer {
|
||||
/// every joint of `bp` ([`Self::miter_tip`]) or `None` when there are none, instead of measured
|
||||
/// along `bp`. It measures the projecting caps and arrowheads as [`Self::bounds_on`] does.
|
||||
pub fn bounds_with_tips(&self, bp: &BezPath, bounds: Rect, tips: Option<Rect>) -> Rect {
|
||||
let els = bp.elements();
|
||||
let mut out = tips.map_or(bounds, |t| bounds.union(t));
|
||||
let mut side_max: f64 = 0.0;
|
||||
// Dashes put a cap on every dash: projecting ones reach a corner's diagonal anywhere.
|
||||
let capped_dashes = self.cap == LineCap::Square && self.dash.as_ref().is_some_and(Dash::is_dashed);
|
||||
for (r, closed) in subpaths(bp) {
|
||||
let side = self.side_reach(closed);
|
||||
side_max = side_max.max(if capped_dashes { side * std::f64::consts::SQRT_2 } else { side });
|
||||
if side <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
if self.cap == LineCap::Square && !closed {
|
||||
let segs = drawn(&els[r]);
|
||||
for (at, dir) in ends(&segs).into_iter().flatten() {
|
||||
let n = Vec2::new(-dir.y, dir.x) * side;
|
||||
out = out.union_pt(at + dir * side + n).union_pt(at + dir * side - n);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (at, _, end) in head_ends(self, bp) {
|
||||
let r = self.head_reach(end);
|
||||
out = out.union(Rect::from_center_size(at, (2.0 * r, 2.0 * r)));
|
||||
}
|
||||
out.union(bounds.inflate(side_max, side_max))
|
||||
self.reach_along(bp, bounds, tips, false)
|
||||
}
|
||||
|
||||
/// Does this stroke along `bp` (filled with `rule`) paint within `tol` of `p`? Inside and
|
||||
@@ -283,18 +385,19 @@ impl StrokeLayer {
|
||||
}
|
||||
|
||||
/// Is `p` within `tol` of where the start (`end == false`) or end arrowhead at `at` (the path
|
||||
/// leaving along `out`) can paint? Heads are at most about 4.6 weights long and 4 wide. With
|
||||
/// leaving along `out`) can paint? A head reaches [`crate::Arrowhead::reach`] weights from its tip and
|
||||
/// [`crate::Arrowhead::half_width`] either side of its line (4.6 and 2 for most). With
|
||||
/// [`ArrowAlign::Extend`] a head points along `out` with its tip up to its length (or the cap)
|
||||
/// past the end; with [`ArrowAlign::Tip`] its tip is on the end and it lies back along the
|
||||
/// chord the path bends through under it, so anywhere within its length of the end.
|
||||
fn head_contains(&self, at: Point, out: Vec2, end: bool, p: Point, tol: f64) -> bool {
|
||||
let hw = self.arrow_weight(end);
|
||||
let v = p - at;
|
||||
let Some(kind) = (if end { self.end_arrow } else { self.start_arrow }) else { return false };
|
||||
let (hw, v) = (self.arrow_weight(end), p - at);
|
||||
match self.arrow_align {
|
||||
ArrowAlign::Tip => v.hypot() <= HEAD_REACH * hw + tol,
|
||||
ArrowAlign::Tip => v.hypot() <= kind.reach() * hw + tol,
|
||||
ArrowAlign::Extend => {
|
||||
let along = v.dot(out);
|
||||
v.cross(out).abs() <= 2.0 * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -HEAD_REACH * hw - tol
|
||||
v.cross(out).abs() <= kind.half_width() * hw + tol && along <= 4.0 * hw + self.width / 2.0 + tol && along >= -kind.reach() * hw - tol
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +417,7 @@ impl Appearance {
|
||||
}
|
||||
|
||||
/// [`Self::stroked_bounds`] with the box of each visible stroke that needs the path's shape
|
||||
/// (miter spikes, projecting caps, arrowheads or an alignment) given by `shaped`.
|
||||
/// (miter spikes, projecting or butt caps, arrowheads or an alignment) given by `shaped`.
|
||||
pub fn stroked_bounds_by(&self, bounds: Rect, mut shaped: impl FnMut(&StrokeLayer) -> Rect) -> Rect {
|
||||
self.painted_strokes().fold(bounds, |acc, s| {
|
||||
if !s.needs_shape() {
|
||||
@@ -325,6 +428,30 @@ impl Appearance {
|
||||
})
|
||||
}
|
||||
|
||||
/// Does this appearance have a visible butt-capped stroke?
|
||||
pub fn has_butt_stroke(&self) -> bool {
|
||||
self.painted_strokes().any(|s| s.cap == LineCap::Butt)
|
||||
}
|
||||
|
||||
/// Does a visible butt-capped stroke of this appearance run along an open subpath of `bp` with
|
||||
/// a segment that starts or ends at a slant, not square to the axes, once placed by `a`? The
|
||||
/// visual box's margin at the stroke's corners there depends on their direction, which a scale
|
||||
/// out of proportion changes.
|
||||
pub fn has_slanted_butt_stroke(&self, bp: &BezPath, a: vectorcraft_geom::Affine) -> bool {
|
||||
if !self.has_butt_stroke() {
|
||||
return false;
|
||||
}
|
||||
let slanted = |v: Vec2| {
|
||||
let d = unit(a * v.to_point() - a * Point::ZERO);
|
||||
(d.x * d.y).abs() > 1e-9
|
||||
};
|
||||
let els = bp.elements();
|
||||
subpaths(bp)
|
||||
.into_iter()
|
||||
.filter(|(_, closed)| !closed)
|
||||
.any(|(r, _)| drawn(&els[r]).iter().any(|s| slanted(tangent(s, 0.0)) || slanted(tangent(s, 1.0))))
|
||||
}
|
||||
|
||||
/// Does a visible stroke of this appearance along `bp` (filled with `rule`) paint within
|
||||
/// `tol` of `p` ([`StrokeLayer::hits`])?
|
||||
pub fn stroke_hit(&self, bp: &BezPath, rule: FillRule, p: Point, tol: f64) -> bool {
|
||||
@@ -332,6 +459,31 @@ impl Appearance {
|
||||
}
|
||||
}
|
||||
|
||||
/// How many symbol instances [`crate::Node::has_slanted_butt_stroke`] looks into for one object
|
||||
/// before it answers yes without looking further.
|
||||
pub const SLANT_INSTANCES: usize = 4096;
|
||||
|
||||
impl crate::Node {
|
||||
/// Does a visible butt-capped stroke of this object, of an object in it or of an object in a
|
||||
/// symbol instance's art from `symbols`, placed by `a`, run along an open path with a segment
|
||||
/// at a slant ([`Appearance::has_slanted_butt_stroke`])? Past [`SLANT_INSTANCES`] instances it
|
||||
/// answers yes.
|
||||
pub fn has_slanted_butt_stroke(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine) -> bool {
|
||||
self.slanted_butt(symbols, a, &std::cell::Cell::new(SLANT_INSTANCES))
|
||||
}
|
||||
|
||||
/// [`Self::has_slanted_butt_stroke`] with `left` instances still to look into.
|
||||
fn slanted_butt(&self, symbols: &crate::SymbolBoxes, a: vectorcraft_geom::Affine, left: &std::cell::Cell<usize>) -> bool {
|
||||
if let crate::NodeKind::SymbolInstance { symbol, xf } = &self.kind {
|
||||
let Some(l) = left.get().checked_sub(1) else { return true };
|
||||
left.set(l);
|
||||
return symbols.measure_art(symbol, |art| art.slanted_butt(symbols, a * *xf, left)).unwrap_or(false);
|
||||
}
|
||||
(self.appearance.has_butt_stroke() && self.stroke_path().is_some_and(|bp| self.appearance.has_slanted_butt_stroke(&bp, a)))
|
||||
|| self.children().is_some_and(|c| c.iter().any(|c| c.slanted_butt(symbols, a, left)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -378,7 +530,7 @@ mod tests {
|
||||
let (small, big) = (at(100.0).bounds_on(&l, b), at(300.0).bounds_on(&l, b));
|
||||
assert!(big.x1 > small.x1 + 15.0 && big.y1 > small.y1 + 7.0, "{small:?} → {big:?}");
|
||||
// A 300% head on a 2 pt stroke is 24 pt long and wide: its tip, past the end, is inside.
|
||||
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == -1.0, "{big:?}");
|
||||
assert!(big.x1 >= 100.0 + 24.0 && big.y1 >= 12.0 && big.x0 == 0.0, "{big:?}");
|
||||
// The start has no head.
|
||||
assert_eq!(at(300.0).head_reach(false), 0.0);
|
||||
}
|
||||
@@ -405,6 +557,203 @@ mod tests {
|
||||
assert_eq!(stroke(4.0, |s| s.align = StrokeAlign::Outside).bounds_on(&sq, b), b.inflate(4.0, 4.0));
|
||||
}
|
||||
|
||||
/// An open path through `points`.
|
||||
fn polyline(points: &[(f64, f64)]) -> BezPath {
|
||||
let mut bp = BezPath::new();
|
||||
for (i, p) in points.iter().enumerate() {
|
||||
if i == 0 { bp.move_to(*p) } else { bp.line_to(*p) }
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn butt_caps_stop_at_the_ends_of_an_open_path() {
|
||||
let butt = |f: fn(&mut StrokeLayer)| {
|
||||
stroke(2.0, |s| {
|
||||
s.cap = LineCap::Butt;
|
||||
f(s);
|
||||
})
|
||||
};
|
||||
// A vertical line reaches sideways only.
|
||||
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
|
||||
assert_eq!(butt(|_| {}).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
|
||||
// A 3-4-5 diagonal: its corners reach 0.8 pt across x and 0.6 pt across y.
|
||||
let d = polyline(&[(0.0, 0.0), (30.0, 40.0)]);
|
||||
let b = butt(|_| {}).bounds_on(&d, d.bounding_box());
|
||||
for (got, want) in [(b.x0, -0.8), (b.y0, -0.6), (b.x1, 30.8), (b.y1, 40.6)] {
|
||||
assert!((got - want).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
// The dashes of a butt stroke stop inside the line as well, and an open path ignores the
|
||||
// alignment.
|
||||
let dashed = butt(|s| s.dash = Some(Dash { pattern: vec![4.0, 2.0], offset: 0.0, align_corners: false }));
|
||||
assert_eq!(dashed.bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
|
||||
assert_eq!(butt(|s| s.align = StrokeAlign::Inside).bounds_on(&v, v.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
|
||||
// A round cap reaches half the weight past each end, a square one the corners.
|
||||
let round = butt(|s| s.cap = LineCap::Round).bounds_on(&v, v.bounding_box());
|
||||
assert_eq!(round, Rect::new(49.0, 9.0, 51.0, 61.0));
|
||||
let square = butt(|s| s.cap = LineCap::Square).bounds_on(&v, v.bounding_box());
|
||||
assert_eq!(square, Rect::new(49.0, 9.0, 51.0, 61.0));
|
||||
// Two strokes union.
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Butt;
|
||||
let wide = stroke(10.0, |s| s.cap = LineCap::Round);
|
||||
ap.items.push(crate::AppearanceItem::Stroke(wide));
|
||||
let vb = v.bounding_box();
|
||||
assert_eq!(ap.stroked_bounds(vb, || v.clone()), Rect::new(45.0, 5.0, 55.0, 65.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_butt_paths_joins_and_curves_stay_inside_the_box() {
|
||||
// A corner in the middle: the round join reaches a full half weight straight down, outside
|
||||
// the turn, the ends do not, and a miter reaches its tip.
|
||||
let zig = polyline(&[(0.0, 0.0), (20.0, 20.0), (40.0, 0.0)]);
|
||||
let round = stroke(4.0, |s| {
|
||||
s.cap = LineCap::Butt;
|
||||
s.join = LineJoin::Round;
|
||||
})
|
||||
.bounds_on(&zig, zig.bounding_box());
|
||||
assert!((round.y1 - 22.0).abs() < 1e-9, "the round join's reach: {round:?}");
|
||||
assert!((round.x0 + 2.0_f64.sqrt()).abs() < 1e-9 && (round.x1 - 40.0 - 2.0_f64.sqrt()).abs() < 1e-9, "the ends' corners: {round:?}");
|
||||
// A round join paints its arc on the outer side of a turn too shallow to count as a
|
||||
// corner, and nothing where the path goes straight on.
|
||||
let round_butt = stroke(4.0, |s| {
|
||||
s.cap = LineCap::Butt;
|
||||
s.join = LineJoin::Round;
|
||||
});
|
||||
let shallow = polyline(&[(0.0, 0.25), (50.0, 0.0), (100.0, 0.25)]);
|
||||
let b = round_butt.bounds_on(&shallow, shallow.bounding_box());
|
||||
assert!((b.y0 + 2.0).abs() < 1e-12, "{b:?}");
|
||||
let straight = polyline(&[(0.0, 0.0), (0.1, 0.0), (100.0, 0.0)]);
|
||||
assert_eq!(round_butt.bounds_on(&straight, straight.bounding_box()), Rect::new(0.0, -2.0, 100.0, 2.0));
|
||||
let miter = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&zig, zig.bounding_box());
|
||||
assert!((miter.y1 - (20.0 + 2.0 * 2.0_f64.sqrt())).abs() < 1e-9, "the miter tip: {miter:?}");
|
||||
// An arc over the top whose ends leave the page straight down: the top reaches half the
|
||||
// weight past the path, and the bottom stops at the ends.
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to((0.0, 50.0));
|
||||
arc.curve_to((0.0, 22.4), (22.4, 0.0), (50.0, 0.0));
|
||||
arc.curve_to((77.6, 0.0), (100.0, 22.4), (100.0, 50.0));
|
||||
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&arc, arc.bounding_box());
|
||||
assert!((b.y0 + 2.0).abs() < 1e-9 && (b.x0 + 2.0).abs() < 1e-9 && (b.x1 - 102.0).abs() < 1e-9, "{b:?}");
|
||||
assert!((b.y1 - 50.0).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_butt_curve_with_a_handle_on_its_end_point_stops_near_that_end() {
|
||||
let butt = stroke(2.0, |s| s.cap = LineCap::Butt);
|
||||
// A straight curve whose first or last handle sits on its end point measures like a line.
|
||||
let mut first = BezPath::new();
|
||||
first.move_to((50.0, 10.0));
|
||||
first.curve_to((50.0, 10.0), (50.0, 40.0), (50.0, 60.0));
|
||||
let mut last = BezPath::new();
|
||||
last.move_to((50.0, 10.0));
|
||||
last.curve_to((50.0, 20.0), (50.0, 60.0), (50.0, 60.0));
|
||||
for bp in [first, last] {
|
||||
assert_eq!(butt.bounds_on(&bp, bp.bounding_box()), Rect::new(49.0, 10.0, 51.0, 60.0));
|
||||
}
|
||||
// A bend that leaves its start straight up from a handle on the start point: the stroke
|
||||
// reaches only a little below the start, not half the weight.
|
||||
let mut bend = BezPath::new();
|
||||
bend.move_to((0.0, 50.0));
|
||||
bend.curve_to((0.0, 50.0), (0.0, 0.0), (50.0, 0.0));
|
||||
let b = stroke(4.0, |s| s.cap = LineCap::Butt).bounds_on(&bend, bend.bounding_box());
|
||||
assert!(b.y1 >= 50.0 && b.y1 < 50.2, "{b:?}");
|
||||
assert!((b.x0 + 2.0).abs() < 1e-9 && (b.y0 + 2.0).abs() < 1e-9 && (b.x1 - 50.0).abs() < 1e-9, "{b:?}");
|
||||
// Nearly a cusp: the curve turns back at its lowest point, where its tangent is level and
|
||||
// the stroke reaches the full half weight below it.
|
||||
let (a0, a1, a2) = (1e5 * 81.0 / 256.0, -1e5 * 9.0 / 8.0, 1e5);
|
||||
let x = |i: f64| 2e-9 * i / 3.0;
|
||||
let mut cusp = BezPath::new();
|
||||
cusp.move_to((x(0.0), a0));
|
||||
cusp.curve_to((x(1.0), a0 + a1 / 3.0), (x(2.0), a0 + 2.0 * a1 / 3.0 + a2 / 3.0), (x(3.0), a0 + a1 + a2));
|
||||
let b = butt.bounds_on(&cusp, cusp.bounding_box());
|
||||
assert!(b.y0 <= -1.0 + 1e-6, "{b:?}");
|
||||
// A curve far shorter than the weight still reaches half the weight above where its
|
||||
// tangent is level.
|
||||
let mut tiny = BezPath::new();
|
||||
tiny.move_to((0.0, 0.9e-12));
|
||||
tiny.curve_to((0.3e-12, -0.5e-12), (1.7e-12, -0.2e-12), (2e-12, 1e-12));
|
||||
let b = butt.bounds_on(&tiny, tiny.bounding_box());
|
||||
assert!(b.y0 <= -1.0 + 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_butt_stroke_is_slanted_where_a_segment_starts_or_ends_at_a_slant() {
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Butt;
|
||||
let id = vectorcraft_geom::Affine::IDENTITY;
|
||||
assert!(!ap.has_slanted_butt_stroke(&polyline(&[(0.0, 0.0), (0.0, 50.0), (30.0, 50.0)]), id));
|
||||
// Square ends, slanted segments in the middle.
|
||||
let apex = polyline(&[(0.0, 50.0), (0.0, 40.0), (10.0, 0.0), (20.0, 40.0), (20.0, 50.0)]);
|
||||
assert!(ap.has_slanted_butt_stroke(&apex, id));
|
||||
// A rotation slants a vertical line; a round cap is never slanted.
|
||||
let v = polyline(&[(0.0, 0.0), (0.0, 50.0)]);
|
||||
assert!(ap.has_slanted_butt_stroke(&v, vectorcraft_geom::Affine::rotate(0.3)));
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Round;
|
||||
assert!(!ap.has_slanted_butt_stroke(&apex, id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slant_check_looks_into_at_most_4096_symbol_instances() {
|
||||
// S0 is a vertical butt line. Each of S1 to S12 holds two instances of the symbol before
|
||||
// it, so an instance of S12 holds 8,191 instances and none of them is slanted. Past 4,096
|
||||
// the check answers yes.
|
||||
let mut d = Document::new(400.0, 400.0);
|
||||
let mut ap = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0);
|
||||
ap.stroke_mut().unwrap().cap = LineCap::Butt;
|
||||
let line = Node::path(d.alloc_id(), PathData::from_bezpath(&polyline(&[(0.0, 0.0), (0.0, 10.0)])), ap);
|
||||
d.symbols.push(crate::Symbol { dynamic: false, name: "S0".into(), art: std::sync::Arc::new(line) });
|
||||
for k in 1..=12 {
|
||||
let mut instance = |dx: f64| {
|
||||
let xf = vectorcraft_geom::Affine::translate((dx, 0.0));
|
||||
std::sync::Arc::new(Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: format!("S{}", k - 1), xf }))
|
||||
};
|
||||
let pair = vec![instance(0.0), instance(20.0)];
|
||||
let art = Node::group(d.alloc_id(), pair);
|
||||
d.symbols.push(crate::Symbol { dynamic: false, name: format!("S{k}"), art: std::sync::Arc::new(art) });
|
||||
}
|
||||
let top = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S12".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
let symbols = d.symbol_boxes();
|
||||
assert!(top.has_slanted_butt_stroke(&symbols, vectorcraft_geom::Affine::IDENTITY));
|
||||
assert!(symbols.art_measured() <= SLANT_INSTANCES, "{}", symbols.art_measured());
|
||||
// One instance of S1 holds three instances, all looked into.
|
||||
let one = Node::new(d.alloc_id(), crate::NodeKind::SymbolInstance { symbol: "S1".into(), xf: vectorcraft_geom::Affine::IDENTITY });
|
||||
assert!(!one.has_slanted_butt_stroke(&d.symbol_boxes(), vectorcraft_geom::Affine::IDENTITY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_compound_path_measures_each_of_its_subpaths_by_its_own_ends() {
|
||||
// A closed square at the top and an open line below it: only the square reaches past its
|
||||
// own edges.
|
||||
let mut bp = BezPath::new();
|
||||
bp.extend(square().elements().iter().copied());
|
||||
bp.move_to((50.0, 150.0));
|
||||
bp.line_to((50.0, 200.0));
|
||||
let st = stroke(4.0, |s| s.cap = LineCap::Butt);
|
||||
assert_eq!(st.bounds_on(&bp, bp.bounding_box()), Rect::new(-2.0, -2.0, 102.0, 200.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_middle_segment_can_set_the_box_of_a_butt_path() {
|
||||
// The flat middle segment reaches a full half weight above the path, and the ends' corners
|
||||
// reach 10/51 of it below.
|
||||
let hat = polyline(&[(0.0, 50.0), (10.0, 0.0), (20.0, 0.0), (30.0, 50.0)]);
|
||||
let b = stroke(2.0, |s| {
|
||||
s.cap = LineCap::Butt;
|
||||
s.join = LineJoin::Bevel;
|
||||
})
|
||||
.bounds_on(&hat, hat.bounding_box());
|
||||
let corner = 10.0 / (10.0_f64 * 10.0 + 50.0 * 50.0).sqrt();
|
||||
assert!((b.y0 + 1.0).abs() < 1e-9 && (b.y1 - 50.0 - corner).abs() < 1e-9, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_with_tips_measures_a_butt_line_as_bounds_on_does() {
|
||||
let v = polyline(&[(50.0, 10.0), (50.0, 60.0)]);
|
||||
let st = stroke(2.0, |s| s.cap = LineCap::Butt);
|
||||
assert_eq!(st.bounds_with_tips(&v, v.bounding_box(), None), Rect::new(49.0, 10.0, 51.0, 60.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_hit_outside_strokes_on_their_side_only() {
|
||||
let sq = square();
|
||||
|
||||
@@ -158,6 +158,8 @@ pub const STYLE_LIBRARIES: &[BuiltinStyleLibrary] = &[
|
||||
BuiltinStyleLibrary { id: "gradient-finishes", name: "Gradient Finishes", make: gradient_finishes },
|
||||
BuiltinStyleLibrary { id: "shape-effects", name: "Shape Effects", make: shape_effects },
|
||||
BuiltinStyleLibrary { id: "blends-transparency", name: "Blends and Transparency", make: blends_transparency },
|
||||
BuiltinStyleLibrary { id: "game-hud", name: "Game HUD", make: game_hud },
|
||||
BuiltinStyleLibrary { id: "status-effects", name: "Status Effects", make: status_effects },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -453,6 +455,71 @@ fn blends_transparency() -> Vec<GraphicStyle> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Game HUD: fills and glows for health, mana, damage and quest chrome (original arcade/HUD look).
|
||||
fn game_hud() -> Vec<GraphicStyle> {
|
||||
vec![
|
||||
style("Health Fill", vec![F(fill("#e74c3c")), S(stroke("#5c1a1a", 1.25))], vec![]),
|
||||
style(
|
||||
"Mana Glow",
|
||||
vec![F(fill("#3498db"))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 7.0, "color": "#5dade2"}))],
|
||||
),
|
||||
style(
|
||||
"Damage Flash",
|
||||
vec![F(fill("#ff6b35"))],
|
||||
vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 80.0, "blur": 8.0, "color": "#ffe66d", "source": "edge"}))],
|
||||
),
|
||||
style("Quest Outline", vec![F(fill("#f7f3e8")), S(round("#2c3e50", 3.0))], vec![]),
|
||||
style(
|
||||
"Coin Shine",
|
||||
vec![F(fill("#f1c40f")), S(stroke("#b7950b", 1.5))],
|
||||
vec![fx("stylize.dropShadow", json!({"mode": "multiply", "opacity": 40.0, "x": 2.0, "y": 3.0, "blur": 4.0, "color": "#1d1b2a"}))],
|
||||
),
|
||||
style(
|
||||
"Boss Alert",
|
||||
vec![F(fill("#1a1a2e")), S(round("#e94560", 2.5))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 6.0, "color": "#e94560"}))],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Status Effects: buffs and debuffs for RPG/gamedev art (original, catalogue stylize effects only).
|
||||
fn status_effects() -> Vec<GraphicStyle> {
|
||||
let stealth = GraphicStyle {
|
||||
opacity: 0.45,
|
||||
blend: BlendMode::Normal,
|
||||
..style("Stealth Fade", vec![F(fill("#7f8c8d")), S(stroke("#2c3e50", 1.0))], vec![])
|
||||
};
|
||||
vec![
|
||||
style(
|
||||
"Poison Tint",
|
||||
vec![F(fill("#2ecc71"))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 80.0, "blur": 6.0, "color": "#58d68d"}))],
|
||||
),
|
||||
style(
|
||||
"Freeze Edge",
|
||||
vec![F(fill("#aed6f1")), S(stroke("#5dade2", 2.0))],
|
||||
vec![fx("stylize.innerGlow", json!({"mode": "screen", "opacity": 70.0, "blur": 5.0, "color": "#d6eaf8", "source": "edge"}))],
|
||||
),
|
||||
style(
|
||||
"Burn Aura",
|
||||
vec![F(fill("#e67e22"))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 85.0, "blur": 8.0, "color": "#f39c12"}))],
|
||||
),
|
||||
style(
|
||||
"Stun Pulse",
|
||||
vec![F(fill("#f4d03f"))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 75.0, "blur": 7.0, "color": "#f7dc6f"}))],
|
||||
),
|
||||
stealth,
|
||||
style(
|
||||
"Shield Buff",
|
||||
vec![F(fill("#85c1e9")), S(round("#2874a6", 2.0))],
|
||||
vec![fx("stylize.outerGlow", json!({"mode": "normal", "opacity": 70.0, "blur": 5.0, "color": "#5dade2"}))],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -475,6 +542,14 @@ mod tests {
|
||||
}
|
||||
assert!(Arc::ptr_eq(&builtin_style_library("hand-drawn").unwrap(), &builtin_style_library("hand-drawn").unwrap()), "made once");
|
||||
assert!(builtin_style_library("nope").is_none());
|
||||
let game = builtin_style_library("game-hud").unwrap();
|
||||
assert_eq!(game.name, "Game HUD");
|
||||
let names: Vec<&str> = game.styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(names, ["Health Fill", "Mana Glow", "Damage Flash", "Quest Outline", "Coin Shine", "Boss Alert"]);
|
||||
let status = builtin_style_library("status-effects").unwrap();
|
||||
assert_eq!(status.name, "Status Effects");
|
||||
let status_names: Vec<&str> = status.styles.iter().map(|g| g.name.as_str()).collect();
|
||||
assert_eq!(status_names, ["Poison Tint", "Freeze Edge", "Burn Aura", "Stun Pulse", "Stealth Fade", "Shield Buff"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,6 +5,52 @@ mod lookups;
|
||||
mod slices;
|
||||
mod units;
|
||||
|
||||
/// A guide label's text is a layer color's name or `#rrggbb`, and reads back as the same color;
|
||||
/// other spellings, an alpha part and a short form are not labels.
|
||||
#[test]
|
||||
fn guide_label_text_names_a_layer_color_or_rgb() {
|
||||
for (i, (name, _)) in LAYER_COLORS.iter().enumerate() {
|
||||
let c = LayerColor::Preset(u8::try_from(i).unwrap());
|
||||
assert_eq!(c.label_text(), *name);
|
||||
assert_eq!(LayerColor::from_label_text(name), Some(c));
|
||||
}
|
||||
let custom = LayerColor::Custom([0x0a, 0xbc, 0xff]);
|
||||
assert_eq!(custom.label_text(), "#0abcff");
|
||||
assert_eq!(LayerColor::from_label_text("#0ABCff"), Some(custom));
|
||||
// A preset past the layer colors has no name: its text is the color it draws in.
|
||||
let [r, g, b] = LayerColor::Preset(200).rgb();
|
||||
assert_eq!(LayerColor::Preset(200).label_text(), format!("#{r:02x}{g:02x}{b:02x}"));
|
||||
for bad in ["red", "Red ", "", "#", "#fff", "#0abcff00", "0abcff", "#0abcfg", "#0abcff"] {
|
||||
assert_eq!(LayerColor::from_label_text(bad), None, "{bad:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An artboard's guide label: none unless set, left out of the JSON when none, and a preset past
|
||||
/// the layer colors reading as none.
|
||||
#[test]
|
||||
fn artboard_guide_label_defaults_and_skips_in_json() {
|
||||
let mut a = Document::new(100.0, 100.0).artboards[0].clone();
|
||||
assert_eq!(a.guide_label, None);
|
||||
let plain = serde_json::to_value(&a).unwrap();
|
||||
assert!(plain.get("guide_label").is_none(), "{plain}");
|
||||
a.guide_label = Some(LayerColor::Custom([1, 2, 3]));
|
||||
let v = serde_json::to_value(&a).unwrap();
|
||||
assert_eq!(v["guide_label"], serde_json::json!({"Custom": [1, 2, 3]}));
|
||||
assert_eq!(serde_json::from_value::<Artboard>(v).unwrap(), a);
|
||||
let read = |label: serde_json::Value| {
|
||||
let mut v = plain.clone();
|
||||
v["guide_label"] = label;
|
||||
serde_json::from_value::<Artboard>(v).map(|a| a.guide_label)
|
||||
};
|
||||
assert_eq!(read(serde_json::json!({"Preset": 26})).unwrap(), Some(LayerColor::Preset(26)));
|
||||
assert_eq!(read(serde_json::json!({"Preset": 27})).unwrap(), None);
|
||||
assert_eq!(read(serde_json::json!({"Preset": 255})).unwrap(), None);
|
||||
assert_eq!(read(serde_json::Value::Null).unwrap(), None);
|
||||
assert!(read(serde_json::json!({"Preset": -1})).is_err());
|
||||
assert!(read(serde_json::json!({"Tint": 1})).is_err());
|
||||
assert_eq!(serde_json::from_value::<Artboard>(plain).unwrap().guide_label, None, "older files have none");
|
||||
}
|
||||
|
||||
/// Zooming in on an inch ruler divided the inch into tenths (#2827). Its steps below an inch
|
||||
/// are binary fractions now, and the minor ticks between them stay binary too; every other
|
||||
/// unit is still read in tenths.
|
||||
@@ -146,6 +192,7 @@ fn art_on_a_locked_artboard_cannot_be_selected_or_edited() {
|
||||
show_video_safe_areas: false,
|
||||
video_ruler_pixel_aspect_ratio: 1.0,
|
||||
color: None,
|
||||
guide_label: None,
|
||||
});
|
||||
let rect = |d: &mut Document, r: Rect, appearance: Appearance| {
|
||||
let id = d.alloc_id();
|
||||
|
||||
@@ -121,6 +121,12 @@ pub struct TextObject {
|
||||
/// vertical column's centre line), filled in with `cached_bounds` (not serialized).
|
||||
#[serde(skip)]
|
||||
pub cached_baselines: Vec<(Point, Point)>,
|
||||
/// Cached ink of the glyphs in text space, underline and strikethrough bars included
|
||||
/// (`vectorcraft_effects::preview::laid_out_ink`, `None` when nothing draws),
|
||||
/// with the `cached_bounds` it was measured with: filled in with `cached_bounds` by the engine's
|
||||
/// text edits, and stale once `cached_bounds` differs ([`Self::ink`]). Not serialized.
|
||||
#[serde(skip)]
|
||||
pub cached_ink: Option<(Rect, Option<Rect>)>,
|
||||
}
|
||||
|
||||
/// Conversion from stored character attributes to document-space type controls.
|
||||
|
||||
@@ -22,10 +22,10 @@ pub struct CharStyle {
|
||||
/// Leading in points; None = Auto (120% of size).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub leading: Option<f64>,
|
||||
/// Tracking in 1/1000 em.
|
||||
/// Tracking in 1/1000 em; in horizontal type, of the em as [`Self::h_scale`] draws it.
|
||||
#[serde(default)]
|
||||
pub tracking: f64,
|
||||
/// Manual pair spacing in 1/1000 em; None uses the automatic mode.
|
||||
/// Manual pair spacing in 1/1000 em, as [`Self::tracking`]; None uses the automatic mode.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kerning: Option<f64>,
|
||||
/// Use glyph shapes for automatic pair spacing; false uses font metrics.
|
||||
|
||||
@@ -71,6 +71,7 @@ impl TextObject {
|
||||
self.xf *= Affine::scale_non_uniform(1.0 / sx, 1.0 / sy);
|
||||
self.cached_bounds = None;
|
||||
self.cached_baselines.clear();
|
||||
self.cached_ink = None;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -97,6 +98,7 @@ impl TextObject {
|
||||
list_level_counts: Vec::new(),
|
||||
cached_bounds: None,
|
||||
cached_baselines: Vec::new(),
|
||||
cached_ink: None,
|
||||
}
|
||||
}
|
||||
/// The list counts the story's earlier frames carry into this frame at each level
|
||||
@@ -288,6 +290,12 @@ impl TextObject {
|
||||
TextKind::Point => Some(self.xf.transform_rect_bbox(self.cached_bounds.unwrap_or_else(|| self.estimate_bounds()))),
|
||||
}
|
||||
}
|
||||
/// The cached ink of the glyphs in text space ([`Self::cached_ink`]): `Some(None)` when nothing
|
||||
/// draws, and `None` when it was not measured with the current `cached_bounds`.
|
||||
pub fn ink(&self) -> Option<Option<Rect>> {
|
||||
let (bounds, ink) = self.cached_ink?;
|
||||
(self.cached_bounds == Some(bounds)).then_some(ink)
|
||||
}
|
||||
/// How far the characters' strokes paint past their glyphs, in document space: they scale
|
||||
/// with the type.
|
||||
pub fn char_stroke_outset(&self) -> f64 {
|
||||
@@ -359,6 +367,24 @@ impl TextObject {
|
||||
pub fn move_path_anchors(&mut self, refs: &[(usize, usize)], d: Vec2) -> bool {
|
||||
matches!(self.kind, TextKind::OnPath { .. }) && self.reshape_local(|path, to_text| move_anchors(path, to_text, refs, d))
|
||||
}
|
||||
/// Move the out (`out`) or in direction handle of anchor `ai` of subpath `si` of type on a
|
||||
/// path's path to `pos` in document space, as a path's anchor takes it
|
||||
/// ([`vectorcraft_geom::Anchor::set_handle`]; `independent`: that side only, the anchor a
|
||||
/// corner): a Direct Selection drag of a handle (#3565). The handle is set in document space,
|
||||
/// so a smooth anchor keeps its other handle's length as drawn; the brackets keep their
|
||||
/// fractions of the path. False, changing nothing, for other type, when the anchor doesn't
|
||||
/// exist, a degenerate `xf` or a path that would not be finite.
|
||||
pub fn set_path_handle(&mut self, (si, ai): (usize, usize), out: bool, pos: Point, independent: bool) -> bool {
|
||||
let xf = self.xf;
|
||||
matches!(self.kind, TextKind::OnPath { .. })
|
||||
&& self.reshape_local(|path, to_text| {
|
||||
let Some(a) = path.anchor_mut(si, ai) else { return false };
|
||||
let mut drawn = a.transform(xf);
|
||||
drawn.set_handle(out, pos, independent);
|
||||
*a = drawn.transform(to_text);
|
||||
true
|
||||
})
|
||||
}
|
||||
/// Edit a copy of the area frame with `f` (given the document → text space map) and keep it
|
||||
/// when `f` succeeds and the result is finite.
|
||||
fn reshape_area(&mut self, f: impl FnOnce(&mut PathData, Affine) -> bool) -> bool {
|
||||
|
||||
@@ -468,12 +468,14 @@ pub fn adjust(n: &Node, outer: Option<&ColorMap>, swatches: &SwatchKinds, images
|
||||
}
|
||||
|
||||
/// [`adjust`] for export and Expand Appearance: recoloured embedded images become new images in
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art.
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
/// `d` (named by their content), and a symbol instance stands for its symbol's art: `symbol_art`
|
||||
/// when given, otherwise the symbol's art with the instance's own appearance changes.
|
||||
pub fn adjust_in_document(d: &mut Document, n: &Node, symbol_art: Option<Node>) -> Option<Node> {
|
||||
let art;
|
||||
let src = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if has_adjustment(n) => {
|
||||
let symbol = d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n));
|
||||
let symbol = symbol_art
|
||||
.or_else(|| d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_doc::symbol_overrides::dynamic_art(&s.art, n)));
|
||||
art = crate::reshape::as_art(Some(d), n, symbol.as_ref())?;
|
||||
&art
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
}
|
||||
// Colour adjustments: the object recoloured (inside it too), then baked as it is.
|
||||
if has_adjustment(n)
|
||||
&& let Some(m) = adjust_in_document(d, n)
|
||||
&& let Some(m) = adjust_in_document(d, n, None)
|
||||
{
|
||||
return Some(bake_node(d, &m).unwrap_or(m));
|
||||
}
|
||||
@@ -124,7 +124,7 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::addons::halftone::halftone_art(d, n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
if let Some(m) = crate::revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) {
|
||||
let m = evaluate_container(Some(d), &m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -395,7 +395,7 @@ fn container_keeping_type(d: &Document, n: &Node) -> Option<Node> {
|
||||
if !kept {
|
||||
return None;
|
||||
}
|
||||
reshape_leaves(&mut m, &fx, Some(d));
|
||||
reshape_leaves(&mut m, &fx, &n.appearance.effects, Some(d));
|
||||
m.appearance.effects.retain(|e| !is_pathfinder(&e.id) && !is_geometry(&e.id));
|
||||
m.appearance.items.clear();
|
||||
m.appearance.contents_index = None;
|
||||
@@ -481,7 +481,7 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
||||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art_in(Some(d), n).or_else(|| crate::solid_art_in(Some(d), n)) {
|
||||
let m = evaluate_container(Some(d), &m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
@@ -551,7 +551,7 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
||||
/// painting their pieces name are looked up in `doc`. `None` when it has neither a stroke to
|
||||
/// outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n).or_else(|| crate::solid_art(n)) {
|
||||
if let Some(m) = crate::revolve_art_in(doc, n).or_else(|| crate::solid_art_in(doc, n)) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
|
||||
@@ -603,7 +603,7 @@ fn a_baked_transform_copy_keeps_its_scaled_stroke() {
|
||||
let m = bake_appearance(None, &n).unwrap();
|
||||
let w = widths(&m);
|
||||
assert_eq!(w.len(), 2, "{w:?}");
|
||||
assert!((w[0] - 102.0).abs() < 0.5 && (w[1] - 204.0).abs() < 0.5, "{w:?}");
|
||||
assert!((w[1] - 102.0).abs() < 0.5 && (w[0] - 204.0).abs() < 0.5, "the original on top: {w:?}");
|
||||
assert!(baked_paths(&m).iter().all(|p| p.appearance.stroke().is_none()), "the strokes are outlined");
|
||||
}
|
||||
|
||||
@@ -619,7 +619,7 @@ fn a_baked_transform_copy_keeps_its_moved_pattern() {
|
||||
p => panic!("not a pattern: {p:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(xs, vec![0.0, 30.0]);
|
||||
assert_eq!(xs, vec![30.0, 0.0], "the original on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -247,7 +247,7 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
||||
"distort.transform",
|
||||
"Transform…",
|
||||
DT,
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
|
||||
"{scaleH: % (100), scaleV: % (100), moveH: pt (0; right = +), moveV: pt (0; down = +), rotate: deg (0; counter-clockwise; when the effect turns, reflects or scales the object unevenly, a gradient fitted to the object is fixed to its fit and turns with the object and with each copy), copies: 0..1000 (0; each copy transforms the last again and is painted below it, the object on top; a group's copies each hold all its members, and a group with a member that carries a geometry, Pathfinder or 3D effect copies each member in its place; placed gradients transform with the object and with each copy), reflectX: bool (false; flips left to right), reflectY: bool (false; flips top to bottom), reference: 0..8 (4; the point of the bounds' 9-point grid it scales, rotates and reflects about: 0 top left, 4 centre, 8 bottom right), random: bool (false; each scale goes a random share of the way from 100 % to its value, each move and the angle a random share of theirs, differently for each object and the same on every redraw), objects: bool (true; Transform Objects: off, the geometry stays where it is and makes no copies, and only the two options below apply, once), patterns: bool (false; Transform Patterns: pattern fills and strokes transform along; with copies the original keeps its pattern and copy k takes the transformation k times), strokes: bool (false; Scale Strokes & Effects: stroke weights and dashes, and the distances of the effects on the fills and strokes, scale by the mean scale √|scaleH·scaleV|, copy k by its k-th power)}",
|
||||
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false, "reference": 4, "random": false, "objects": true, "patterns": false, "strokes": false}),
|
||||
),
|
||||
g(
|
||||
|
||||
@@ -26,6 +26,10 @@ pub struct GeomContext<'a> {
|
||||
/// The document that the swatches and patterns named by a live-effect plug-in that paints its
|
||||
/// pieces are looked up in. Without one, a result that names any is refused.
|
||||
pub doc: Option<&'a Document>,
|
||||
/// The object, fill or stroke and effect index a deferred live-effect plug-in is listed by
|
||||
/// ([`vectorcraft_plugins::deferred`]). When `None`, it is the object `seed` names, `item` and
|
||||
/// the effect's index in the list being applied.
|
||||
pub stage: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
}
|
||||
|
||||
impl<'a> GeomContext<'a> {
|
||||
@@ -36,7 +40,17 @@ impl<'a> GeomContext<'a> {
|
||||
_ => FillRule::NonZero,
|
||||
};
|
||||
let stroke = n.appearance.stroke().filter(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None }
|
||||
Self { stroke, rule, seed: n.id.0, appearance: Some(&n.appearance), item: None, doc: None, stage: None }
|
||||
}
|
||||
|
||||
/// The context of effect `e`, one of `list`: unless [`Self::stage`] is set, a live-effect
|
||||
/// plug-in there is listed by `e`'s index in `list`.
|
||||
pub(crate) fn at(self, list: &[Effect], e: &Effect) -> Self {
|
||||
let stage = self.stage.or_else(|| {
|
||||
let effect = list.iter().position(|x| std::ptr::eq(x, e))?;
|
||||
Some(vectorcraft_plugins::deferred::Stage { object: self.seed, effect, item: self.item })
|
||||
});
|
||||
Self { stage, ..self }
|
||||
}
|
||||
|
||||
/// The context of the effects on `item`, one of the same object's appearance items: a
|
||||
@@ -335,7 +349,7 @@ pub(crate) fn apply_geometry_carrying_within(
|
||||
}
|
||||
let p = merged_params(&e.id, &e.params);
|
||||
let limit = *limit.get_or_insert_with(|| budget.at_least(Budget::of(path)));
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, ctx, limit) {
|
||||
if let Some(next) = apply_one(&e.id, &p, &cur, b, &ctx.at(effects, e), limit) {
|
||||
if let Some(c) = carry.as_deref_mut() {
|
||||
c.push(Carry::of(&e.id, &p, b, cur.bounds(), next.bounds(), ctx.seed));
|
||||
}
|
||||
@@ -377,7 +391,7 @@ fn builds_within(id: &str) -> bool {
|
||||
fn apply_unchecked(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext, max: usize) -> PathData {
|
||||
use util::{join, num};
|
||||
if let Some(plugin) = vectorcraft_plugins::effect::plugin_id(id) {
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: ctx.appearance, item: ctx.item, at: ctx.stage };
|
||||
return vectorcraft_plugins::effect::apply(plugin, p, path, b, ctx.rule, paints, ctx.doc).unwrap_or_else(|| path.clone());
|
||||
}
|
||||
match id {
|
||||
|
||||
@@ -209,7 +209,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
|
||||
Some(area) => Some(reshaped(&area, n, doc)),
|
||||
None => {
|
||||
let mut m = crate::reshape::outlined_members_with(None, doc, n, outline, nodes);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
|
||||
// The group's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&m.appearance) {
|
||||
carry.apply(&mut m.appearance);
|
||||
@@ -225,7 +225,7 @@ fn reshaped_art(doc: Option<&Document>, n: &Node, hook: Option<OutlineHook>, nod
|
||||
let art = h(n).filter(|art| nodes.charge(art))?;
|
||||
let mut art = crate::reshape::symbol_art_with(&art, h, nodes, symbol);
|
||||
crate::reshape::outline_nested_text(doc, &mut art, false);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut art, &fx, &n.appearance.effects, doc);
|
||||
// The art's own fills and strokes follow the effects as its members do.
|
||||
if carry.moves(&art.appearance) {
|
||||
carry.apply(&mut art.appearance);
|
||||
@@ -738,7 +738,7 @@ pub fn evaluate_container(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
}
|
||||
if !fx.is_empty() {
|
||||
m = crate::reshape::outlined_members(doc, &m);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, doc);
|
||||
let carry = crate::reshape::reshape_leaves_carrying(&mut m, &fx, &n.appearance.effects, doc);
|
||||
// The group's own fills and strokes paint the members as a whole: their gradients follow
|
||||
// the effects as the members' do.
|
||||
if paint && carry.moves(&m.appearance) {
|
||||
|
||||
@@ -594,7 +594,7 @@ fn a_groups_transform_moves_the_gradients_placed_on_an_images_own_fills() {
|
||||
};
|
||||
for kind in [image as fn() -> NodeKind, instance] {
|
||||
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30})), [30.0], "{:?}", kind());
|
||||
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [0.0, 30.0, 60.0], "{:?}", kind());
|
||||
assert_eq!(starts(kind(), serde_json::json!({"moveH": 30, "copies": 2})), [60.0, 30.0, 0.0], "{:?}", kind());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -677,7 +677,8 @@ fn a_transform_on_a_group_paints_each_copy_with_its_own_gradient() {
|
||||
let art = evaluate_container(None, &g).unwrap();
|
||||
let copies = art.children().unwrap()[0].children().expect("the object and its copies are objects of their own");
|
||||
assert_eq!(copies.len(), 3);
|
||||
for (i, c) in copies.iter().enumerate() {
|
||||
// The original is on top, after the copies below it.
|
||||
for (i, c) in copies.iter().rev().enumerate() {
|
||||
// Each copy's own box: an unplaced gradient fits it, a placed one moved with it.
|
||||
assert!((c.geometric_bounds().unwrap().x0 - 30.0 * i as f64).abs() < 1e-6);
|
||||
match c.appearance.fill_paint() {
|
||||
|
||||
@@ -83,11 +83,12 @@ pub use reshape::{
|
||||
SYMBOL_ART_MAX_NODES, expand_outlined, gives_image_shape, has_gradient_copies, has_item_geometry, has_painted_effects, has_transform_art,
|
||||
has_transform_copies, has_transform_options, image_paint, needs_outline, outline_art, outline_text, reshape,
|
||||
};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_art_in, revolve_options, validate_revolve, validate_revolve_in};
|
||||
pub use solids::{
|
||||
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
|
||||
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as,
|
||||
validate_solid, validate_three_d, validate_three_d_params, view_options,
|
||||
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, solid_art_in, three_d_meshes, three_d_meshes_in,
|
||||
three_d_meshes_of, three_d_meshes_of_in, three_d_params_as, validate_solid, validate_solid_in, validate_three_d, validate_three_d_at,
|
||||
validate_three_d_at_in, validate_three_d_in, validate_three_d_params, view_options,
|
||||
};
|
||||
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
|
||||
pub use svg_filter::SVG_FILTER;
|
||||
|
||||
@@ -161,7 +161,8 @@ pub fn any_plugin_paints_output() -> bool {
|
||||
vectorcraft_plugins::registry::list().iter().any(|p| vectorcraft_plugins::effect::paints_output(&p.manifest().id))
|
||||
}
|
||||
|
||||
/// The plug-in registry's revision, which changes whenever a plug-in is installed or removed.
|
||||
/// Changes whenever a plug-in is installed or removed (the registry's revision), and when deferred
|
||||
/// live-effect plug-in stages give results ([`vectorcraft_plugins::deferred::revision`]).
|
||||
pub fn plugin_revision() -> u64 {
|
||||
vectorcraft_plugins::registry::revision()
|
||||
vectorcraft_plugins::registry::revision() ^ vectorcraft_plugins::deferred::revision().rotate_left(32)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
//! [`Selection::anchor_bounds`] measure the objects themselves. Raster effects (shadows, glows,
|
||||
//! blurs) are not measured. In one object, the art of at most 4,096 symbol instances is measured
|
||||
//! with its geometry effects, and the other instances in it are measured without them.
|
||||
//! [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure as these do and,
|
||||
//! with [`Measure::glyphs`], take point type by the ink of its glyphs (Measure Type by Glyph Bounds).
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
@@ -74,6 +76,121 @@ pub fn anchor_bounds(selection: &Selection, doc: &Document, preview: bool) -> Op
|
||||
union_opt(bounds_of(doc, &selection.whole(), preview), selection.anchors_box(doc))
|
||||
}
|
||||
|
||||
/// How [`bounds_of_with`], [`oriented_bounds_with`] and [`anchor_bounds_with`] measure objects:
|
||||
/// `preview` as in [`bounds_of`], and with `glyphs` (Preferences › Type › Measure Type by Glyph
|
||||
/// Bounds), point type, horizontal or vertical, by the ink of its glyphs instead of its em box
|
||||
/// ([`text_ink_in`]).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Measure {
|
||||
pub preview: bool,
|
||||
pub glyphs: bool,
|
||||
}
|
||||
|
||||
impl Measure {
|
||||
/// See [`by_glyphs`].
|
||||
fn by_glyphs(self, drawn: &Drawn, n: &Node) -> bool {
|
||||
self.glyphs
|
||||
&& matches!(&n.kind, NodeKind::Text(t) if matches!(t.kind, vectorcraft_doc::TextKind::Point))
|
||||
&& n.projection().is_none()
|
||||
&& !(self.preview && drawn.draws_geometry(n))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `m` measures `n` by the ink of its glyphs: point type, not in perspective, and with
|
||||
/// `preview` only when no geometry effect changes what it draws.
|
||||
pub fn by_glyphs(symbols: &SymbolBoxes, n: &Node, m: Measure) -> bool {
|
||||
m.by_glyphs(&Drawn::new(symbols), n)
|
||||
}
|
||||
|
||||
/// [`bounds_of`] measured by `m`.
|
||||
pub fn bounds_of_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<Rect> {
|
||||
if !m.glyphs {
|
||||
return bounds_of(doc, ids, m.preview);
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
let drawn = Drawn::new(&symbols);
|
||||
if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) {
|
||||
return bounds_of(doc, ids, m.preview);
|
||||
}
|
||||
ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, Affine::IDENTITY, m)))
|
||||
}
|
||||
|
||||
/// [`oriented_bounds`] measured by `m`.
|
||||
pub fn oriented_bounds_with(doc: &Document, ids: &[NodeId], m: Measure) -> Option<OrientedBox> {
|
||||
if !m.glyphs {
|
||||
return oriented_bounds(doc, ids, m.preview);
|
||||
}
|
||||
let symbols = doc.symbol_boxes();
|
||||
let drawn = Drawn::new(&symbols);
|
||||
if !ids.iter().filter_map(|id| doc.node(*id)).any(|n| m.by_glyphs(&drawn, n)) {
|
||||
return oriented_bounds(doc, ids, m.preview);
|
||||
}
|
||||
let angle = doc.bbox_angle(ids);
|
||||
let to_local = if angle == 0.0 { Affine::IDENTITY } else { OrientedBox { angle, rect: Rect::ZERO }.to_doc().inverse() };
|
||||
let rect = ids.iter().filter_map(|id| doc.node(*id)).fold(None, |acc, n| union_opt(acc, measured_in(&drawn, n, to_local, m)))?;
|
||||
Some(OrientedBox { angle, rect })
|
||||
}
|
||||
|
||||
/// [`anchor_bounds`] with the objects selected whole measured by `m`.
|
||||
pub fn anchor_bounds_with(selection: &Selection, doc: &Document, m: Measure) -> Option<Rect> {
|
||||
if selection.anchors.is_empty() {
|
||||
return None;
|
||||
}
|
||||
union_opt(bounds_of_with(doc, &selection.whole(), m), selection.anchors_box(doc))
|
||||
}
|
||||
|
||||
/// The box of `n` as `a` would place it, without changing it, measured by `m`: [`bounds_in`] with
|
||||
/// `preview`, else [`Node::bounds_in`] of its geometric bounds, and point type by its glyphs as
|
||||
/// [`Measure`] says.
|
||||
pub fn bounds_in_with(symbols: &SymbolBoxes, n: &Node, a: Affine, m: Measure) -> Option<Rect> {
|
||||
measured_in(&Drawn::new(symbols), n, a, m)
|
||||
}
|
||||
|
||||
/// See [`bounds_in_with`].
|
||||
fn measured_in(drawn: &Drawn, n: &Node, a: Affine, m: Measure) -> Option<Rect> {
|
||||
if m.by_glyphs(drawn, n)
|
||||
&& let NodeKind::Text(t) = &n.kind
|
||||
&& let Some(r) = text_ink_in(t, a)
|
||||
{
|
||||
// The fills' and characters' strokes paint around the glyphs, as in `Node::visual_bounds`.
|
||||
let o = if m.preview { n.appearance.outset().max(t.char_stroke_outset()) } else { 0.0 };
|
||||
return Some(r.inflate(o, o));
|
||||
}
|
||||
match (m.preview, a == Affine::IDENTITY) {
|
||||
(true, _) => drawn.next().bounds_in(n, a),
|
||||
(false, true) => n.geometric_bounds_with(drawn.symbols),
|
||||
(false, false) => n.bounds_in(drawn.symbols, a, false),
|
||||
}
|
||||
}
|
||||
|
||||
/// The box of the ink of type `t`'s glyphs with its text space mapped by `a * t.xf`: the cached
|
||||
/// ink ([`vectorcraft_doc::TextObject::ink`]) when that map keeps a rectangle square, else the
|
||||
/// glyph outlines and the underline and strikethrough bars mapped point by point
|
||||
/// ([`vectorcraft_text::envelope_ink`]). `None` when nothing draws (no text, only spaces without
|
||||
/// underline or strikethrough, or glyphs without outlines).
|
||||
pub fn text_ink_in(t: &vectorcraft_doc::TextObject, a: Affine) -> Option<Rect> {
|
||||
let m = a * t.xf;
|
||||
let [p, q, r, s, _, _] = m.as_coeffs();
|
||||
let square = (q.abs() <= 1e-12 && r.abs() <= 1e-12) || (p.abs() <= 1e-12 && s.abs() <= 1e-12);
|
||||
if square {
|
||||
let ink = match t.ink() {
|
||||
Some(ink) => ink,
|
||||
None => vectorcraft_text::envelope_ink(t, Affine::IDENTITY, None),
|
||||
};
|
||||
return ink.map(|i| m.transform_rect_bbox(i));
|
||||
}
|
||||
vectorcraft_text::envelope_ink(t, m, None)
|
||||
}
|
||||
|
||||
/// The ink of type `t` laid out as `layout`, in text space: its glyphs
|
||||
/// ([`vectorcraft_text::TextLayout::ink_bounds`]) and its underline and strikethrough bars
|
||||
/// ([`vectorcraft_text::decorations`]), what [`text_ink_in`] measures. `None` when nothing draws.
|
||||
pub fn laid_out_ink(layout: &vectorcraft_text::TextLayout, t: &vectorcraft_doc::TextObject) -> Option<Rect> {
|
||||
vectorcraft_text::decorations(layout, vectorcraft_text::FontDb::global(), t)
|
||||
.into_iter()
|
||||
.fold(layout.ink_bounds(), |acc, (_, bar)| union_opt(acc, Some(bar.bounding_box())))
|
||||
}
|
||||
|
||||
/// The visual bounds of `n` as drawn: those of its [`drawn_art`], else
|
||||
/// [`Node::visual_bounds_with`].
|
||||
pub fn visual_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
|
||||
|
||||
+235
-57
@@ -46,8 +46,8 @@ pub fn has_gradient_copies(n: &Node) -> bool {
|
||||
/// Is `n` a path or compound path with a visible Transform effect that makes copies and fills and
|
||||
/// strokes that paint the copies differently as one path than as separate objects
|
||||
/// ([`paints_copies_apart`])? Such an object is drawn through its art ([`outline_art`]), each copy
|
||||
/// an object of its own above the one before ([`copy_leaf`]), as Expand Appearance makes it
|
||||
/// (#5920).
|
||||
/// an object of its own below the one before ([`copy_leaf`]), as Expand Appearance makes it
|
||||
/// (#5920, #6316).
|
||||
pub(crate) fn has_layered_copies(n: &Node) -> bool {
|
||||
leaf_geometry(n).is_some_and(|(_, rule)| paints_copies_apart(&n.appearance, rule)) && has_transform_copies(n)
|
||||
}
|
||||
@@ -573,7 +573,7 @@ fn as_art_opts(
|
||||
}
|
||||
_ => outline_art_with(symbols, paint_doc, n, symbol_art, outline, nodes, apart)?,
|
||||
};
|
||||
reshape_leaves_apart(&mut art, &fx, Budget::EFFECTS, apart, paint_doc);
|
||||
reshape_leaves_apart(&mut art, &fx, &n.appearance.effects, Budget::EFFECTS, apart, paint_doc);
|
||||
if apart {
|
||||
hoist_copies(&mut art);
|
||||
}
|
||||
@@ -636,21 +636,22 @@ pub(crate) fn geometry_effects(n: &Node) -> Vec<&Effect> {
|
||||
/// effects leave them as they are (a raster follows a non-affine map only as a mesh warp). The
|
||||
/// paths keep to one [`Budget::EFFECTS`] together (or to what they cost, when that is more): a path
|
||||
/// whose effect would pass what the others leave of it keeps its shape. The swatches and patterns
|
||||
/// that live-effect plug-ins painting their pieces name are looked up in `doc`
|
||||
/// ([`reshape_leaves_apart`]).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS, doc);
|
||||
/// that live-effect plug-ins painting their pieces name are looked up in `doc`, and a deferred one
|
||||
/// is listed by its index in `owner`, the effect list `fx` was taken from ([`reshape_leaves_apart`]).
|
||||
pub(crate) fn reshape_leaves(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) {
|
||||
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc);
|
||||
}
|
||||
|
||||
/// [`reshape_leaves`], and how gradients that paint the art as a whole (a group's own fills and
|
||||
/// strokes) follow the effects ([`crate::Carry`]).
|
||||
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_within(art, fx, Budget::EFFECTS, doc)
|
||||
pub(crate) fn reshape_leaves_carrying(art: &mut Node, fx: &[&Effect], owner: &[Effect], doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_apart(art, fx, owner, Budget::EFFECTS, false, doc)
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reshape_leaves_within(art: &mut Node, fx: &[&Effect], budget: Budget, doc: Option<&Document>) -> crate::Carry {
|
||||
reshape_leaves_apart(art, fx, budget, false, doc)
|
||||
reshape_leaves_apart(art, fx, &[], budget, false, doc)
|
||||
}
|
||||
|
||||
/// `art`, the group of the one piece a path's copies are made from ([`outline_art_with`]), holding
|
||||
@@ -669,12 +670,15 @@ fn hoist_copies(art: &mut Node) {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`reshape_leaves_within`]; with `apart`, a Transform effect's copies of each path are objects
|
||||
/// [`reshape_leaves_carrying`] whose paths keep to `budget` together, or to what they cost; with
|
||||
/// `apart`, a Transform effect's copies of each path are objects
|
||||
/// of their own ([`copy_leaf`]) whatever paints them, as Expand Appearance makes them. A live-effect
|
||||
/// plug-in that paints what it returns makes each path a group of its pieces ([`paint_leaf`]),
|
||||
/// whose swatches and patterns must be in `doc` (without one, pieces that name one are refused).
|
||||
/// Returns how gradients that paint the art as a whole follow the effects.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
|
||||
/// Returns how gradients that paint the art as a whole follow the effects. A deferred live-effect
|
||||
/// plug-in is listed by its index in `owner`, the effect list `fx` was taken from, or in `fx` when
|
||||
/// `owner` does not hold it.
|
||||
fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], owner: &[Effect], budget: Budget, apart: bool, doc: Option<&Document>) -> crate::Carry {
|
||||
let mut whole = crate::Carry::default();
|
||||
if fx.is_empty() {
|
||||
return whole;
|
||||
@@ -693,20 +697,26 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
});
|
||||
let art_stroke = art.appearance.stroke().is_some_and(|s| !s.paint.is_none() && s.width > 0.0);
|
||||
let style_safe = leaves_safe && !art_stroke;
|
||||
let listed = fx;
|
||||
let opt_fx = crate::geometry::optimize_geometry_effects(fx, style_safe);
|
||||
let fx = opt_fx.as_slice();
|
||||
let seed = art.id.0;
|
||||
let mut used = art_cost(art);
|
||||
let max = budget.at_least(used);
|
||||
// Read before the effects run: a path's own copies don't make it art of several objects.
|
||||
let copy_whole = copies_whole(art);
|
||||
for e in fx {
|
||||
let Some(bounds) = art.geometric_bounds() else { break };
|
||||
let params = merged_params(&e.id, &e.params);
|
||||
// Where a live-effect plug-in sits, which it is listed by when it is deferred.
|
||||
let effect = owner.iter().position(|x| std::ptr::eq(x, *e)).or_else(|| listed.iter().position(|x| std::ptr::eq(*x, *e)));
|
||||
if let Some(plugin) = painted_plugin(e) {
|
||||
let at = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, _)) = leaf_geometry(leaf) else { return };
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc) {
|
||||
if let Some(made) = paint_leaf(leaf, plugin, ¶ms, bounds, room, doc, at) {
|
||||
used = used.minus(before).plus(made);
|
||||
}
|
||||
});
|
||||
@@ -738,6 +748,14 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
let options = crate::distort::TransformOptions::of(¶ms);
|
||||
(m, copies, options, crate::distort::transform_affine(bounds, ¶ms, seed))
|
||||
});
|
||||
// Art of several objects is copied whole, so the copies don't interleave member by member.
|
||||
if let Some((m, copies, options, _)) = transform
|
||||
&& copies > 0
|
||||
&& copy_whole
|
||||
{
|
||||
used = copy_art(art, m, copies, copy_scaling(options), max);
|
||||
continue;
|
||||
}
|
||||
for_each_leaf(art, &mut |leaf| {
|
||||
let Some((path, rule)) = leaf_geometry(leaf) else { return };
|
||||
// What the other paths leave of the budget.
|
||||
@@ -747,7 +765,7 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
// Transform Patterns and Scale Strokes & Effects change each copy's fills and
|
||||
// strokes; a gradient paints each copy by itself, not the one piece the copies
|
||||
// make together; and Expand Appearance (`apart`) makes each copy an object.
|
||||
// Fills and strokes that stack paint each copy above the one before (#5920).
|
||||
// Fills and strokes that stack paint each copy below the one before (#5920, #6316).
|
||||
let separate = copies > 0
|
||||
&& (apart
|
||||
|| leaf.appearance.has_unplaced_gradient()
|
||||
@@ -768,7 +786,8 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, ..GeomContext::of(leaf) }, room) else {
|
||||
let stage = effect.map(|effect| vectorcraft_plugins::deferred::Stage { object: seed, effect, item: None });
|
||||
let Some(out) = apply_one(&e.id, ¶ms, &path, bounds, &GeomContext { seed, stage, ..GeomContext::of(leaf) }, room) else {
|
||||
kept_ids.insert(leaf.id);
|
||||
return;
|
||||
};
|
||||
@@ -813,6 +832,19 @@ fn reshape_leaves_apart(art: &mut Node, fx: &[&Effect], budget: Budget, apart: b
|
||||
/// doesn't grow with them (a horizontal line stretched vertically).
|
||||
const MAX_STROKE_SCALE: f64 = 1.0e6;
|
||||
|
||||
/// How a Transform effect with `options` scales what it maps: strokes, effects and patterns as
|
||||
/// its options say.
|
||||
fn copy_scaling(options: crate::distort::TransformOptions) -> vectorcraft_doc::Scaling {
|
||||
vectorcraft_doc::Scaling {
|
||||
strokes: options.strokes,
|
||||
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
|
||||
patterns: options.patterns,
|
||||
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
|
||||
keep_fit_when_flat: true,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// A Transform effect whose Transform Patterns or Scale Strokes & Effects is on (`options`), with
|
||||
/// map `m` and `copies`, applied to `leaf` (a path or compound path with geometry `path`) as Object
|
||||
/// › Transform with those options transforms an object (#885): the strokes' weights and dashes
|
||||
@@ -829,14 +861,7 @@ fn transform_leaf(
|
||||
options: crate::distort::TransformOptions,
|
||||
budget: Budget,
|
||||
) -> (Budget, bool) {
|
||||
let scaling = vectorcraft_doc::Scaling {
|
||||
strokes: options.strokes,
|
||||
effects: options.strokes.then_some(crate::scale_effect as fn(&mut Effect, f64)),
|
||||
patterns: options.patterns,
|
||||
// A map that flattens the path leaves its fitted gradients fitted, as `fixes_fit` does.
|
||||
keep_fit_when_flat: true,
|
||||
..Default::default()
|
||||
};
|
||||
let scaling = copy_scaling(options);
|
||||
if !options.objects {
|
||||
if options.strokes
|
||||
&& let Some(k) = vectorcraft_doc::Scaling::factor(m).filter(|k| k.is_finite() && *k > 0.0)
|
||||
@@ -861,36 +886,172 @@ fn transform_leaf(
|
||||
(copy_leaf(leaf, path, m, copies, scaling, budget), true)
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more, each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies
|
||||
/// an object. Each copy is an object of its own, so its gradients fit it (a placed gradient moves
|
||||
/// with it) rather than spanning every copy, and its strokes and patterns can differ from the
|
||||
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
|
||||
/// `budget`, and before one's strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the
|
||||
/// copies cost, the original included.
|
||||
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
|
||||
let mut made = Budget::of(path);
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
/// Does a Transform effect's copies of `art` copy it whole ([`copy_art`])? They do when `art` is a
|
||||
/// group or layer of several objects, at least one of which paints, with no type left in it and no
|
||||
/// fill or stroke of its own that paints. Type that stays type is copied in its place by the baking
|
||||
/// that keeps it, a clipping group's first member is its clip, and the art's own fills and strokes
|
||||
/// paint the shape of everything under it, copies included. A member that [`makes_art`] is copied
|
||||
/// in its place as well: each copy would make that art again, past what [`copy_art`] counts.
|
||||
fn copies_whole(art: &Node) -> bool {
|
||||
fn parts(n: &Node, count: &mut usize, painted: &mut bool, text: &mut bool) {
|
||||
match &n.kind {
|
||||
NodeKind::Text(_) => *text = true,
|
||||
NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => {
|
||||
*count += 1;
|
||||
*painted |= n.appearance.items.iter().any(crate::paints);
|
||||
}
|
||||
NodeKind::Image(_) | NodeKind::SymbolInstance { .. } => {
|
||||
*count += 1;
|
||||
*painted = true;
|
||||
}
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
*painted |= n.appearance.items.iter().any(crate::paints);
|
||||
children.iter().for_each(|c| parts(c, count, painted, text));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if art.clips() || !matches!(art.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) || art.appearance.items.iter().any(crate::paints) {
|
||||
return false;
|
||||
}
|
||||
let (mut count, mut painted, mut text) = (0, false, false);
|
||||
parts(art, &mut count, &mut painted, &mut text);
|
||||
count > 1 && painted && !text && !art.children().into_iter().flatten().any(|c| makes_art(c))
|
||||
}
|
||||
|
||||
/// Does `n` or anything visible under it, the art of its opacity masks included, carry a visible
|
||||
/// effect that makes art when it is drawn (its own or that of a fill or stroke that paints), or is
|
||||
/// any of them a compound shape? Raster effects, color adjustments and unknown effects make none.
|
||||
fn makes_art(n: &Node) -> bool {
|
||||
let makes = |e: &Effect| {
|
||||
e.visible
|
||||
&& (is_geometry(&e.id)
|
||||
|| crate::is_pathfinder(&e.id)
|
||||
|| crate::is_three_d(&e.id)
|
||||
|| e.id == crate::CROP_MARKS
|
||||
|| e.id == crate::addons::halftone::HALFTONE)
|
||||
};
|
||||
n.visible
|
||||
&& (n.is_compound_shape()
|
||||
|| n.appearance.effects.iter().any(makes)
|
||||
|| n.appearance.items.iter().any(|i| crate::paints(i) && i.effects().iter().any(makes))
|
||||
|| n.extras().mask.as_deref().is_some_and(|m| makes_art(&m.art))
|
||||
|| n.children().into_iter().flatten().any(|c| makes_art(c)))
|
||||
}
|
||||
|
||||
/// What the paths under `n` cost together, those of its opacity masks included.
|
||||
fn copy_cost(n: &Node) -> Budget {
|
||||
let mut cost = Budget { anchors: 0, lines: 0 };
|
||||
vectorcraft_doc::swatches::walk_with_masks(n, &mut |c| {
|
||||
if let NodeKind::Path { guide: false, path, .. } = &c.kind {
|
||||
cost = cost.plus(Budget::of(path));
|
||||
}
|
||||
});
|
||||
cost
|
||||
}
|
||||
|
||||
/// What the geometry effects of `leaf` (with geometry `path`) and of its fills and strokes add to
|
||||
/// what `path` costs when it is drawn.
|
||||
fn effects_cost(leaf: &Node, path: &PathData) -> Budget {
|
||||
let none = Budget { anchors: 0, lines: 0 };
|
||||
let items: Vec<&AppearanceItem> = leaf.appearance.items.iter().filter(|i| crate::paints(i) && crate::has_geometry(i.effects())).collect();
|
||||
if items.is_empty() && !crate::has_geometry(&leaf.appearance.effects) {
|
||||
return none;
|
||||
}
|
||||
let Some(bounds) = path.bounds() else { return none };
|
||||
let ctx = GeomContext { seed: leaf.id.0, ..GeomContext::of(leaf) };
|
||||
let own = crate::apply_geometry_with(&leaf.appearance.effects, path, bounds, &ctx);
|
||||
let item_cost = |i: &&AppearanceItem| {
|
||||
let b = own.bounds()?;
|
||||
Some(Budget::of(&crate::apply_geometry_with(i.effects(), &own, b, &ctx.item(i))))
|
||||
};
|
||||
// The object's geometry is drawn as it is only by what has no geometry effect of its own.
|
||||
let plain = items.is_empty() || leaf.appearance.items.iter().any(|i| crate::paints(i) && !crate::has_geometry(i.effects()));
|
||||
let base = if plain { Budget::of(&own) } else { none };
|
||||
base.plus(items.iter().filter_map(item_cost).fold(none, Budget::plus)).minus(Budget::of(path))
|
||||
}
|
||||
|
||||
/// Remove the guides anywhere under `n`.
|
||||
fn without_guides(n: &mut Node) {
|
||||
let Some(children) = n.children_mut() else { return };
|
||||
children.retain(|c| !matches!(c.kind, NodeKind::Path { guide: true, .. }));
|
||||
children.iter_mut().for_each(|c| without_guides(Arc::make_mut(c)));
|
||||
}
|
||||
|
||||
/// Replace the members of `art` by groups of them: the original on top and `copies` more below it,
|
||||
/// each mapped once more by `m` than the last (with `scaling`), as a Transform effect copies a
|
||||
/// group, without its guides. The copies stop where [`copy_leaf`]'s would, before they pass `budget` or
|
||||
/// [`crate::distort::MAX_COPY_OBJECTS`] nodes in all, the art of opacity masks counted. Returns what
|
||||
/// the art costs after.
|
||||
fn copy_art(art: &mut Node, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
|
||||
let Some(members) = art.children().cloned() else { return art_cost(art) };
|
||||
let original = Node::group(art.id, members);
|
||||
let mut made = copy_cost(&original);
|
||||
let mut drawn = original.clone();
|
||||
without_guides(&mut drawn);
|
||||
let mut nodes = original.count_with_masks();
|
||||
let one = drawn.count_with_masks();
|
||||
let mut placements: Vec<Arc<Node>> = vec![];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
xf *= m;
|
||||
// A copy scaled up is drawn with more lines.
|
||||
let cost = Budget::of(&path.transformed(xf));
|
||||
if !budget.fits(made.plus(cost)) {
|
||||
break;
|
||||
}
|
||||
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
|
||||
break;
|
||||
}
|
||||
let mut copy = leaf.clone();
|
||||
nodes = nodes.saturating_add(one);
|
||||
if nodes > crate::distort::MAX_COPY_OBJECTS {
|
||||
break;
|
||||
}
|
||||
let mut copy = drawn.clone();
|
||||
copy.transform(xf, scaling);
|
||||
if !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
let cost = copy_cost(©);
|
||||
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
made = made.plus(cost);
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name.
|
||||
// The last copy is the lowest.
|
||||
placements.reverse();
|
||||
placements.push(Arc::new(original));
|
||||
if let Some(children) = art.children_mut() {
|
||||
*children = placements;
|
||||
}
|
||||
made
|
||||
}
|
||||
|
||||
/// Replace `leaf` (a path or compound path with geometry `path`) by a group of it and `copies`
|
||||
/// more below it, each mapped once more by `m` than the last (with `scaling`), as a Transform effect
|
||||
/// copies an object. Each copy is an object of its own, so its gradients fit it (a placed gradient
|
||||
/// moves with it) rather than spanning every copy, and its strokes and patterns can differ from the
|
||||
/// others'. The copies stop where [`crate::distort::transform_within`]'s would, before they pass
|
||||
/// `budget` with what each copy's own geometry effects make ([`effects_cost`]), and before one's
|
||||
/// strokes would scale past [`MAX_STROKE_SCALE`]. Returns what the copies cost, the original
|
||||
/// included.
|
||||
fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling: vectorcraft_doc::Scaling, budget: Budget) -> Budget {
|
||||
let mut made = Budget::of(path).plus(effects_cost(leaf, path));
|
||||
let mut placements: Vec<Arc<Node>> = vec![Arc::new(leaf.clone())];
|
||||
let mut xf = Affine::IDENTITY;
|
||||
for _ in 0..copies {
|
||||
xf *= m;
|
||||
// A copy scaled up is drawn with more lines.
|
||||
let moved = path.transformed(xf);
|
||||
if scaling.strokes && vectorcraft_doc::Scaling::factor(xf).is_some_and(|k| !(k.is_finite() && k <= MAX_STROKE_SCALE)) {
|
||||
break;
|
||||
}
|
||||
let mut copy = leaf.clone();
|
||||
copy.transform(xf, scaling);
|
||||
// Each copy's own geometry effects make a different amount, as the copy and its effects scale.
|
||||
let cost = Budget::of(&moved).plus(effects_cost(©, &moved));
|
||||
if !budget.fits(made.plus(cost)) || !copy.geometric_bounds().is_some_and(crate::distort::within_reach) {
|
||||
break;
|
||||
}
|
||||
placements.push(Arc::new(copy));
|
||||
made = made.plus(cost);
|
||||
}
|
||||
// The copies become nodes of their own under a group with the object's id and name, the object
|
||||
// on top.
|
||||
placements.reverse();
|
||||
let mut g = Node::group(leaf.id, placements);
|
||||
g.name = leaf.name.clone();
|
||||
*leaf = g;
|
||||
@@ -904,10 +1065,18 @@ fn copy_leaf(leaf: &mut Node, path: &PathData, m: Affine, copies: usize, scaling
|
||||
/// its own ([`vectorcraft_plugins::painted::appearance`]). The swatches and patterns the pieces
|
||||
/// name must be in `doc`, and without one the pieces may name none. Returns what the pieces cost
|
||||
/// ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails, the pieces are
|
||||
/// refused, or they would pass `budget`.
|
||||
fn paint_leaf(leaf: &mut Node, plugin: &str, params: &serde_json::Value, bounds: Rect, budget: Budget, doc: Option<&Document>) -> Option<Budget> {
|
||||
/// refused, or they would pass `budget`. A deferred run is listed by `at`.
|
||||
fn paint_leaf(
|
||||
leaf: &mut Node,
|
||||
plugin: &str,
|
||||
params: &serde_json::Value,
|
||||
bounds: Rect,
|
||||
budget: Budget,
|
||||
doc: Option<&Document>,
|
||||
at: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
) -> Option<Budget> {
|
||||
let (path, rule) = leaf_geometry(leaf)?;
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: None, at };
|
||||
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
|
||||
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
|
||||
if !budget.fits(made) {
|
||||
@@ -945,18 +1114,18 @@ fn piece_cost(path: &PathData) -> Budget {
|
||||
/// other fills and strokes; one that gives none keeps the leaf's. Returns
|
||||
/// what the paths cost ([`piece_cost`]); `None` (the leaf stays as it is) when the plug-in fails,
|
||||
/// the pieces are refused (a swatch or pattern not in `doc`, or any without one), or they would pass
|
||||
/// `budget`.
|
||||
/// `budget`. A deferred run is listed by `at`.
|
||||
fn paint_piece(
|
||||
leaf: &mut Node,
|
||||
item: &AppearanceItem,
|
||||
plugin: &str,
|
||||
params: &serde_json::Value,
|
||||
(plugin, params): (&str, &serde_json::Value),
|
||||
bounds: Rect,
|
||||
budget: Budget,
|
||||
doc: Option<&Document>,
|
||||
at: Option<vectorcraft_plugins::deferred::Stage>,
|
||||
) -> Option<Budget> {
|
||||
let (path, rule) = leaf_geometry(leaf)?;
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0) };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&leaf.appearance), item: Some(0), at };
|
||||
let pieces = vectorcraft_plugins::effect::apply_painted(plugin, params, &path, bounds, rule, paints, doc)?;
|
||||
let made = pieces.iter().map(|p| piece_cost(&p.path)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus);
|
||||
if !budget.fits(made) {
|
||||
@@ -1089,6 +1258,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
};
|
||||
let Some(tb) = g.bounds() else { continue };
|
||||
let params = merged_params(&fx[at].id, &fx[at].params);
|
||||
// Where each effect the piece keeps sits among the item's effects.
|
||||
let kept: Vec<usize> = (0..at).filter(|&k| !is_geometry(&fx[k].id)).chain(at + 1..fx.len()).collect();
|
||||
if let Some(plugin) = painted_plugin(&fx[at]) {
|
||||
let mut it = item.clone();
|
||||
let rest: Vec<Effect> = before.iter().filter(|e| !is_geometry(&e.id)).chain(&fx[at + 1..]).cloned().collect();
|
||||
@@ -1098,7 +1269,8 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
carry.apply(&mut one);
|
||||
}
|
||||
let it = one.items.pop().unwrap_or(it);
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i) };
|
||||
let stage = vectorcraft_plugins::deferred::Stage { object: ctx.seed, effect: at, item: Some(i) };
|
||||
let paints = vectorcraft_plugins::effect::Paints { appearance: Some(&original.appearance), item: Some(i), at: Some(stage) };
|
||||
let painted = vectorcraft_plugins::effect::apply_painted(plugin, ¶ms, &g, tb, rule, paints, doc)
|
||||
.map(|pieces| pieces.iter().map(|p| (p.path.clone(), p.rule, vectorcraft_plugins::painted::items(&it, p))).collect::<Vec<_>>())
|
||||
.filter(|pieces| room.fits(pieces.iter().map(|(p, ..)| piece_cost(p)).fold(Budget { anchors: 0, lines: 0 }, Budget::plus)));
|
||||
@@ -1112,7 +1284,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
group.push(Arc::new(p));
|
||||
}
|
||||
let mut p = Node::group(leaf.id, group);
|
||||
transform_piece(&mut p, ctx.seed, limit, used, doc);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
continue;
|
||||
}
|
||||
@@ -1129,7 +1301,7 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
set_geometry(&mut p, g.clone(), rule);
|
||||
let (made, _) = transform_leaf(&mut p, &g, map, copies, options, room);
|
||||
*used = used.plus(made);
|
||||
transform_piece(&mut p, ctx.seed, limit, used, doc);
|
||||
transform_piece(&mut p, (ctx.seed, i, &kept), limit, used, doc);
|
||||
pieces.push(Arc::new(p));
|
||||
}
|
||||
if !plain.is_empty() {
|
||||
@@ -1145,14 +1317,17 @@ fn transform_item_leaf(leaf: &mut Node, max: Budget, used: &mut Budget, doc: Opt
|
||||
/// Apply the visible geometry effects left on the one fill or stroke of each path under `p` (a
|
||||
/// transformed piece of [`transform_item_leaf`] and its copies) to all of `p`'s paths as one piece:
|
||||
/// each effect takes the bounds of all the paths as its reference box and `seed`, as an object's
|
||||
/// own effects reshape all its paths ([`reshape_leaves_within`]). Each path applies its own copy of
|
||||
/// own effects reshape all its paths ([`reshape_leaves_carrying`]). Each path applies its own copy of
|
||||
/// the effect, whose distances its Scale Strokes & Effects scaled. A Transform effect with
|
||||
/// Transform Patterns or Scale Strokes & Effects on transforms each path as [`transform_leaf`] does.
|
||||
/// A live-effect plug-in that paints what it returns ([`painted_plugin`]) makes each path one path
|
||||
/// for each piece it returns ([`paint_piece`]). The geometry effects then leave the fills and
|
||||
/// strokes; their other effects stay. The paths keep to `max` together with the rest of the art
|
||||
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`.
|
||||
fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
/// (`used`), and the swatches and patterns painted pieces name must be in `doc`. `(seed, source,
|
||||
/// kept)`: the object's seed, the index in the object's appearance of the fill or stroke the
|
||||
/// piece was made from, and the index among that item's effects of each effect the piece kept,
|
||||
/// which a deferred live-effect plug-in is listed by.
|
||||
fn transform_piece(p: &mut Node, (seed, source, kept): (u64, usize, &[usize]), max: Budget, used: &mut Budget, doc: Option<&Document>) {
|
||||
let mut count = 0;
|
||||
for_each_leaf(p, &mut |l| {
|
||||
if let Some(item) = l.appearance.items.first() {
|
||||
@@ -1168,8 +1343,9 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
|
||||
// What the other paths leave of the budget.
|
||||
let before = Budget::of(&path);
|
||||
let room = max.minus(used.minus(before));
|
||||
let stage = vectorcraft_plugins::deferred::Stage { object: seed, effect: kept.get(j).copied().unwrap_or(j), item: Some(source) };
|
||||
if let Some(plugin) = painted_plugin(&e) {
|
||||
if let Some(made) = paint_piece(leaf, &item, plugin, &merged_params(&e.id, &e.params), bounds, room, doc) {
|
||||
if let Some(made) = paint_piece(leaf, &item, (plugin, &merged_params(&e.id, &e.params)), bounds, room, doc, Some(stage)) {
|
||||
*used = used.minus(before).plus(made);
|
||||
}
|
||||
return;
|
||||
@@ -1188,7 +1364,7 @@ fn transform_piece(p: &mut Node, seed: u64, max: Budget, used: &mut Budget, doc:
|
||||
AppearanceItem::Stroke(s) => Some(s),
|
||||
AppearanceItem::Fill(_) => None,
|
||||
};
|
||||
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc };
|
||||
let ctx = GeomContext { stroke, rule, seed, appearance: Some(&appearance), item: Some(0), doc, stage: Some(stage) };
|
||||
let mut carry = crate::Carry::default();
|
||||
let out = crate::geometry::apply_geometry_carrying_within(std::slice::from_ref(&e), &path, bounds, &ctx, room, Some(&mut carry));
|
||||
*used = used.minus(before).plus(Budget::of(&out));
|
||||
@@ -1463,6 +1639,8 @@ fn copy_placed(n: &mut Node, m: Affine, copies: usize, room: &mut usize) {
|
||||
placements.push(Arc::new(copy));
|
||||
}
|
||||
*room -= placements.len() - 1;
|
||||
// The object stays on top of its copies, as a path does.
|
||||
placements.reverse();
|
||||
let mut g = Node::group(src.id, placements);
|
||||
g.name = src.name.clone();
|
||||
*c = Arc::new(g);
|
||||
|
||||
+822
-58
File diff suppressed because it is too large
Load Diff
+253
-67
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, Effect, Node, NodeId, NodeKind, OpacityMask, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View};
|
||||
|
||||
@@ -17,6 +17,9 @@ use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
mod classic;
|
||||
pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
|
||||
|
||||
mod source;
|
||||
pub(crate) use source::{resolve, resolve_for};
|
||||
|
||||
pub const EXTRUDE: &str = "threeD.extrude";
|
||||
pub const INFLATE: &str = "threeD.inflate";
|
||||
pub const ROTATE: &str = "threeD.rotate";
|
||||
@@ -138,7 +141,7 @@ pub fn inflate_options(p: &Value) -> Inflate {
|
||||
}
|
||||
|
||||
/// The shape a solid is made from: a path's or a compound path's outline.
|
||||
fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
pub(crate) fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, rule, guide: false, clipping: false, .. } => Some((path.clone(), *rule)),
|
||||
NodeKind::Compound { children, rule } => {
|
||||
@@ -156,7 +159,7 @@ fn profile(n: &Node) -> Option<(PathData, FillRule)> {
|
||||
|
||||
/// What a 3D effect paints `n` with: its own fills and strokes, else, for type that has none, the
|
||||
/// fill and stroke of its first painted run (its characters' colours).
|
||||
fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
|
||||
pub(crate) fn solid_paints(n: &Node) -> Vec<AppearanceItem> {
|
||||
let own: Vec<AppearanceItem> = n.appearance.items.iter().filter(|i| crate::paints(i)).cloned().collect();
|
||||
if !own.is_empty() || !matches!(n.kind, NodeKind::Text(_)) {
|
||||
return own;
|
||||
@@ -198,38 +201,120 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The name an error gives 3D effect `id`.
|
||||
pub(crate) fn label_of(id: &str) -> &'static str {
|
||||
crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'))
|
||||
}
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
validate_solid_in(None, n, id, p)
|
||||
}
|
||||
|
||||
/// [`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_params(id, p)?;
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
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(|_| ()));
|
||||
}
|
||||
let (path, rule) = profile(n).ok_or_else(|| {
|
||||
format!("{label} currently requires a path, compound path, type or group. Convert images and other objects to paths first.")
|
||||
})?;
|
||||
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(|_| ())
|
||||
}
|
||||
|
||||
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
|
||||
/// (empty, or only spaces) is a shape without anchors, an image is traced first, and any other
|
||||
/// object is the wrong kind.
|
||||
pub(crate) fn object_profile(n: &Node, label: &str) -> Result<(PathData, FillRule), String> {
|
||||
profile(n).ok_or_else(|| match n.kind {
|
||||
NodeKind::Text(_) => format!("{label} requires a shape with at least two anchors"),
|
||||
NodeKind::Image(_) => source::image_refusal(label),
|
||||
_ => format!("{label} currently requires a path, compound path, type, group, blend or symbol instance. Expand other objects first."),
|
||||
})
|
||||
}
|
||||
|
||||
/// A group in a 3D effect's group, or a member of it, whose opacity is below 100%, whose blending
|
||||
/// mode is not Normal or that has an opacity mask. The members of one group share its `key`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Scope {
|
||||
pub(crate) key: usize,
|
||||
pub(crate) opacity: f32,
|
||||
pub(crate) blend: BlendMode,
|
||||
/// The object's opacity mask, unless it has none or it is disabled. The mask art is in
|
||||
/// document coordinates, as the solids' faces are.
|
||||
pub(crate) mask: Option<Arc<OpacityMask>>,
|
||||
}
|
||||
|
||||
impl Scope {
|
||||
/// The scope of `n` with key `key`, or `None` when `n` is opaque, blends normally and has no
|
||||
/// opacity mask that applies.
|
||||
fn of(n: &Node, key: usize) -> Option<Self> {
|
||||
let mask = n.extras().mask.as_deref().filter(|m| !m.disabled);
|
||||
(n.opacity < 1.0 || n.blend != BlendMode::Normal || mask.is_some()).then(|| Scope {
|
||||
key,
|
||||
opacity: n.opacity,
|
||||
blend: n.blend,
|
||||
mask: mask.map(|m| Arc::new(m.clone())),
|
||||
})
|
||||
}
|
||||
|
||||
/// A group of `children` with id `id` and this scope's opacity, blending mode and opacity mask.
|
||||
pub(crate) fn group(&self, id: NodeId, children: Vec<Arc<Node>>) -> Node {
|
||||
let mut group = Node::group(id, children);
|
||||
group.opacity = self.opacity;
|
||||
group.blend = self.blend;
|
||||
if let Some(mask) = &self.mask {
|
||||
group.extras_mut().mask = Some(Box::new((**mask).clone()));
|
||||
}
|
||||
group
|
||||
}
|
||||
}
|
||||
|
||||
/// `surfaces`, each with the scopes of the member it was built from, in groups with the opacity
|
||||
/// masks of those scopes, so each mask applies to the faces of the members it holds. The members'
|
||||
/// opacity is already in their paint ([`Profile::opacity`]), so these groups are opaque.
|
||||
fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
|
||||
let masks: Vec<(Vec<Scope>, Arc<Node>)> = surfaces
|
||||
.into_iter()
|
||||
.map(|(scopes, s)| {
|
||||
let masks = scopes.iter().filter(|s| s.mask.is_some()).map(|s| Scope { opacity: 1.0, blend: BlendMode::Normal, ..s.clone() }).collect();
|
||||
(masks, s)
|
||||
})
|
||||
.collect();
|
||||
crate::revolve::scoped(id, masks.iter().map(|(scopes, s)| (scopes.as_slice(), s.clone())).collect(), 0)
|
||||
}
|
||||
|
||||
/// Does any of `shapes` sit in a scope with an opacity mask?
|
||||
fn has_masks(shapes: &[Profile]) -> bool {
|
||||
shapes.iter().any(|s| s.scopes.iter().any(|s| s.mask.is_some()))
|
||||
}
|
||||
|
||||
/// One shape a solid is built from: its outline and fill rule, the fills and strokes that paint
|
||||
/// it, and the opacity of the objects it is in (1 for the object with the effect itself).
|
||||
struct Profile {
|
||||
path: PathData,
|
||||
rule: FillRule,
|
||||
items: Vec<AppearanceItem>,
|
||||
opacity: f32,
|
||||
/// it, and the opacity of the objects it is in multiplied together (1 for the object with the
|
||||
/// effect itself). `scopes` lists those objects with transparency or an opacity mask of their own
|
||||
/// ([`Scope`]), from the outermost group down to the member itself (none for the object itself).
|
||||
/// Group Revolve applies each once, to the surfaces of the members it holds together; the other 3D
|
||||
/// effects apply each opacity mask that way.
|
||||
pub(crate) struct Profile {
|
||||
pub(crate) path: PathData,
|
||||
pub(crate) rule: FillRule,
|
||||
pub(crate) items: Vec<AppearanceItem>,
|
||||
pub(crate) opacity: f32,
|
||||
pub(crate) scopes: Vec<Scope>,
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (a 3D effect on it turns its members about one shared axis)?
|
||||
fn is_container(n: &Node) -> bool {
|
||||
pub(crate) fn is_container(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })
|
||||
}
|
||||
|
||||
/// The bounds of every shape together: the centre a group's members turn about.
|
||||
fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
|
||||
pub(crate) fn union_bounds(shapes: &[Profile]) -> Option<Rect> {
|
||||
shapes.iter().filter_map(|s| s.path.bounds()).reduce(|a, c| a.union(c))
|
||||
}
|
||||
|
||||
@@ -241,51 +326,95 @@ const MAX_MEMBERS: usize = 4096;
|
||||
const MAX_MESH_TRIANGLES: usize = 2_000_000;
|
||||
|
||||
/// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path,
|
||||
/// compound path and type in it, at any depth of its groups, each through its own geometry
|
||||
/// effects and painted with its own fills and strokes. Like the effect on a group in the
|
||||
/// compound path and type in it (after [`resolve`] gives it the art of its symbol instances, blends
|
||||
/// and clipping groups), at any depth of its groups, each through its own geometry
|
||||
/// effects, then those of each group it is in (on that group's members together), and painted
|
||||
/// with its own fills and strokes. Like the effect on a group in the
|
||||
/// reference app, they turn about one shared axis (the centre of their bounds together) rather
|
||||
/// than each about its own. An error names what the group holds that can't become a solid.
|
||||
fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
|
||||
fn walk(n: &Node, opacity: f32, label: &str, out: &mut Vec<Profile>) -> Result<(), String> {
|
||||
pub(crate) fn members(n: &Node, label: &str) -> Result<Vec<Profile>, String> {
|
||||
struct Walk<'a> {
|
||||
label: &'a str,
|
||||
out: Vec<Profile>,
|
||||
keys: usize,
|
||||
}
|
||||
fn walk(n: &Node, opacity: f32, scopes: &[Scope], w: &mut Walk) -> Result<(), String> {
|
||||
let label = w.label;
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
continue;
|
||||
}
|
||||
let opacity = opacity * c.opacity;
|
||||
let mut scopes = scopes.to_vec();
|
||||
if let Some(scope) = Scope::of(c, w.keys + 1) {
|
||||
w.keys += 1;
|
||||
scopes.push(scope);
|
||||
}
|
||||
if is_container(c) {
|
||||
walk(c, opacity, label, out)?;
|
||||
let first = w.out.len();
|
||||
walk(c, opacity, &scopes, w)?;
|
||||
// The group's own geometry effects reshape its members together, as it is drawn.
|
||||
if let Some(shapes) = w.out.get_mut(first..) {
|
||||
reshape_members(c, &c.appearance.effects, shapes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some((path, rule)) = profile(c) else {
|
||||
let what = match &c.kind {
|
||||
NodeKind::Group { clip: true, .. } => "clipping groups",
|
||||
NodeKind::Image(_) => "images",
|
||||
NodeKind::SymbolInstance { .. } => "symbol instances",
|
||||
NodeKind::Image(_) => return Err(source::image_refusal(label)),
|
||||
NodeKind::Path { clipping: true, .. } => "clipping paths",
|
||||
_ => "objects other than paths, compound paths and type",
|
||||
_ => "other objects",
|
||||
};
|
||||
return Err(format!("{label} on a group needs paths, compound paths and type: release or expand its {what} first."));
|
||||
return Err(format!(
|
||||
"{label} on a group needs paths, compound paths, type, groups, clipping groups, blends and symbol instances: release or expand its {what} first."
|
||||
));
|
||||
};
|
||||
if out.len() >= MAX_MEMBERS {
|
||||
if w.out.len() >= MAX_MEMBERS {
|
||||
return Err(format!("{label} on a group takes at most {MAX_MEMBERS} objects"));
|
||||
}
|
||||
let path = geometry(&path, &c.appearance.effects, &GeomContext::of(c));
|
||||
if path.bounds().is_some() {
|
||||
out.push(Profile { path, rule, items: solid_paints(c), opacity });
|
||||
w.out.push(Profile { path, rule, items: solid_paints(c), opacity, scopes });
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
let mut out = vec![];
|
||||
walk(n, 1.0, label, &mut out)?;
|
||||
Ok(out)
|
||||
let mut w = Walk { label, out: vec![], keys: 0 };
|
||||
walk(n, 1.0, &[], &mut w)?;
|
||||
Ok(w.out)
|
||||
}
|
||||
|
||||
/// Can 3D effect `id` (Revolve included, [`is_three_d`]) with parameters `p` draw `n`? Checked
|
||||
/// before a command changes the document: Revolve by [`crate::validate_revolve`], the others by
|
||||
/// [`validate_solid`].
|
||||
pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
|
||||
validate_three_d_in(None, n, id, p)
|
||||
}
|
||||
|
||||
/// [`validate_three_d`] with the symbols of `doc` ([`validate_solid_in`]).
|
||||
pub fn validate_three_d_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
if id == crate::REVOLVE { crate::revolve::validate_revolve_in(doc, n, p) } else { validate_solid_in(doc, n, id, p) }
|
||||
}
|
||||
|
||||
/// [`validate_three_d`] for an effect at `(item, index)`, index `index` of the effects of
|
||||
/// appearance item `item` of `n` (the effects' length for a new effect), or on the object itself
|
||||
/// when `at` is `None`. A Revolve on a fill or stroke is checked in that item's paint, after the
|
||||
/// item's geometry effects before it, together with the object's other items that carry a Revolve.
|
||||
/// A group's or layer's fills and strokes take no 3D effect: only the group's own effect draws.
|
||||
pub fn validate_three_d_at(n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
|
||||
validate_three_d_at_in(None, n, id, p, at)
|
||||
}
|
||||
|
||||
/// [`validate_three_d_at`] with the symbols of `doc` ([`validate_solid_in`]). A symbol instance, a
|
||||
/// blend and a clipping group take a 3D effect as groups do: on the object itself.
|
||||
pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value, at: Option<(usize, usize)>) -> Result<(), String> {
|
||||
match at {
|
||||
Some(_) if is_container(n) || source::resolves(n) => {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
|
||||
@@ -355,6 +484,21 @@ fn paint_of(item: &AppearanceItem, member: f32) -> (Paint, f32, BlendMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `item`'s paint, borrowed (for checks that must not copy it).
|
||||
fn paint_ref(item: &AppearanceItem) -> &Paint {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => &f.paint,
|
||||
AppearanceItem::Stroke(s) => &s.paint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Paint copies `surface` makes for `facets`: two per facet (fill and seam stroke), six when colored
|
||||
/// lights give each facet derived paints (`lighting::solid_surface`).
|
||||
fn facet_copies(facets: &[Facet], paint: &Paint, colored: bool) -> usize {
|
||||
let derived = colored && paint.color().is_some() && facets.iter().any(|f| f.rgb_gradient.is_some());
|
||||
facets.len().saturating_mul(if derived { 6 } else { 2 })
|
||||
}
|
||||
|
||||
/// The area `item` paints on `path`, and the rule that fills it: the path itself for a fill, the
|
||||
/// stroke's outline (whose contours nest as its holes do) for a stroke.
|
||||
fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, FillRule) {
|
||||
@@ -364,28 +508,32 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
}
|
||||
}
|
||||
|
||||
/// One part of [`solid_parts`]: a fill or stroke, its shape's opacity, the region it paints and its
|
||||
/// fill rule, and its shape's scopes.
|
||||
type Part<'a> = (&'a AppearanceItem, f32, PathData, FillRule, &'a [Scope]);
|
||||
|
||||
/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes,
|
||||
/// a group's members from the back up, each with its shape's opacity and the region it paints.
|
||||
/// The solids share the bounds of all their parts (`fallback` when none has any): one center to
|
||||
/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
|
||||
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) {
|
||||
let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes
|
||||
/// a group's members from the back up, each with its shape's opacity, the region it paints and its
|
||||
/// shape's scopes. The solids share the bounds of all their parts (`fallback` when none has any):
|
||||
/// one center to turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines.
|
||||
fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<Part<'_>>, Rect) {
|
||||
let parts: Vec<Part> = shapes
|
||||
.iter()
|
||||
.flat_map(|s| {
|
||||
let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| {
|
||||
let (area, rule) = region(&s.path, s.rule, i);
|
||||
(i, s.opacity, area, rule)
|
||||
(i, s.opacity, area, rule, s.scopes.as_slice())
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
||||
let bounds = parts.iter().filter_map(|(_, _, area, _, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback);
|
||||
(parts, bounds)
|
||||
}
|
||||
|
||||
/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before`
|
||||
/// its 3D effect, about the bounds of all of them.
|
||||
fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
|
||||
pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
|
||||
if before.iter().any(|e| e.visible && is_geometry(&e.id))
|
||||
&& let Some(b) = union_bounds(shapes)
|
||||
{
|
||||
@@ -402,12 +550,16 @@ fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
|
||||
/// each in its own paint. When every one is opaque with normal blending and no colored light makes
|
||||
/// a surface one composite or masks it, their faces are drawn in one back-to-front order (where
|
||||
/// 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.
|
||||
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask, or that of
|
||||
/// a group it is in, masks the surfaces of the members it holds ([`masked`]).
|
||||
fn art_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;
|
||||
let colored = !classic && view_options(&p).has_colored_lights();
|
||||
let masks = has_masks(shapes);
|
||||
// The paint copies of every solid together: past the allowance the source path is drawn.
|
||||
let mut paint_bytes = 0usize;
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds (a group's members about
|
||||
// the centre of theirs), so they stay together.
|
||||
@@ -417,24 +569,28 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
|
||||
let (paint, opacity, blend) = paint_of(item, s.opacity);
|
||||
let (area, rule) = region(&s.path, s.rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, &paint, colored), &paint) {
|
||||
return None;
|
||||
}
|
||||
out.push((s.scopes.as_slice(), Arc::new(surface(n, f, &paint, opacity, blend, colored))));
|
||||
}
|
||||
}
|
||||
return Some(out);
|
||||
return Some(if masks { masked(n.id, out) } else { out.into_iter().map(|(_, s)| s).collect() });
|
||||
}
|
||||
let (parts, bounds) = solid_parts(shapes, b);
|
||||
let built = if fx.id == INFLATE {
|
||||
// One eye for every puff, as far away as the highest needs.
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_together(&shapes, bounds, inflate_options(&p), view_options(&p))
|
||||
} else {
|
||||
parts.iter().map(|(_, _, area, rule)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
parts.iter().map(|(_, _, area, rule, _)| facets(&fx.id, area, *rule, bounds, &p)).collect()
|
||||
};
|
||||
let mut solids = vec![];
|
||||
for ((item, opacity, _, _), f) in parts.iter().zip(built) {
|
||||
for ((item, opacity, _, _, scopes), f) in parts.iter().zip(built) {
|
||||
let item: &AppearanceItem = item;
|
||||
match f {
|
||||
Ok(f) => solids.push((item, *opacity, f)),
|
||||
Ok(f) if !crate::revolve::paint_copies_add(&mut paint_bytes, facet_copies(&f, paint_ref(item), colored), paint_ref(item)) => return None,
|
||||
Ok(f) => solids.push((item, *opacity, *scopes, f)),
|
||||
// A stroke's outline past the limits is left out; the fill's solid still draws.
|
||||
Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {}
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path
|
||||
@@ -446,30 +602,33 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
|
||||
if solids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let opaque = solids.iter().all(|(item, member, _)| {
|
||||
let opaque = solids.iter().all(|(item, member, _, _)| {
|
||||
let (_, opacity, blend) = paint_of(item, *member);
|
||||
opacity >= 1.0 && blend == BlendMode::Normal
|
||||
});
|
||||
let surfaces: Vec<(Vec<f64>, Node)> = solids
|
||||
let surfaces: Vec<(Vec<f64>, Node, &[Scope])> = solids
|
||||
.into_iter()
|
||||
.map(|(item, member, f)| {
|
||||
.map(|(item, member, scopes, f)| {
|
||||
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
|
||||
let (paint, opacity, blend) = paint_of(item, member);
|
||||
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
|
||||
(depths, art)
|
||||
(depths, art, scopes)
|
||||
})
|
||||
.collect();
|
||||
if masks {
|
||||
return Some(masked(n.id, surfaces.into_iter().map(|(_, s, scopes)| (scopes, Arc::new(s))).collect()));
|
||||
}
|
||||
// One order takes a plain group of faces from each solid, one face for each facet. With
|
||||
// colored lights a surface can instead be one clipped composite of all its facets, or carry an
|
||||
// opacity mask, and then it stays whole.
|
||||
let plain = surfaces.iter().all(|(depths, s)| {
|
||||
let plain = surfaces.iter().all(|(depths, s, _)| {
|
||||
s.extras().mask.is_none() && matches!(&s.kind, NodeKind::Group { children, clip: false } if children.len() == depths.len())
|
||||
});
|
||||
if !opaque || !plain {
|
||||
return Some(surfaces.into_iter().map(|(_, s)| Arc::new(s)).collect());
|
||||
return Some(surfaces.into_iter().map(|(_, s, _)| Arc::new(s)).collect());
|
||||
}
|
||||
let mut faces = vec![];
|
||||
for (depths, s) in surfaces {
|
||||
for (depths, s, _) in surfaces {
|
||||
if let NodeKind::Group { children, .. } = s.kind {
|
||||
faces.extend(depths.into_iter().zip(children));
|
||||
}
|
||||
@@ -488,9 +647,17 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
/// On a group or layer the effect builds a solid from each path, compound path and type in it,
|
||||
/// all turned about one shared axis ([`members`]).
|
||||
pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
solid_art_in(None, n)
|
||||
}
|
||||
|
||||
/// [`solid_art`] with the symbols of `doc`: a symbol instance, a blend and a clipping group, alone
|
||||
/// or in a group, build from the art they show ([`resolve`]). `None` too when that art can't be
|
||||
/// resolved.
|
||||
pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
|
||||
if !has_solid(n) {
|
||||
return None;
|
||||
}
|
||||
let n = &*resolve(doc, n, "").ok()?;
|
||||
if is_container(n) {
|
||||
return group_solid_art(n);
|
||||
}
|
||||
@@ -501,7 +668,7 @@ pub fn solid_art(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 }])?);
|
||||
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?);
|
||||
// Later geometry effects act on the projected art as a whole.
|
||||
remaining = n
|
||||
.appearance
|
||||
@@ -517,7 +684,7 @@ pub fn solid_art(n: &Node) -> Option<Node> {
|
||||
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 }])? {
|
||||
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);
|
||||
@@ -613,11 +780,11 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
|
||||
// Inflate lays out every puff at once (they share one eye); each Inflate and Extrude & Bevel
|
||||
// solid and its mesh are built as the loop reaches them.
|
||||
let mut inflated = (fx.id == INFLATE).then(|| {
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect();
|
||||
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
|
||||
vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view)
|
||||
});
|
||||
let extrude = extrude_options(&p);
|
||||
for (item, member, area, rule) in &parts {
|
||||
for (item, member, area, rule, _) in &parts {
|
||||
let item: &AppearanceItem = item;
|
||||
let mesh = match inflated.as_mut() {
|
||||
Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())),
|
||||
@@ -642,28 +809,44 @@ fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize, limit: usize)
|
||||
/// such effect, its solids can't be built, none has a triangle, or together they have more than
|
||||
/// [`MAX_MESH_TRIANGLES`].
|
||||
pub fn three_d_meshes(n: &Node) -> Result<Vec<SolidMesh>, String> {
|
||||
meshes_within(n, &mut 0, MAX_MESH_TRIANGLES)
|
||||
three_d_meshes_in(None, n)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
|
||||
/// build from the art they show ([`resolve`]).
|
||||
pub fn three_d_meshes_in(doc: Option<&Document>, n: &Node) -> Result<Vec<SolidMesh>, String> {
|
||||
meshes_within(doc, n, &mut 0, MAX_MESH_TRIANGLES)
|
||||
}
|
||||
|
||||
/// The meshes of every object in `nodes` ([`three_d_meshes`]), in order, as one 3D asset exports
|
||||
/// them: the objects share one budget of [`MAX_MESH_TRIANGLES`].
|
||||
pub fn three_d_meshes_of<'a>(nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
|
||||
meshes_of_within(nodes, MAX_MESH_TRIANGLES)
|
||||
three_d_meshes_of_in(None, nodes)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes_of`] with a budget of `limit` triangles.
|
||||
fn meshes_of_within<'a>(nodes: impl IntoIterator<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
/// [`three_d_meshes_of`] with the symbols of `doc` ([`three_d_meshes_in`]).
|
||||
pub fn three_d_meshes_of_in<'a>(doc: Option<&Document>, nodes: impl IntoIterator<Item = &'a Node>) -> Result<Vec<SolidMesh>, String> {
|
||||
meshes_of_within(doc, nodes, MAX_MESH_TRIANGLES)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes_of_in`] with a budget of `limit` triangles.
|
||||
fn meshes_of_within<'a>(doc: Option<&Document>, nodes: impl IntoIterator<Item = &'a Node>, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
let mut total = 0usize;
|
||||
let mut out = vec![];
|
||||
for n in nodes {
|
||||
out.extend(meshes_within(n, &mut total, limit)?);
|
||||
out.extend(meshes_within(doc, n, &mut total, limit)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// [`three_d_meshes`], with the triangles of every solid added to `total` as it is built and an
|
||||
/// [`three_d_meshes_in`], with the triangles of every solid added to `total` as it is built and an
|
||||
/// error as soon as `total` passes `limit`.
|
||||
fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
fn meshes_within(doc: Option<&Document>, n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidMesh>, String> {
|
||||
if !crate::has_revolve(n) && !has_solid(n) {
|
||||
return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into());
|
||||
}
|
||||
let label = n.appearance.effects.iter().find(|e| e.visible && is_three_d(&e.id)).map_or("This effect", |e| label_of(&e.id));
|
||||
let n = &*resolve(doc, n, label)?;
|
||||
let meshes = if crate::has_revolve(n) {
|
||||
crate::revolve::revolve_meshes(n, total, limit)?
|
||||
} else if !has_solid(n) {
|
||||
@@ -675,19 +858,19 @@ fn meshes_within(n: &Node, total: &mut usize, limit: usize) -> Result<Vec<SolidM
|
||||
reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes);
|
||||
meshes_for(fx, &shapes, total, limit)?
|
||||
} else {
|
||||
let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?;
|
||||
let (path, rule) = object_profile(n, label)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let paints = solid_paints(n);
|
||||
if let Some((index, fx)) = solid(&n.appearance.effects) {
|
||||
let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx);
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], total, limit)?
|
||||
meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }], total, limit)?
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
let mut out = vec![];
|
||||
for item in &paints {
|
||||
if let Some((index, fx)) = solid(item.effects()) {
|
||||
let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item));
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], total, limit)?);
|
||||
out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }], total, limit)?);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -712,3 +895,6 @@ mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_mesh;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_sources;
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
//! The art a 3D effect builds its solids from when its object, or a member of its group, shows art
|
||||
//! it does not hold as paths: a symbol instance its symbol's art placed by the instance, a blend its
|
||||
//! key objects and steps, a clipping group (or a layer with a clipping mask) its art inside the
|
||||
//! clipping path. Each becomes a plain group of that art, keeping its transparency, so the solids
|
||||
//! build from what the object shows. Images build no solid: they are traced first.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeKind};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_pathops as po;
|
||||
|
||||
use super::{MAX_MEMBERS, Scope, geometry, is_container, members, profile, region, reshape_members, solid_paints};
|
||||
use crate::{GeomContext, is_geometry};
|
||||
|
||||
/// The deepest nesting of symbol instances, blends and clipping groups resolved for one 3D effect.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
|
||||
/// The most objects resolving one 3D effect's art may visit or make: every object listed (hidden
|
||||
/// ones and guides included), every symbol instance, blend and clipping group resolved, every blend
|
||||
/// step made and every object cut to a clipping path. A symbol that holds instances of another
|
||||
/// repeats that symbol's art at every level, so art with few visible objects can still take this
|
||||
/// many steps.
|
||||
pub(super) const MAX_WORK: usize = 1 << 18;
|
||||
|
||||
/// The error for an image a 3D effect is asked to build from.
|
||||
pub(crate) fn image_refusal(label: &str) -> String {
|
||||
format!("{label} can't build a solid from an image: trace it first with Object › Image Trace › Make.")
|
||||
}
|
||||
|
||||
/// Is `n` a symbol instance, a blend or a clipping group or layer?
|
||||
pub(crate) fn resolves(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::SymbolInstance { .. } | NodeKind::Blend { .. }) || n.clips()
|
||||
}
|
||||
|
||||
/// Is `n`, or a visible member of `n` at any depth of its groups and layers, one [`resolves`] takes?
|
||||
fn needs(n: &Node) -> bool {
|
||||
let mut stack = vec![n];
|
||||
while let Some(n) = stack.pop() {
|
||||
if resolves(n) {
|
||||
return true;
|
||||
}
|
||||
if is_container(n) {
|
||||
stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| &**c));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// `n` as a 3D effect builds from it: unchanged when neither it nor a visible member of its groups
|
||||
/// is a symbol instance, a blend or a clipping group, otherwise a group with `n`'s id, appearance
|
||||
/// and transparency holding that art (a symbol instance's symbol art from `doc`). An error names
|
||||
/// what can't be resolved: an image, a missing symbol, art nested or numbering past the limits, or
|
||||
/// a clipping path too complex to cut the art with. `label` is the effect's name. The art is cut
|
||||
/// for the solid effect `n` carries ([`resolve_for`]).
|
||||
pub(crate) fn resolve<'a>(doc: Option<&Document>, n: &'a Node, label: &str) -> Result<Cow<'a, Node>, String> {
|
||||
let every_fill = super::solid(&n.appearance.effects).is_some_and(|(_, fx)| fx.id == super::ROTATE);
|
||||
resolve_for(doc, n, label, every_fill)
|
||||
}
|
||||
|
||||
/// [`resolve`] for the 3D effect that builds from `n`: `every_fill` for Rotate, which turns every
|
||||
/// fill, so each fill a clipping path cuts keeps its own place among the strokes. Otherwise the
|
||||
/// effect builds from the top fill, and the fills cut go together in the top fill's place.
|
||||
pub(crate) fn resolve_for<'a>(doc: Option<&Document>, n: &'a Node, label: &str, every_fill: bool) -> Result<Cow<'a, Node>, String> {
|
||||
if !needs(n) {
|
||||
return Ok(Cow::Borrowed(n));
|
||||
}
|
||||
let mut r = Resolver { doc, label, every_fill, leaves: 0, work: 0, shown: None };
|
||||
let children = r.content(n, 0);
|
||||
#[cfg(test)]
|
||||
WORK.with(|w| w.set(r.work));
|
||||
let mut out = n.clone();
|
||||
out.kind = NodeKind::Group { children: children?, clip: false };
|
||||
Ok(Cow::Owned(out))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// The objects the last [`resolve`] on this thread visited or made ([`Resolver::work`]).
|
||||
pub(super) static WORK: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
struct Resolver<'a> {
|
||||
doc: Option<&'a Document>,
|
||||
label: &'a str,
|
||||
/// Does each fill a clipping path cuts become a piece of its own ([`resolve_for`])?
|
||||
every_fill: bool,
|
||||
/// The objects other than groups and layers kept so far, counted against [`MAX_MEMBERS`].
|
||||
leaves: usize,
|
||||
/// The objects visited or made so far, counted against [`MAX_WORK`].
|
||||
work: usize,
|
||||
/// Inside clipping groups, the box of what their clipping paths show together.
|
||||
shown: Option<Rect>,
|
||||
}
|
||||
|
||||
impl Resolver<'_> {
|
||||
/// Count `steps` more objects visited or made, or an error past [`MAX_WORK`].
|
||||
fn spend(&mut self, steps: usize) -> Result<(), String> {
|
||||
self.work = self.work.saturating_add(steps);
|
||||
if self.work > MAX_WORK {
|
||||
return Err(self.too_much_work());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn too_much_work(&self) -> String {
|
||||
format!("{} can't build solids from this art: its symbol instances, blends and clipping groups hold too many objects", self.label)
|
||||
}
|
||||
|
||||
/// `f` run on the art of `c`. The effects of an object or group can move its art into a clip,
|
||||
/// so inside one with effects no image is left out for lying outside the clip ([`Self::shown`]).
|
||||
fn inside<T>(&mut self, c: &Node, f: impl FnOnce(&mut Self) -> Result<T, String>) -> Result<T, String> {
|
||||
let outer = self.shown;
|
||||
if has_effects(c) {
|
||||
self.shown = None;
|
||||
}
|
||||
let out = f(self);
|
||||
self.shown = outer;
|
||||
out
|
||||
}
|
||||
|
||||
/// The art `n`, a symbol instance, a blend, a clipping group or layer, or a group holding one,
|
||||
/// shows, as the members of a plain group.
|
||||
fn content(&mut self, n: &Node, depth: usize) -> Result<Vec<Arc<Node>>, String> {
|
||||
let label = self.label;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
self.spend(1)?;
|
||||
match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, xf } => {
|
||||
let missing = || format!("{label} can't build a solid from the instance of symbol \"{symbol}\": the symbol's art isn't available");
|
||||
let doc = self.doc.ok_or_else(missing)?;
|
||||
let sym = doc.symbols.iter().find(|s| s.name == *symbol).ok_or_else(missing)?;
|
||||
// Counted before the art is placed, so a symbol past the limit is never copied:
|
||||
// first its own objects, which the instance's overrides keep, then the most
|
||||
// objects placing makes, with those overrides and the symbol's 9-slice guides.
|
||||
if self.work.saturating_add(sym.art.count_with_masks()) > MAX_WORK {
|
||||
return Err(self.too_much_work());
|
||||
}
|
||||
let art = vectorcraft_doc::symbol_overrides::dynamic_art(&sym.art, n);
|
||||
let most = doc.symbol_slice_map(symbol, *xf).map_or(art.count_with_masks() as u64, |m| m.most_placed(&art, doc));
|
||||
if (self.work as u64).saturating_add(most) > MAX_WORK as u64 {
|
||||
return Err(self.too_much_work());
|
||||
}
|
||||
let placed = doc.place_symbol_art(symbol, &art, *xf).ok_or_else(missing)?;
|
||||
Ok(self.list(&[Arc::new(placed)], depth + 1)?.0)
|
||||
}
|
||||
NodeKind::Blend { children, spec } => {
|
||||
// Counted before the steps are made, so a blend past the limit is never expanded.
|
||||
let made = vectorcraft_doc::blend::blend_steps(children, spec).into_iter().fold(children.len(), usize::saturating_add);
|
||||
if self.leaves.saturating_add(made) > MAX_MEMBERS {
|
||||
return Err(self.too_many());
|
||||
}
|
||||
self.spend(made)?;
|
||||
let steps: Vec<Arc<Node>> = crate::expand_live(self.doc, n).into_iter().map(Arc::new).collect();
|
||||
Ok(self.list(&steps, depth + 1)?.0)
|
||||
}
|
||||
_ if n.clips() => {
|
||||
let Some((first, rest)) = kids(n).split_first() else { return Ok(vec![]) };
|
||||
if clip_nested_past(first, MAX_DEPTH.saturating_sub(depth)) {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
let clip = clip_area(first);
|
||||
// An image wholly outside every clip around it shows nothing and is left out.
|
||||
let outer = self.shown;
|
||||
let here = clip.0.bounds().unwrap_or(Rect::ZERO);
|
||||
self.shown = Some(outer.map_or(here, |o| o.intersect(here)));
|
||||
let listed = self.list(rest, depth + 1);
|
||||
self.shown = outer;
|
||||
let (inner, _) = listed?;
|
||||
let mut out = vec![];
|
||||
for c in &inner {
|
||||
if let Some(c) = self.clipped(c, &clip, depth + 1)? {
|
||||
out.push(Arc::new(c));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
_ => Ok(self.list(kids(n), depth + 1)?.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The visible members of `children` as plain art: images refused, guides left out, symbol
|
||||
/// instances, blends and clipping groups resolved into groups with their transparency and
|
||||
/// geometry effects, groups holding them rebuilt. Also whether any member changed: a group
|
||||
/// whose members did not is kept as it is. Each object is visited once.
|
||||
fn list(&mut self, children: &[Arc<Node>], depth: usize) -> Result<(Vec<Arc<Node>>, bool), String> {
|
||||
let mut out = Vec::with_capacity(children.len());
|
||||
let mut changed = false;
|
||||
for c in children {
|
||||
self.spend(1)?;
|
||||
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
continue;
|
||||
}
|
||||
if matches!(c.kind, NodeKind::Image(_)) {
|
||||
// Only an image with no effects or fills and strokes of its own is measured by its
|
||||
// frame; any other one may draw inside the clip.
|
||||
let plain = !has_effects(c) && c.appearance.items.iter().all(|i| !i.visible());
|
||||
let hidden = plain && self.shown.is_some_and(|k| c.visual_bounds().is_none_or(|b| b.intersect(k).area() <= 0.0));
|
||||
if hidden {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
return Err(image_refusal(self.label));
|
||||
}
|
||||
if resolves(c) {
|
||||
let kids = self.inside(c, |r| r.content(c, depth))?;
|
||||
out.push(Arc::new(with_effects(wrap(c, kids), c)));
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
if is_container(c) {
|
||||
// At the depth limit a group is kept, and not walked further, only when it holds no
|
||||
// group with members and nothing in it needs resolving.
|
||||
if depth >= MAX_DEPTH {
|
||||
if nested_past(c, 1) || needs(c) {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
out.push(c.clone());
|
||||
continue;
|
||||
}
|
||||
let (kids, inner) = self.inside(c, |r| r.list(kids(c), depth + 1))?;
|
||||
if inner {
|
||||
out.push(Arc::new(with_effects(wrap(c, kids), c)));
|
||||
changed = true;
|
||||
} else {
|
||||
out.push(c.clone());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
self.leaves += 1;
|
||||
if self.leaves > MAX_MEMBERS {
|
||||
return Err(self.too_many());
|
||||
}
|
||||
out.push(c.clone());
|
||||
}
|
||||
Ok((out, changed))
|
||||
}
|
||||
|
||||
fn too_complex(&self) -> String {
|
||||
format!("{}: the clipping path is too complex to cut the art with", self.label)
|
||||
}
|
||||
|
||||
fn too_many(&self) -> String {
|
||||
format!("{} takes at most {MAX_MEMBERS} objects", self.label)
|
||||
}
|
||||
|
||||
fn too_deep(&self) -> String {
|
||||
format!("{} can't build solids from art nested more than {MAX_DEPTH} levels deep", self.label)
|
||||
}
|
||||
|
||||
/// `n`, plain art inside a clipping group, cut to `clip`: each shape's fills as its area inside
|
||||
/// the clip and each stroke as the part of its outline inside it, filled in the stroke's paint,
|
||||
/// after the geometry effects of the shape and of the groups it is in; hidden objects and guides
|
||||
/// left out. `None` when nothing of it is inside. Objects that build no solid stay as they are.
|
||||
fn clipped(&mut self, n: &Node, clip: &(PathData, FillRule), depth: usize) -> Result<Option<Node>, String> {
|
||||
if depth > MAX_DEPTH {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
self.spend(1)?;
|
||||
if is_container(n) {
|
||||
let mut kids = vec![];
|
||||
if n.appearance.effects.iter().any(|e| e.visible && is_geometry(&e.id)) {
|
||||
// The group's geometry effects reshape its members together before the clip cuts
|
||||
// them, as they are drawn: each member becomes its reshaped outline.
|
||||
if nested_past(n, MAX_DEPTH.saturating_sub(depth)) {
|
||||
return Err(self.too_deep());
|
||||
}
|
||||
let mut shapes = members(n, self.label)?;
|
||||
reshape_members(n, &n.appearance.effects, &mut shapes);
|
||||
let mut cut = vec![];
|
||||
for s in shapes {
|
||||
if let Some(c) = self.clipped(&shape(n, s.path, s.rule, s.items), clip, depth + 1)? {
|
||||
cut.push((s.scopes, c));
|
||||
}
|
||||
}
|
||||
kids = nest(n, cut, 0);
|
||||
} else {
|
||||
for c in n.children().into_iter().flatten() {
|
||||
if !c.visible || matches!(c.kind, NodeKind::Path { guide: true, .. }) {
|
||||
continue;
|
||||
}
|
||||
if let Some(c) = self.clipped(c, clip, depth + 1)? {
|
||||
kids.push(Arc::new(c));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok((!kids.is_empty()).then(|| wrap(n, kids)));
|
||||
}
|
||||
let Some((path, rule)) = profile(n) else { return Ok(Some(n.clone())) };
|
||||
let items = solid_paints(n);
|
||||
// The fills' piece goes where the top fill is, the one Extrude & Bevel and Inflate build
|
||||
// their solid from, so the strokes above and below it keep their places. Rotate turns
|
||||
// every fill, so each fill is a piece in its own place.
|
||||
let top_fill = items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_)));
|
||||
let fill_pieces =
|
||||
if self.every_fill { items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).count() } else { usize::from(top_fill.is_some()) };
|
||||
// Each cut makes a piece, counted before it is made: the shape was counted once already.
|
||||
let made = fill_pieces + items.iter().filter(|i| matches!(i, AppearanceItem::Stroke(_))).count();
|
||||
self.spend(made)?;
|
||||
self.leaves = self.leaves.saturating_add(made.saturating_sub(1));
|
||||
if self.leaves > MAX_MEMBERS {
|
||||
return Err(self.too_many());
|
||||
}
|
||||
let path = geometry(&path, &n.appearance.effects, &GeomContext::of(n));
|
||||
let cut = |area: &PathData, rule: FillRule| -> Result<Option<PathData>, String> {
|
||||
let inside =
|
||||
po::try_boolean(area, rule, &clip.0, clip.1, po::BoolOp::Intersect, po::DEFAULT_PRECISION).map_err(|_| self.too_complex())?;
|
||||
Ok(inside.bounds().is_some().then_some(inside))
|
||||
};
|
||||
let fills: Vec<AppearanceItem> = items.iter().filter(|i| matches!(i, AppearanceItem::Fill(_))).cloned().collect();
|
||||
// Every fill paints the same area: it is cut once.
|
||||
let mut fill_area: Option<Option<PathData>> = None;
|
||||
let mut pieces = vec![];
|
||||
for (k, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
AppearanceItem::Fill(_) if self.every_fill || Some(k) == top_fill => {
|
||||
let area = match &fill_area {
|
||||
Some(area) => area.clone(),
|
||||
None => fill_area.insert(cut(&path, rule)?).clone(),
|
||||
};
|
||||
if let Some(area) = area {
|
||||
let painted = if self.every_fill { vec![item.clone()] } else { fills.clone() };
|
||||
pieces.push(Arc::new(shape(n, area, FillRule::NonZero, painted)));
|
||||
}
|
||||
}
|
||||
AppearanceItem::Fill(_) => {}
|
||||
AppearanceItem::Stroke(s) => {
|
||||
let (outline, outline_rule) = region(&path, rule, item);
|
||||
if let Some(area) = cut(&outline, outline_rule)? {
|
||||
let mut fill = FillLayer::new(s.paint.clone());
|
||||
fill.opacity = s.opacity;
|
||||
fill.blend = s.blend;
|
||||
pieces.push(Arc::new(shape(n, area, FillRule::NonZero, vec![AppearanceItem::Fill(fill)])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((!pieces.is_empty()).then(|| wrap(n, pieces)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The area clipping path `first` keeps, as the renderer clips by it ([`crate::clip_outline`]): a
|
||||
/// nested clipping group's own clipping path, the union of a group's members. Empty when it keeps
|
||||
/// nothing.
|
||||
fn clip_area(first: &Node) -> (PathData, FillRule) {
|
||||
crate::clip_outline(first).map_or_else(|| (PathData::default(), FillRule::NonZero), |(bp, rule)| (PathData::from_bezpath(&bp), rule))
|
||||
}
|
||||
|
||||
/// `pieces`, each with the [`Scope`]s of the groups it was in, back in groups of `n`'s id with
|
||||
/// those groups' opacity, blending mode and opacity mask: consecutive pieces sharing the scope at
|
||||
/// `level` go into one group, so its transparency applies to them together. `level` stays below the
|
||||
/// nesting the pieces came from ([`nested_past`]).
|
||||
fn nest(n: &Node, pieces: Vec<(Vec<Scope>, Node)>, level: usize) -> Vec<Arc<Node>> {
|
||||
let mut out = vec![];
|
||||
let mut run: Vec<(Vec<Scope>, Node)> = vec![];
|
||||
let mut key: Option<Scope> = None;
|
||||
let close = |run: &mut Vec<(Vec<Scope>, Node)>, scope: Option<Scope>, out: &mut Vec<Arc<Node>>| {
|
||||
let Some(scope) = scope else { return };
|
||||
out.push(Arc::new(scope.group(n.id, nest(n, std::mem::take(run), level + 1))));
|
||||
};
|
||||
for (scopes, piece) in pieces {
|
||||
let here = scopes.get(level).cloned();
|
||||
if here.as_ref().map(|s| s.key) != key.as_ref().map(|s| s.key) {
|
||||
close(&mut run, key.take(), &mut out);
|
||||
key = here.clone();
|
||||
}
|
||||
match here {
|
||||
Some(_) => run.push((scopes, piece)),
|
||||
None => out.push(Arc::new(piece)),
|
||||
}
|
||||
}
|
||||
close(&mut run, key, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `n` carry a visible effect?
|
||||
fn has_effects(n: &Node) -> bool {
|
||||
n.appearance.effects.iter().any(|e| e.visible)
|
||||
}
|
||||
|
||||
/// Does the group `n` hold shown objects nested more than `levels` deep in groups and layers?
|
||||
/// Only the members [`members`] walks are followed: hidden members are skipped, and the paths of
|
||||
/// a compound path are one shape. Walked without recursion.
|
||||
fn nested_past(n: &Node, levels: usize) -> bool {
|
||||
let mut stack = vec![(n, 0usize)];
|
||||
while let Some((n, level)) = stack.pop() {
|
||||
if level > levels {
|
||||
return true;
|
||||
}
|
||||
if level == 0 || is_container(n) {
|
||||
stack.extend(kids(n).iter().filter(|c| c.visible).map(|c| (&**c, level + 1)));
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Does the clipping path `n` reach objects nested more than `levels` deep along the members that
|
||||
/// [`Node::clip_shapes`] visits: the first member of a clipping group, the shown members of other
|
||||
/// groups and layers (not a group's shaper), and every member of a live object. Walked without
|
||||
/// recursion.
|
||||
fn clip_nested_past(n: &Node, levels: usize) -> bool {
|
||||
let mut stack = vec![(n, 0usize)];
|
||||
while let Some((n, level)) = stack.pop() {
|
||||
if level > levels {
|
||||
return true;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Group { children, clip: true } => stack.extend(children.first().map(|c| (&**c, level + 1))),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
let skip = usize::from(n.extras().shaper.is_some());
|
||||
stack.extend(children.iter().skip(skip).filter(|c| c.visible).map(|c| (&**c, level + 1)));
|
||||
}
|
||||
NodeKind::Compound { .. } | NodeKind::SymbolInstance { .. } => {}
|
||||
_ => stack.extend(kids(n).iter().map(|c| (&**c, level + 1))),
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The members of `n`, none for an object without members.
|
||||
fn kids(n: &Node) -> &[Arc<Node>] {
|
||||
n.children().map_or(&[], Vec::as_slice)
|
||||
}
|
||||
|
||||
/// A path of `n`'s id on `path`, painted by `items` under `rule`.
|
||||
fn shape(n: &Node, path: PathData, rule: FillRule, items: Vec<AppearanceItem>) -> Node {
|
||||
let mut piece = Node::path(n.id, path, Appearance { items, ..Default::default() });
|
||||
if let NodeKind::Path { rule: r, .. } = &mut piece.kind {
|
||||
*r = rule;
|
||||
}
|
||||
piece
|
||||
}
|
||||
|
||||
/// A plain group of `children` with `n`'s id, visibility, opacity, blending mode and opacity mask.
|
||||
fn wrap(n: &Node, children: Vec<Arc<Node>>) -> Node {
|
||||
let mut g = Node::group(n.id, children);
|
||||
g.name = n.name.clone();
|
||||
g.opacity = n.opacity;
|
||||
g.blend = n.blend;
|
||||
if let Some(mask) = &n.extras().mask {
|
||||
g.extras_mut().mask = Some(mask.clone());
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// `g`, the group `n` was resolved into, with `n`'s appearance: a 3D effect's group reshapes its
|
||||
/// members with its geometry effects ([`members`]), which read its stroke as they do when `n` is
|
||||
/// drawn. A group's own fills and strokes build no solid.
|
||||
fn with_effects(mut g: Node, n: &Node) -> Node {
|
||||
g.appearance = n.appearance.clone();
|
||||
g
|
||||
}
|
||||
@@ -205,12 +205,12 @@ fn a_stroked_open_path_builds_a_ribbon() {
|
||||
/// still draws.
|
||||
#[test]
|
||||
fn a_stroke_past_the_limits_is_left_out() {
|
||||
// 1,500 corners: within the 2,048 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..1500)
|
||||
// 40,000 corners: within the 65,536 points of the fill's solid, but not the stroke's outline.
|
||||
let points: Vec<Point> = (0..40_000)
|
||||
.map(|k| {
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 1500.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 100.0 } else { 96.0 };
|
||||
Point::new(200.0 + r * c, 200.0 + r * s)
|
||||
let (s, c) = (k as f64 * std::f64::consts::TAU / 40_000.0).sin_cos();
|
||||
let r = if k % 2 == 0 { 10_000.0 } else { 9_999.0 };
|
||||
Point::new(20_000.0 + r * c, 20_000.0 + r * s)
|
||||
})
|
||||
.collect();
|
||||
let path = PathData::single(vectorcraft_geom::SubPath::polyline(&points, true));
|
||||
@@ -591,13 +591,14 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
|
||||
// Hidden members and guides are left out; a group holding what can't become a solid is
|
||||
// refused with what to do.
|
||||
let mut g = group(EXTRUDE, json!({}));
|
||||
let mut clip = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]);
|
||||
clip.kind = NodeKind::Group { children: clip.children().unwrap().clone(), clip: true };
|
||||
let mut woven = Node::group(NodeId(5), vec![Arc::new(square_at(6, 0.0, RED))]);
|
||||
woven.kind = NodeKind::Intertwine { children: woven.children().unwrap().clone() };
|
||||
if let NodeKind::Group { children, .. } = &mut g.kind {
|
||||
children.push(Arc::new(clip));
|
||||
children.push(Arc::new(woven));
|
||||
}
|
||||
let err = validate_solid(&g, EXTRUDE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("clipping groups"), "{err}");
|
||||
assert!(err.contains("release or expand its other objects"), "{err}");
|
||||
assert!(err.contains("type, groups, clipping groups, blends and symbol instances"), "{err}");
|
||||
assert!(solid_art(&g).is_none(), "drawn as the group it is");
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ fn the_meshes_of_one_object_share_one_triangle_budget() {
|
||||
assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err());
|
||||
assert_eq!(out.len(), 1);
|
||||
// Building past the budget stops with an error instead of holding every mesh.
|
||||
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }];
|
||||
let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0, scopes: vec![] }];
|
||||
let Some(effect) = n.appearance.effects.first() else { panic!("no effect") };
|
||||
let mut total = 0;
|
||||
assert!(meshes_for(effect, &shapes, &mut total, MAX_MESH_TRIANGLES).is_ok());
|
||||
@@ -79,7 +79,7 @@ fn revolve_stops_at_the_first_mesh_past_the_budget() {
|
||||
assert!(whole.as_ref().is_err_and(|e| !e.contains("to export")), "the stroke's own error: {whole:?}");
|
||||
// With room for less than the fill's mesh, the stroke's is never built.
|
||||
let mut total = 0;
|
||||
let capped = meshes_within(&n, &mut total, triangles - 1);
|
||||
let capped = meshes_within(None, &n, &mut total, triangles - 1);
|
||||
assert!(capped.as_ref().is_err_and(|e| e.contains("too complex to export")), "{capped:?}");
|
||||
assert_eq!(total, triangles);
|
||||
}
|
||||
@@ -92,10 +92,10 @@ fn the_objects_of_one_asset_share_one_triangle_budget() {
|
||||
b.appearance.effects.push(fx(EXTRUDE, json!({})));
|
||||
let triangles = |n: &Node| three_d_meshes(n).unwrap().iter().map(|m| m.mesh.triangles.len()).sum::<usize>();
|
||||
let (ta, tb) = (triangles(&a), triangles(&b));
|
||||
assert!(meshes_of_within([&a], ta).is_ok());
|
||||
assert!(meshes_of_within([&b], tb).is_ok());
|
||||
assert!(meshes_of_within([&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export")));
|
||||
assert_eq!(meshes_of_within([&a, &b], ta + tb).map(|m| m.len()), Ok(4));
|
||||
assert!(meshes_of_within(None, [&a], ta).is_ok());
|
||||
assert!(meshes_of_within(None, [&b], tb).is_ok());
|
||||
assert!(meshes_of_within(None, [&a, &b], ta.max(tb)).is_err_and(|e| e.contains("too complex to export")));
|
||||
assert_eq!(meshes_of_within(None, [&a, &b], ta + tb).map(|m| m.len()), Ok(4));
|
||||
assert_eq!(three_d_meshes_of([&a, &b]).map(|m| m.len()), Ok(4));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::Color;
|
||||
use vectorcraft_doc::{AppearanceItem, BlendSpacing, BlendSpec, Document, FillLayer, ImageObject, NodeId, StrokeLayer, Symbol};
|
||||
use vectorcraft_geom::{Affine, shapes};
|
||||
|
||||
use super::*;
|
||||
|
||||
const TYPES: [&str; 5] = [EXTRUDE, CLASSIC_EXTRUDE, INFLATE, ROTATE, crate::REVOLVE];
|
||||
|
||||
/// Rotate facing the viewer: the art drawn where it is.
|
||||
fn front() -> Value {
|
||||
json!({"rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0})
|
||||
}
|
||||
|
||||
fn rect(id: u64, x0: f64, y0: f64, x1: f64, y1: f64, stroke: f64) -> Node {
|
||||
let path = shapes::rectangle(Rect::new(x0, y0, x1, y1));
|
||||
let stroke_paint = if stroke > 0.0 { Paint::solid(Color::BLACK) } else { Paint::None };
|
||||
Node::path(NodeId(id), path, Appearance::basic(Paint::solid(Color::rgb(0.2, 0.5, 0.8)), stroke_paint, stroke))
|
||||
}
|
||||
|
||||
fn with(mut n: Node, id: &str, params: Value) -> Node {
|
||||
n.appearance.effects.push(crate::new_effect(id, ¶ms).unwrap());
|
||||
n
|
||||
}
|
||||
|
||||
fn art(d: &Document, n: &Node, id: &str) -> Option<Node> {
|
||||
if id == crate::REVOLVE { crate::revolve_art_in(Some(d), n) } else { solid_art_in(Some(d), n) }
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
n.visual_bounds().unwrap()
|
||||
}
|
||||
|
||||
fn close(a: Rect, b: Rect, tolerance: f64) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(p, q)| (p - q).abs() <= tolerance)
|
||||
}
|
||||
|
||||
fn document() -> Document {
|
||||
let mut d = Document::new(1000.0, 1000.0);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Box".into(), art: Arc::new(rect(0, 0.0, 0.0, 100.0, 100.0, 2.0)) });
|
||||
d
|
||||
}
|
||||
|
||||
/// A rectangular clipping path covering `clip`.
|
||||
fn clipping_path(id: u64, clip: Rect) -> Node {
|
||||
let mut path = Node::path(NodeId(id), shapes::rectangle(clip), Appearance::default());
|
||||
if let NodeKind::Path { clipping, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// A clipping group: `art` inside a rectangular clipping path covering `clip`.
|
||||
fn clip_group(id: u64, clip: Rect, art: Node) -> Node {
|
||||
Node::new(NodeId(id), NodeKind::Group { children: vec![Arc::new(clipping_path(id + 1, clip)), Arc::new(art)], clip: true })
|
||||
}
|
||||
|
||||
fn blend(id: u64, steps: u32) -> Node {
|
||||
let keys = vec![Arc::new(rect(id + 1, 100.0, 100.0, 140.0, 140.0, 0.0)), Arc::new(rect(id + 2, 300.0, 120.0, 360.0, 180.0, 0.0))];
|
||||
let spec = BlendSpec { spacing: BlendSpacing::Steps(steps), ..Default::default() };
|
||||
Node::new(NodeId(id), NodeKind::Blend { children: keys, spec: Box::new(spec) })
|
||||
}
|
||||
|
||||
fn instance(id: u64, symbol: &str, xf: Affine) -> Node {
|
||||
Node::new(NodeId(id), NodeKind::SymbolInstance { symbol: symbol.into(), xf })
|
||||
}
|
||||
|
||||
fn image(id: u64) -> Node {
|
||||
Node::new(
|
||||
NodeId(id),
|
||||
NodeKind::Image(Box::new(ImageObject {
|
||||
key: "k".into(),
|
||||
width: 10,
|
||||
height: 10,
|
||||
xf: Affine::IDENTITY,
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
fn sources() -> [(&'static str, Node); 3] {
|
||||
[
|
||||
("clipping group", clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0))),
|
||||
("blend", blend(20, 3)),
|
||||
("symbol instance", instance(30, "Box", Affine::translate((300.0, 0.0)))),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_three_d_type_builds_clipping_groups_blends_and_symbol_instances_alone_and_in_a_group() {
|
||||
let d = document();
|
||||
for (what, source) in sources() {
|
||||
for id in TYPES {
|
||||
let n = with(source.clone(), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id} on a {what}");
|
||||
assert!(art(&d, &n, id).is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws");
|
||||
assert!(three_d_meshes_in(Some(&d), &n).is_ok_and(|m| !m.is_empty()), "{id} on a {what} exports");
|
||||
let g = with(Node::group(NodeId(90), vec![Arc::new(source.clone()), Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0))]), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding a {what}");
|
||||
assert!(art(&d, &g, id).is_some(), "{id} on a group holding a {what} draws");
|
||||
assert!(three_d_meshes_in(Some(&d), &g).is_ok_and(|m| !m.is_empty()), "{id} on a group holding a {what} exports");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipping_groups_solid_is_built_from_its_art_inside_the_clip() {
|
||||
let d = document();
|
||||
let clip = Rect::new(100.0, 100.0, 150.0, 200.0);
|
||||
let fill_only = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0)), ROTATE, front());
|
||||
let cut = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front());
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &fill_only).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01));
|
||||
// The stroke is cut to the clip too: nothing reaches past it but the faces' seams.
|
||||
let stroked = with(clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 8.0)), EXTRUDE, front());
|
||||
let b = bounds(&solid_art_in(Some(&d), &stroked).unwrap());
|
||||
let outside = b.x0 < clip.x0 - 1.0 || b.x1 > clip.x1 + 1.0;
|
||||
let extruded =
|
||||
bounds(&solid_art(&with(Node::group(NodeId(10), vec![Arc::new(rect(12, 100.0, 100.0, 150.0, 200.0, 0.0))]), EXTRUDE, front())).unwrap());
|
||||
assert!(!outside && close(b, extruded, 1.0), "{b:?} within {clip:?}");
|
||||
// A clip inside a clip keeps what both show.
|
||||
let nested =
|
||||
with(clip_group(40, Rect::new(120.0, 0.0, 400.0, 400.0), clip_group(10, clip, rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))), ROTATE, front());
|
||||
let both = with(Node::group(NodeId(10), vec![Arc::new(rect(12, 120.0, 100.0, 150.0, 200.0, 0.0))]), ROTATE, front());
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &nested).unwrap()), bounds(&solid_art(&both).unwrap()), 0.01));
|
||||
// A clipping group used as the clipping path clips by its own clipping path, not by its art.
|
||||
let mask = Arc::new(clip_group(70, clip, rect(72, 100.0, 100.0, 110.0, 110.0, 0.0)));
|
||||
let by_mask = with(
|
||||
Node::new(NodeId(10), NodeKind::Group { children: vec![mask, Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }),
|
||||
ROTATE,
|
||||
front(),
|
||||
);
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &by_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01));
|
||||
// A stroke between two fills keeps its place below the top fill's solid.
|
||||
let mut layered = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0);
|
||||
layered.appearance = Appearance {
|
||||
items: vec![
|
||||
AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(1.0, 0.0, 0.0)))),
|
||||
AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::rgb(0.0, 1.0, 0.0)), 6.0)),
|
||||
AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0)))),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
.into();
|
||||
let colors = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), EXTRUDE, json!({}))).unwrap().iter().map(|m| m.color).collect::<Vec<_>>();
|
||||
let plain = colors(&Node::group(NodeId(10), vec![Arc::new(layered.clone())]));
|
||||
assert_eq!(plain.len(), 2, "the stroke's solid and the top fill's");
|
||||
assert_eq!(colors(&clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered.clone())), plain);
|
||||
// Rotate turns both fills and the stroke, each in its own place.
|
||||
let turned = |n: &Node| three_d_meshes_in(Some(&d), &with(n.clone(), ROTATE, front())).unwrap().iter().map(|m| m.color).collect::<Vec<_>>();
|
||||
let plain = turned(&Node::group(NodeId(10), vec![Arc::new(layered.clone())]));
|
||||
assert_eq!(plain.len(), 3, "both fills' surfaces and the stroke's");
|
||||
let clipped = clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), layered);
|
||||
assert_eq!(turned(&clipped), plain);
|
||||
assert_eq!(validate_three_d_in(Some(&d), &clipped, ROTATE, &json!({})), Ok(()));
|
||||
// Each fill Rotate turns counts against the limit on objects; another effect takes the top
|
||||
// fill only, whatever 3D effect the object carries before it changes type.
|
||||
let mut many = rect(12, 100.0, 100.0, 150.0, 200.0, 0.0);
|
||||
many.appearance =
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::BLACK))); MAX_MEMBERS + 1], ..Default::default() }.into();
|
||||
let rotated = with(clip_group(10, Rect::new(0.0, 0.0, 400.0, 400.0), many), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &rotated, ROTATE, &json!({})).is_err_and(|e| e.contains("takes at most")));
|
||||
for id in [EXTRUDE, crate::REVOLVE] {
|
||||
assert_eq!(validate_three_d_in(Some(&d), &rotated, id, &json!({})), Ok(()), "Rotate changed to {id}");
|
||||
}
|
||||
// Art nested past the limit inside a clipping group is refused, not followed to any depth.
|
||||
let mut deep = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0);
|
||||
for i in 0..200 {
|
||||
deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]);
|
||||
}
|
||||
let n = with(clip_group(10, clip, deep.clone()), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep")));
|
||||
assert!(solid_art_in(Some(&d), &n).is_none());
|
||||
// So is a clipping path nested past the limit, and a symbol's art nested past it.
|
||||
let by_deep =
|
||||
Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(deep.clone()), Arc::new(rect(12, 0.0, 0.0, 10.0, 10.0, 0.0))], clip: true });
|
||||
let mut deep_symbol = d.clone();
|
||||
deep_symbol.symbols.push(Symbol { dynamic: false, name: "Deep".into(), art: Arc::new(deep) });
|
||||
for n in [with(by_deep, ROTATE, front()), with(instance(30, "Deep", Affine::IDENTITY), ROTATE, front())] {
|
||||
assert!(validate_three_d_in(Some(&deep_symbol), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep")), "{:?}", n.id);
|
||||
assert!(three_d_meshes_in(Some(&deep_symbol), &n).is_err());
|
||||
}
|
||||
// A compound path counts as one shape at the depth limit: a symbol's art nested as deep as a
|
||||
// path may be also takes a compound path there.
|
||||
let square = rect(80, 0.0, 0.0, 10.0, 10.0, 0.0);
|
||||
let mut compound =
|
||||
Node::new(NodeId(81), NodeKind::Compound { children: vec![Arc::new(square.clone())], rule: vectorcraft_geom::FillRule::NonZero });
|
||||
compound.appearance = square.appearance.clone();
|
||||
let mut seen = vec![];
|
||||
for levels in 60..70 {
|
||||
let ok = |leaf: &Node| {
|
||||
let mut art = leaf.clone();
|
||||
for i in 0..levels {
|
||||
art = Node::group(NodeId(2000 + i), vec![Arc::new(art)]);
|
||||
}
|
||||
let mut doc = d.clone();
|
||||
doc.symbols.push(Symbol { dynamic: false, name: "Nested".into(), art: Arc::new(art) });
|
||||
validate_three_d_in(Some(&doc), &with(instance(30, "Nested", Affine::IDENTITY), ROTATE, front()), ROTATE, &json!({})).is_ok()
|
||||
};
|
||||
let path_ok = ok(&square);
|
||||
assert_eq!(ok(&compound), path_ok, "{levels} levels");
|
||||
seen.push(path_ok);
|
||||
}
|
||||
assert!(seen.contains(&true) && seen.contains(&false), "the levels cross the limit: {seen:?}");
|
||||
// A clipping group used as the clipping path clips by its first member only, so deep art it
|
||||
// clips is not walked for the clip.
|
||||
let mask = Node::new(
|
||||
NodeId(70),
|
||||
NodeKind::Group { children: vec![Arc::new(clipping_path(71, clip)), Arc::new(deep_symbol.symbols[1].art.as_ref().clone())], clip: true },
|
||||
);
|
||||
let by_deep_mask = with(
|
||||
Node::new(NodeId(10), NodeKind::Group { children: vec![Arc::new(mask), Arc::new(rect(12, 100.0, 100.0, 200.0, 200.0, 0.0))], clip: true }),
|
||||
ROTATE,
|
||||
front(),
|
||||
);
|
||||
assert_eq!(validate_three_d_in(Some(&d), &by_deep_mask, ROTATE, &json!({})), Ok(()));
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &by_deep_mask).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01));
|
||||
// A clip that shows none of the art builds nothing, and says so.
|
||||
let empty = clip_group(10, Rect::new(500.0, 500.0, 550.0, 550.0), rect(12, 100.0, 100.0, 200.0, 200.0, 2.0));
|
||||
for id in TYPES {
|
||||
let n = with(empty.clone(), id, json!({}));
|
||||
assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("at least two anchors")), "{id}");
|
||||
assert!(art(&d, &n, id).is_none(), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_instances_solid_is_its_symbols_art_where_the_instance_places_it() {
|
||||
let d = document();
|
||||
let placed = with(instance(30, "Box", Affine::translate((300.0, 50.0))), EXTRUDE, json!({}));
|
||||
let same = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 300.0, 50.0, 400.0, 150.0, 2.0))]), EXTRUDE, json!({}));
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&same).unwrap()), 0.01));
|
||||
// A redefined symbol reshapes the solid.
|
||||
let mut wider = d.clone();
|
||||
wider.symbols[0].art = Arc::new(rect(0, 0.0, 0.0, 200.0, 100.0, 2.0));
|
||||
assert!(bounds(&solid_art_in(Some(&wider), &placed).unwrap()).width() > bounds(&solid_art_in(Some(&d), &placed).unwrap()).width() + 50.0);
|
||||
// Without the symbol there is nothing to build from.
|
||||
let missing = with(instance(30, "Gone", Affine::IDENTITY), EXTRUDE, json!({}));
|
||||
assert!(validate_three_d_in(Some(&d), &missing, EXTRUDE, &json!({})).is_err_and(|e| e.contains("\"Gone\"")));
|
||||
assert!(validate_three_d(&placed, EXTRUDE, &json!({})).is_err(), "no document: no symbol art");
|
||||
// A symbol holding an instance of itself is refused, not followed without end.
|
||||
let mut looped = d.clone();
|
||||
let inner = Arc::new(instance(0, "Loop", Affine::translate((10.0, 10.0))));
|
||||
looped.symbols.push(Symbol {
|
||||
dynamic: false,
|
||||
name: "Loop".into(),
|
||||
art: Arc::new(Node::group(NodeId(0), vec![Arc::new(rect(0, 0.0, 0.0, 10.0, 10.0, 0.0)), inner])),
|
||||
});
|
||||
let n = with(instance(30, "Loop", Affine::IDENTITY), EXTRUDE, json!({}));
|
||||
assert!(validate_three_d_in(Some(&looped), &n, EXTRUDE, &json!({})).is_err());
|
||||
assert!(solid_art_in(Some(&looped), &n).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blends_solid_is_built_from_its_keys_and_steps() {
|
||||
let d = document();
|
||||
for steps in [1, 3, 8] {
|
||||
let n = with(blend(20, steps), ROTATE, front());
|
||||
let expanded = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, steps)).into_iter().map(Arc::new).collect());
|
||||
assert_eq!(expanded.children().unwrap().len(), steps as usize + 2);
|
||||
let same = with(expanded, ROTATE, front());
|
||||
let (a, b) = (solid_art_in(Some(&d), &n).unwrap(), solid_art(&same).unwrap());
|
||||
assert_eq!(a.children().unwrap().len(), b.children().unwrap().len(), "a surface for each key and step");
|
||||
assert!(close(bounds(&a), bounds(&b), 0.01));
|
||||
}
|
||||
// A blend making more objects than a 3D effect takes is refused.
|
||||
let keys = (0..6).map(|i| Arc::new(rect(21 + i, 100.0 * i as f64, 0.0, 100.0 * i as f64 + 40.0, 40.0, 0.0))).collect();
|
||||
let spec = BlendSpec { spacing: BlendSpacing::Steps(1000), ..Default::default() };
|
||||
let n = with(Node::new(NodeId(20), NodeKind::Blend { children: keys, spec: Box::new(spec) }), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at most 4096 objects")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_are_refused_alone_and_in_a_group_with_image_trace_named() {
|
||||
let d = document();
|
||||
for id in TYPES {
|
||||
for n in [
|
||||
with(image(50), id, json!({})),
|
||||
with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(image(53))]), id, json!({})),
|
||||
with(clip_group(60, Rect::new(0.0, 0.0, 10.0, 10.0), image(62)), id, json!({})),
|
||||
] {
|
||||
let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err();
|
||||
assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}");
|
||||
assert!(art(&d, &n, id).is_none(), "{id}");
|
||||
assert!(three_d_meshes_in(Some(&d), &n).is_err_and(|e| e.contains("Image Trace")), "{id}");
|
||||
}
|
||||
// An image wholly outside the clip, alone or in a group, shows nothing and is left out;
|
||||
// one the clip shows part of is refused.
|
||||
let clip = Rect::new(100.0, 100.0, 150.0, 150.0);
|
||||
let masked = |inside: bool| {
|
||||
let mut img = image(63);
|
||||
if let NodeKind::Image(i) = &mut img.kind {
|
||||
i.xf = Affine::translate(if inside { (95.0, 95.0) } else { (300.0, 300.0) });
|
||||
}
|
||||
img
|
||||
};
|
||||
for img in [masked(false), Node::group(NodeId(64), vec![Arc::new(masked(false))])] {
|
||||
let children = vec![Arc::new(clipping_path(61, clip)), Arc::new(rect(62, 100.0, 100.0, 200.0, 200.0, 0.0)), Arc::new(img)];
|
||||
let n = with(Node::new(NodeId(60), NodeKind::Group { children, clip: true }), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}");
|
||||
assert!(art(&d, &n, id).is_some(), "{id}");
|
||||
}
|
||||
let partly = with(clip_group(60, clip, masked(true)), id, json!({}));
|
||||
assert!(validate_three_d_in(Some(&d), &partly, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}");
|
||||
// An effect on the image, or on a group holding it, can move it into the clip: refused.
|
||||
let moved = |n: Node| with(n, "distort.transform", json!({"moveH": -200.0, "moveV": -200.0}));
|
||||
for img in [moved(masked(false)), moved(Node::group(NodeId(64), vec![Arc::new(masked(false))]))] {
|
||||
let n = with(clip_group(60, clip, img), id, json!({}));
|
||||
assert!(validate_three_d_in(Some(&d), &n, id, &json!({})).is_err_and(|e| e.contains("Image Trace")), "{id}");
|
||||
}
|
||||
// A hidden image is not drawn, so it builds nothing and refuses nothing.
|
||||
let mut hidden = image(53);
|
||||
hidden.visible = false;
|
||||
let g = with(Node::group(NodeId(51), vec![Arc::new(rect(52, 0.0, 0.0, 10.0, 10.0, 0.0)), Arc::new(hidden)]), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Symbols `L0` to `L{levels}`: `L0` holds `leaf`, each later one `fan` instances of the one
|
||||
/// before it.
|
||||
fn nested_symbols(levels: usize, fan: usize, leaf: Node) -> Document {
|
||||
let mut d = document();
|
||||
d.symbols.push(Symbol { dynamic: false, name: "L0".into(), art: Arc::new(Node::group(NodeId(0), vec![Arc::new(leaf)])) });
|
||||
for level in 1..=levels {
|
||||
let below = format!("L{}", level - 1);
|
||||
let members = (0..fan).map(|i| Arc::new(instance(0, &below, Affine::translate((i as f64 * 2.0, 0.0))))).collect();
|
||||
d.symbols.push(Symbol { dynamic: false, name: format!("L{level}"), art: Arc::new(Node::group(NodeId(0), members)) });
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_symbol_instances_that_repeat_hidden_art_are_refused_past_the_work_limit() {
|
||||
// Four instances at each of ten levels repeat the hidden path about a million times, with no
|
||||
// object to count against the 4,096-object limit.
|
||||
let mut hidden = rect(0, 0.0, 0.0, 10.0, 10.0, 0.0);
|
||||
hidden.visible = false;
|
||||
let d = nested_symbols(10, 4, hidden);
|
||||
for id in TYPES {
|
||||
let n = with(instance(30, "L10", Affine::IDENTITY), id, json!({}));
|
||||
let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err();
|
||||
assert!(err.contains("hold too many objects"), "{id}: {err}");
|
||||
assert!(source::WORK.with(std::cell::Cell::get) <= source::MAX_WORK + 1, "{id}: stopped at the limit");
|
||||
assert!(art(&d, &n, id).is_none(), "{id}");
|
||||
assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}");
|
||||
}
|
||||
// A clipped shape with more strokes than a 3D effect takes objects is refused before its
|
||||
// strokes are cut into pieces.
|
||||
let mut stroked = rect(12, 100.0, 100.0, 200.0, 200.0, 0.0);
|
||||
let strokes = (0..5000).map(|_| AppearanceItem::Stroke(StrokeLayer::new(Paint::solid(Color::BLACK), 2.0))).collect();
|
||||
stroked.appearance = Appearance { items: strokes, ..Default::default() }.into();
|
||||
let n = with(clip_group(10, Rect::new(100.0, 100.0, 150.0, 200.0), stroked), ROTATE, front());
|
||||
let err = validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("takes at most 4096 objects") && !err.contains("on a group"), "{err}");
|
||||
// The same nesting with 1,024 visible squares stays far below the limit and builds: each
|
||||
// square, group and instance is visited once (an instance twice: listed, then resolved).
|
||||
let d = nested_symbols(5, 4, rect(0, 0.0, 0.0, 10.0, 10.0, 0.0));
|
||||
let n = with(instance(30, "L5", Affine::IDENTITY), ROTATE, front());
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(()));
|
||||
let work = source::WORK.with(std::cell::Cell::get);
|
||||
assert!(work <= 1024 + 1365 * 3, "{work}");
|
||||
assert!(solid_art_in(Some(&d), &n).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_holding_more_objects_than_the_work_limit_is_refused_before_its_art_is_placed() {
|
||||
let mut d = document();
|
||||
let members = (0..=source::MAX_WORK).map(|i| Arc::new(rect(0, i as f64, 0.0, i as f64 + 1.0, 1.0, 0.0))).collect();
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Big".into(), art: Arc::new(Node::group(NodeId(0), members)) });
|
||||
let n = with(instance(30, "Big", Affine::IDENTITY), EXTRUDE, json!({}));
|
||||
let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("hold too many objects"), "{err}");
|
||||
let work = source::WORK.with(std::cell::Cell::get);
|
||||
assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}");
|
||||
}
|
||||
|
||||
/// A gradient fill: a path filled with one is drawn in clip groups where it crosses 9-slice guides.
|
||||
fn gradient() -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientPaint};
|
||||
Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())))
|
||||
}
|
||||
|
||||
/// A document whose symbol "Sliced" has 9-slice guides and holds `count` paths across all of them,
|
||||
/// each filled with `fill`, and an Extrude on an instance of it at twice its size.
|
||||
fn sliced(count: usize, fill: impl Fn() -> Paint) -> (Document, Node) {
|
||||
let mut d = document();
|
||||
let members = (0..count).map(|i| {
|
||||
Arc::new(Node::path(NodeId(i as u64 + 1), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::basic(fill(), Paint::None, 0.0)))
|
||||
});
|
||||
d.symbols.push(Symbol { dynamic: true, name: "Sliced".into(), art: Arc::new(Node::group(NodeId(0), members.collect())) });
|
||||
d.set_symbol_nine_slice("Sliced", Some(vectorcraft_doc::SymbolNineSlice::default()));
|
||||
let n = with(instance(30, "Sliced", Affine::scale(2.0)), EXTRUDE, json!({}));
|
||||
(d, n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_symbol_whose_9_slice_guides_would_place_too_many_objects_is_refused_before_its_art_is_placed() {
|
||||
// Each gradient path becomes a group of nine clip groups, so the art placed is far past the
|
||||
// limit while the symbol itself holds less than a tenth of it.
|
||||
let (d, n) = sliced(source::MAX_WORK / 20, gradient);
|
||||
assert!(d.symbols[1].art.count_with_masks() < source::MAX_WORK);
|
||||
let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("hold too many objects"), "{err}");
|
||||
let work = source::WORK.with(std::cell::Cell::get);
|
||||
assert!(work <= 2, "refused before the symbol's art is placed and listed: {work}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_symbol_whose_overrides_would_place_too_many_objects_is_refused_before_its_art_is_placed() {
|
||||
// Solid paths are cut at the guides and stay one path each; the instance's overrides fill them
|
||||
// with a gradient, which places each in nine clip groups.
|
||||
let (d, mut n) = sliced(source::MAX_WORK / 20, || Paint::solid(Color::BLACK));
|
||||
n.extras_mut().symbol_overrides = (0..source::MAX_WORK / 20)
|
||||
.map(|i| vectorcraft_doc::SymbolOverride { path: vec![i as u32], fill: Some(gradient()), ..Default::default() })
|
||||
.collect();
|
||||
let err = validate_three_d_in(Some(&d), &n, EXTRUDE, &json!({})).unwrap_err();
|
||||
assert!(err.contains("hold too many objects"), "{err}");
|
||||
let work = source::WORK.with(std::cell::Cell::get);
|
||||
assert!(work <= 2, "refused before the instance's art is placed and listed: {work}");
|
||||
// A few such paths build.
|
||||
let (d, n) = sliced(4, || Paint::solid(Color::BLACK));
|
||||
assert!(solid_art_in(Some(&d), &n).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_sources_and_sources_inside_sources_build_or_refuse_cleanly() {
|
||||
let mut d = document();
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Empty".into(), art: Arc::new(Node::group(NodeId(0), vec![])) });
|
||||
let clip = Rect::new(0.0, 0.0, 50.0, 100.0);
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Clipped".into(), art: Arc::new(clip_group(0, clip, rect(0, 0.0, 0.0, 100.0, 100.0, 0.0))) });
|
||||
let empty_clip = Node::new(NodeId(10), NodeKind::Group { children: vec![], clip: true });
|
||||
let empty_symbol = instance(30, "Empty", Affine::IDENTITY);
|
||||
let no_keys = Node::new(NodeId(20), NodeKind::Blend { children: vec![], spec: Box::default() });
|
||||
for id in TYPES {
|
||||
// Alone, an empty clipping group, an instance of an empty symbol and a blend without keys
|
||||
// build nothing and say so.
|
||||
for empty in [&empty_clip, &empty_symbol, &no_keys] {
|
||||
let n = with(empty.clone(), id, json!({}));
|
||||
let err = validate_three_d_in(Some(&d), &n, id, &json!({})).unwrap_err();
|
||||
assert!(err.contains("at least two anchors"), "{id} on {:?}: {err}", empty.kind);
|
||||
assert!(art(&d, &n, id).is_none(), "{id}");
|
||||
assert!(three_d_meshes_in(Some(&d), &n).is_err(), "{id}");
|
||||
// In a group they add nothing, and the group's other members still build.
|
||||
let g = with(Node::group(NodeId(90), vec![Arc::new(empty.clone()), Arc::new(rect(91, 0.0, 0.0, 50.0, 50.0, 0.0))]), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {:?}", empty.kind);
|
||||
assert!(art(&d, &g, id).is_some(), "{id}");
|
||||
}
|
||||
// A blend set to no steps draws one step, and builds from its keys and that step.
|
||||
let n = with(blend(20, 0), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, id, &json!({})), Ok(()), "{id}");
|
||||
}
|
||||
let drawn = Node::group(NodeId(20), crate::expand_live(Some(&d), &blend(20, 0)).into_iter().map(Arc::new).collect());
|
||||
assert_eq!(drawn.children().unwrap().len(), 3);
|
||||
let (a, b) = (solid_art_in(Some(&d), &with(blend(20, 0), ROTATE, front())).unwrap(), solid_art(&with(drawn, ROTATE, front())).unwrap());
|
||||
assert_eq!(a.children().unwrap().len(), b.children().unwrap().len());
|
||||
assert!(close(bounds(&a), bounds(&b), 0.01));
|
||||
// A symbol holding a clipping group builds from the art inside the clip, where the instance
|
||||
// places it.
|
||||
let placed = with(instance(30, "Clipped", Affine::translate((200.0, 0.0))), ROTATE, front());
|
||||
let cut = with(Node::group(NodeId(30), vec![Arc::new(rect(31, 200.0, 0.0, 250.0, 100.0, 0.0))]), ROTATE, front());
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &placed).unwrap()), bounds(&solid_art(&cut).unwrap()), 0.01));
|
||||
}
|
||||
|
||||
/// The id of the art of [`masked_by`]'s opacity masks.
|
||||
const MASK_ART: NodeId = NodeId(900);
|
||||
|
||||
/// `n` with an opacity mask of white art covering `r`.
|
||||
fn masked_by(mut n: Node, r: Rect) -> Node {
|
||||
let art = Node::path(MASK_ART, shapes::rectangle(r), Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0));
|
||||
n.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||||
n
|
||||
}
|
||||
|
||||
/// The faces (paths) of `art`, and how many of them are inside an object [`masked_by`] masks with
|
||||
/// a mask that is not disabled.
|
||||
fn masked_faces(art: &Node) -> (usize, usize) {
|
||||
fn walk(n: &Node, masked: bool, out: &mut (usize, usize)) {
|
||||
let masked = masked || n.extras().mask.as_ref().is_some_and(|m| m.art.id == MASK_ART && !m.disabled);
|
||||
match n.children() {
|
||||
Some(children) => children.iter().for_each(|c| walk(c, masked, out)),
|
||||
None => {
|
||||
out.0 += 1;
|
||||
out.1 += usize::from(masked);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = (0, 0);
|
||||
walk(art, false, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_opacity_mask_on_a_member_or_a_group_in_a_three_d_group_masks_its_solids() {
|
||||
let d = document();
|
||||
let mask = Rect::new(0.0, 0.0, 400.0, 400.0);
|
||||
let mut members = vec![("a path", rect(1, 100.0, 100.0, 150.0, 200.0, 2.0))];
|
||||
members.extend(sources());
|
||||
for (what, member) in members {
|
||||
let other = || Arc::new(rect(91, 600.0, 600.0, 650.0, 650.0, 0.0));
|
||||
let in_group = Node::group(NodeId(80), vec![Arc::new(member.clone())]);
|
||||
let cases = [
|
||||
("masked", Node::group(NodeId(90), vec![Arc::new(masked_by(member.clone(), mask)), other()])),
|
||||
("in a masked group", Node::group(NodeId(90), vec![Arc::new(masked_by(in_group, mask)), other()])),
|
||||
];
|
||||
for (how, g) in cases {
|
||||
for id in TYPES {
|
||||
let g = with(g.clone(), id, json!({}));
|
||||
let (faces, masked) = masked_faces(&art(&d, &g, id).expect("drawn"));
|
||||
assert!(masked > 0, "{id}: the faces of {what} {how} are masked");
|
||||
assert!(masked < faces, "{id}: the other member's faces are not ({masked} of {faces})");
|
||||
}
|
||||
}
|
||||
// A disabled mask masks nothing.
|
||||
let mut off = masked_by(member.clone(), mask);
|
||||
if let Some(m) = off.extras_mut().mask.as_mut() {
|
||||
m.disabled = true;
|
||||
}
|
||||
for id in TYPES {
|
||||
let g = with(Node::group(NodeId(90), vec![Arc::new(off.clone()), other()]), id, json!({}));
|
||||
assert_eq!(masked_faces(&art(&d, &g, id).expect("drawn")).1, 0, "{id}: {what} with a disabled mask");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_objects_and_groups_in_a_three_d_group_keep_their_geometry_effects() {
|
||||
let mut d = document();
|
||||
d.symbols.push(Symbol { dynamic: false, name: "Left".into(), art: Arc::new(rect(0, 0.0, 100.0, 50.0, 200.0, 0.0)) });
|
||||
let moved = |n: Node| with(n, "distort.transform", json!({"moveH": 100.0}));
|
||||
let clip = Rect::new(100.0, 100.0, 150.0, 200.0);
|
||||
let target = rect(1, 100.0, 100.0, 150.0, 200.0, 0.0);
|
||||
let expected = bounds(&solid_art(&with(Node::group(NodeId(90), vec![Arc::new(target.clone())]), ROTATE, front())).unwrap());
|
||||
let left = || rect(2, 0.0, 100.0, 50.0, 200.0, 0.0);
|
||||
let members = [
|
||||
("a symbol instance", moved(instance(30, "Left", Affine::IDENTITY))),
|
||||
("a clipping group", moved(clip_group(40, Rect::new(0.0, 100.0, 50.0, 200.0), left()))),
|
||||
("a plain group", moved(Node::group(NodeId(50), vec![Arc::new(left())]))),
|
||||
("a group in a clipping group", clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(left())])))),
|
||||
];
|
||||
for (what, member) in members {
|
||||
for id in TYPES {
|
||||
let g = with(Node::group(NodeId(90), vec![Arc::new(member.clone())]), id, json!({}));
|
||||
assert_eq!(validate_three_d_in(Some(&d), &g, id, &json!({})), Ok(()), "{id} on a group holding {what}");
|
||||
}
|
||||
let g = with(Node::group(NodeId(90), vec![Arc::new(member)]), ROTATE, front());
|
||||
assert!(close(bounds(&solid_art_in(Some(&d), &g).unwrap()), expected, 0.01), "{what} moved by its Transform effect");
|
||||
}
|
||||
// A blend's effect moves its keys and steps together.
|
||||
let blended = with(Node::group(NodeId(90), vec![Arc::new(moved(blend(20, 3)))]), ROTATE, front());
|
||||
let unmoved = with(Node::group(NodeId(90), vec![Arc::new(blend(20, 3))]), ROTATE, front());
|
||||
let (a, b) = (bounds(&solid_art_in(Some(&d), &blended).unwrap()), bounds(&solid_art_in(Some(&d), &unmoved).unwrap()));
|
||||
assert!(close(a, Rect::new(b.x0 + 100.0, b.y0, b.x1 + 100.0, b.y1), 0.01), "{a:?} is {b:?} moved");
|
||||
// A geometry effect that reads the object's stroke reads the resolved object's, as a group's.
|
||||
let stroked = |kind: NodeKind| {
|
||||
let mut n = Node::new(NodeId(20), kind);
|
||||
n.appearance = Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 10.0).into();
|
||||
with(n, "path.outlineStroke", json!({}))
|
||||
};
|
||||
let NodeKind::Blend { children, spec } = blend(20, 3).kind else { panic!("a blend") };
|
||||
let steps = crate::expand_live(Some(&d), &blend(20, 3)).into_iter().map(Arc::new).collect();
|
||||
let group_of = |n: Node| with(Node::group(NodeId(90), vec![Arc::new(n)]), ROTATE, front());
|
||||
let resolved = bounds(&solid_art_in(Some(&d), &group_of(stroked(NodeKind::Blend { children, spec }))).unwrap());
|
||||
let plain = bounds(&solid_art(&group_of(stroked(NodeKind::Group { children: steps, clip: false }))).unwrap());
|
||||
assert!(close(resolved, plain, 0.01), "{resolved:?} {plain:?}");
|
||||
// A member's opacity and blending mode stay on it when its group's effects reshape it in a clip.
|
||||
let mut multiplied = left();
|
||||
multiplied.opacity = 0.5;
|
||||
multiplied.blend = BlendMode::Multiply;
|
||||
let n = clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(multiplied), Arc::new(left())])));
|
||||
let resolved = resolve(Some(&d), &n, "").unwrap().into_owned();
|
||||
let mut found = vec![];
|
||||
resolved.walk(&mut |c| {
|
||||
if c.blend == BlendMode::Multiply {
|
||||
found.push((c.opacity, c.children().map_or(0, Vec::len)));
|
||||
}
|
||||
});
|
||||
assert_eq!(found, vec![(0.5, 1)], "one Multiply group at 50% holding the one member's piece");
|
||||
// Groups nested past the limit under a group with geometry effects in a clipping group are
|
||||
// refused before the effects reshape them.
|
||||
let mut deep = left();
|
||||
for i in 0..200 {
|
||||
deep = Node::group(NodeId(1000 + i), vec![Arc::new(deep)]);
|
||||
}
|
||||
let n = with(clip_group(60, clip, moved(deep.clone())), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("levels deep")));
|
||||
// A hidden member nested past the limit is left out, as it is drawn, and does not refuse.
|
||||
deep.visible = false;
|
||||
let n = with(clip_group(60, clip, moved(Node::group(NodeId(62), vec![Arc::new(deep), Arc::new(left())]))), ROTATE, front());
|
||||
assert_eq!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})), Ok(()));
|
||||
// Hidden members of a group inside a clipping group build nothing.
|
||||
let mut hidden = rect(63, 100.0, 100.0, 150.0, 200.0, 0.0);
|
||||
hidden.visible = false;
|
||||
let n = with(clip_group(60, clip, Node::group(NodeId(62), vec![Arc::new(hidden)])), ROTATE, front());
|
||||
assert!(validate_three_d_in(Some(&d), &n, ROTATE, &json!({})).is_err_and(|e| e.contains("at least two anchors")));
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Arrowheads. A head of weight `hw` (stroke weight × scale) fits a box `4·hw` long and `4·hw`
|
||||
//! wide. Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
|
||||
//! wide; the line arrow's arms reach twice as far out ([`Arrowhead::half_width`]). Hollow kinds are rings (`hw/2` walls) or, for the open arrow, a chevron with `hw` arms.
|
||||
//! The stroke ends under the head at the head's *inset* from the tip: halfway into solid heads,
|
||||
//! in the back wall of hollow ones (with the cap kept out of the hole) and at the chevron's inner
|
||||
//! corner. With [`ArrowAlign::Tip`] the tip sits on the end point and the stroke is shortened by
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use std::f64::consts::{FRAC_PI_2, PI};
|
||||
use std::f64::consts::{FRAC_1_SQRT_2, FRAC_PI_2, PI};
|
||||
|
||||
use kurbo::{Affine, BezPath, ParamCurve, ParamCurveArclen, PathSeg, Point, Shape, Vec2};
|
||||
use vectorcraft_doc::{ArrowAlign, Arrowhead, StrokeLayer};
|
||||
@@ -252,6 +252,7 @@ const HEX: f64 = 2.0 * 1.732_050_807_568_877_2;
|
||||
/// Flattening tolerance of the curved local heads, in units.
|
||||
const LOCAL_TOL: f64 = 1e-3;
|
||||
|
||||
/// Where the stroke ends under a head drawn in local units ([`local`]), in those units.
|
||||
fn local_stop(kind: Arrowhead) -> Stop {
|
||||
match kind {
|
||||
Arrowhead::Target | Arrowhead::TagOpen | Arrowhead::DiamondOpen => Stop::Wall(4.0),
|
||||
@@ -261,7 +262,7 @@ fn local_stop(kind: Arrowhead) -> Stop {
|
||||
Arrowhead::Fork => Stop::At(2.7),
|
||||
Arrowhead::HalfArrowLeft | Arrowhead::HalfArrowRight | Arrowhead::Feather => Stop::At(1.3),
|
||||
Arrowhead::Swallowtail => Stop::At(1.4),
|
||||
Arrowhead::Chevron => Stop::At(1.0),
|
||||
Arrowhead::Chevron | Arrowhead::LineArrow => Stop::At(1.0),
|
||||
Arrowhead::Slash => Stop::At(1.2),
|
||||
Arrowhead::DoubleSlash => Stop::At(1.05),
|
||||
Arrowhead::DoubleArrow => Stop::At(1.7),
|
||||
@@ -273,8 +274,9 @@ fn local_stop(kind: Arrowhead) -> Stop {
|
||||
}
|
||||
|
||||
/// The outline of a head added after the first ten, in local units: x from the tip (0) back
|
||||
/// along the line (to at most 4), y across it (−2 to 2, positive to the right of the direction
|
||||
/// the head points). Every shape is generated here.
|
||||
/// along the line (to at most 4; the line arrow's square-cut arms 4.71), y across it (−2 to 2,
|
||||
/// the line arrow −4 to 4; positive to the right of the direction the head points). Every shape
|
||||
/// is generated here.
|
||||
fn local(kind: Arrowhead) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
let poly = |p: &mut BezPath, pts: &[(f64, f64)]| polygon(p, &pts.iter().map(|&q| Point::from(q)).collect::<Vec<_>>());
|
||||
@@ -363,6 +365,14 @@ fn local(kind: Arrowhead) -> BezPath {
|
||||
poly(&mut p, &[(0.0, 2.0), (0.6, 2.0), (0.6, 0.25), (4.0, 1.8), (4.0, -1.8), (0.6, -0.25), (0.6, -2.0), (0.0, -2.0)])
|
||||
}
|
||||
Arrowhead::Oval => p.extend(kurbo::Ellipse::new((2.0, 0.0), (2.0, 1.2), 0.0).path_elements(LOCAL_TOL)),
|
||||
Arrowhead::LineArrow => {
|
||||
// Arms one unit thick (the head's weight: the line's at 100 % for lines of 0.25 pt or
|
||||
// more) at right angles to each other, from a sharp tip to their outer corners at
|
||||
// (4, ±4), cut square across: a unit across an arm at 45° is ½√2 along each axis, and
|
||||
// the arms' inner edges meet √2 behind the tip.
|
||||
let t = FRAC_1_SQRT_2;
|
||||
poly(&mut p, &[(0.0, 0.0), (4.0, 4.0), (4.0 + t, 4.0 - t), (2.0 * t, 0.0), (4.0 + t, t - 4.0), (4.0, -4.0)]);
|
||||
}
|
||||
// The first ten are drawn by `shape`.
|
||||
Arrowhead::Triangle
|
||||
| Arrowhead::TriangleOpen
|
||||
|
||||
@@ -14,6 +14,10 @@ use super::{ARCLEN_ACCURACY, kink, push_seg, segments, subpaths, tangent};
|
||||
|
||||
/// More dashes than this (a tiny pattern on a long path) draws the line solid instead.
|
||||
const MAX_DASHES: f64 = 200_000.0;
|
||||
/// Pattern entries walked in all (every subpath and every corner restart starts the pattern over, and
|
||||
/// zero-length entries cost no path length, so a long pattern is walked in full even on a short
|
||||
/// run); past this the line is drawn solid. About 100x what real art needs.
|
||||
const MAX_STEPS: f64 = 20_000_000.0;
|
||||
/// A pattern whose longest entry is shorter than this can't advance along the path (each entry
|
||||
/// ends within the generator's tolerance of where it starts), so it draws the line solid.
|
||||
const MIN_ENTRY: f64 = 1e-6;
|
||||
@@ -80,6 +84,10 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
|
||||
if plans.iter().map(|p| p.periods).sum::<f64>() * pat.len() as f64 > MAX_DASHES {
|
||||
return None;
|
||||
}
|
||||
let restarts = |p: &Plan| p.restart.iter().filter(|r| **r).count() as f64;
|
||||
if plans.iter().map(|p| (p.periods.ceil().max(1.0) + restarts(p)) * pat.len() as f64).sum::<f64>() > MAX_STEPS {
|
||||
return None;
|
||||
}
|
||||
// Where an exact pattern starts: `offset` into it.
|
||||
let mut off = d.offset.rem_euclid(period);
|
||||
let mut ix0 = 0;
|
||||
@@ -89,8 +97,13 @@ pub fn dash_kept(bp: &BezPath, d: &Dash, keep: &dyn Fn(&PathSeg) -> bool) -> Opt
|
||||
}
|
||||
let phase = Phase { ix: ix0, rem: pat[ix0] - off, on: ix0 % 2 == 0 };
|
||||
let mut out = Dashed::default();
|
||||
// The estimates above trust the fitted scales; this counts the pattern entries actually walked,
|
||||
// so entries that fit down to nothing can't keep a subpath looping.
|
||||
let mut budget = MAX_STEPS as usize;
|
||||
for plan in &plans {
|
||||
dash_subpath(&mut out, plan, &pat, phase, keep);
|
||||
if !dash_subpath(&mut out, plan, &pat, phase, keep, &mut budget) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -183,7 +196,8 @@ impl Piece {
|
||||
}
|
||||
}
|
||||
|
||||
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool) {
|
||||
/// Dash one subpath; `false` when `budget` (pattern entries left to walk, shared by all subpaths) runs out.
|
||||
fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep: &dyn Fn(&PathSeg) -> bool, budget: &mut usize) -> bool {
|
||||
let first_dot = out.dots.len();
|
||||
let total: f64 = plan.lens.iter().sum();
|
||||
// Fraction of the subpath's length at `x` along the plan.
|
||||
@@ -244,6 +258,8 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
|
||||
push_dot(out, first_dot, Dot { at: seg.eval(t), dir: tangent(seg, t), t: frac(acc + end) });
|
||||
}
|
||||
}
|
||||
let Some(rest) = budget.checked_sub(1) else { return false };
|
||||
*budget = rest;
|
||||
cuts += 1;
|
||||
s = end;
|
||||
ph.ix = (ph.ix + 1) % pat.len();
|
||||
@@ -273,7 +289,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
|
||||
write(&mut out.path, &d.segs);
|
||||
out.path.close_path();
|
||||
out.spans.push((frac(0.0), frac(0.0) + 1.0));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if starts_on && ends_on && dashes.len() > 1 {
|
||||
// The dash running through the start point is one dash.
|
||||
@@ -291,6 +307,7 @@ fn dash_subpath(out: &mut Dashed, plan: &Plan, pat: &[f64], mut ph: Phase, keep:
|
||||
let t0 = frac(d.start);
|
||||
out.spans.push((t0, if total > 0.0 { t0 + (d.end - d.start) / total } else { t0 }));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Add `dot` unless the subpath's last dot (from index `first`) is already there: a run ending on
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Every arrowhead's outline: drawn, inside its box (4 × 4 weights; the open arrow's square-cut
|
||||
//! arms reach a little further back), without self-intersections, and covering the end of the
|
||||
//! stroke under it.
|
||||
//! Every arrowhead's outline: drawn, inside its reach and half width (4 × 4 weights for most; the
|
||||
//! open arrow's square-cut arms reach a little further back, the line arrow's twice as far out),
|
||||
//! without self-intersections, and covering the end of the stroke under it.
|
||||
|
||||
use kurbo::{BezPath, PathEl, Point, Shape};
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
@@ -53,7 +53,13 @@ fn every_outline_is_drawn_inside_its_box_without_self_intersections() {
|
||||
let h = head(kind);
|
||||
assert!(h.outline.area().abs() > 4.0, "{kind:?} is drawn");
|
||||
let b = h.outline.bounding_box();
|
||||
assert!(b.x0 >= 91.0 - 1e-6 && b.x1 <= 100.0 + 1e-6 && b.y0 >= -4.0 - 1e-6 && b.y1 <= 4.0 + 1e-6, "{kind:?} leaves its box: {b:?}");
|
||||
let (reach, side) = (2.0 * kind.reach(), 2.0 * kind.half_width());
|
||||
assert!(b.x1 <= 100.0 + 1e-6 && b.y0 >= -side - 1e-6 && b.y1 <= side + 1e-6, "{kind:?} leaves its box: {b:?}");
|
||||
if kind.half_width() == 2.0 {
|
||||
assert!(b.x0 >= 91.0 - 1e-6, "{kind:?} is longer than the 4 × 4 box: {b:?}");
|
||||
}
|
||||
let far = contours(&h.outline).iter().flatten().map(|p| p.distance(h.tip)).fold(0.0, f64::max);
|
||||
assert!(far <= reach + 1e-6, "{kind:?} reaches {far} from its tip, past {reach}");
|
||||
let edges: Vec<(Point, Point)> = contours(&h.outline).iter().flat_map(|c| (0..c.len()).map(|i| (c[i], c[(i + 1) % c.len()]))).collect();
|
||||
assert!(edges.len() >= 3, "{kind:?}");
|
||||
for (i, a) in edges.iter().enumerate() {
|
||||
@@ -74,6 +80,31 @@ fn the_stroke_ends_inside_every_head() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_line_arrow_is_a_right_angle_of_two_arms_as_thick_as_the_line() {
|
||||
// Weight 2 at 100 %: a local unit (x back from the tip, y across) is 2 pt.
|
||||
let h = head(Arrowhead::LineArrow);
|
||||
let at = |x: f64, y: f64| Point::new(100.0 - 2.0 * x, 2.0 * y);
|
||||
// A sharp tip on the end point; outer corners 4 units back and out, square-cut ends.
|
||||
let pts: Vec<Point> = contours(&h.outline).concat();
|
||||
for (corner, name) in [(at(0.0, 0.0), "tip"), (at(4.0, 4.0), "right corner"), (at(4.0, -4.0), "left corner")] {
|
||||
assert!(pts.iter().any(|p| p.distance(corner) < 1e-9), "{name} {corner:?} in {pts:?}");
|
||||
}
|
||||
// Open between the arms, solid at the tip.
|
||||
assert_eq!(h.outline.winding(at(2.5, 0.0)), 0);
|
||||
assert_ne!(h.outline.winding(at(0.7, 0.0)), 0);
|
||||
// Halfway along each arm, 45° off the line, it is one unit (the line's weight) across.
|
||||
let s = std::f64::consts::FRAC_1_SQRT_2;
|
||||
for side in [1.0, -1.0] {
|
||||
let (mx, my) = (2.0 + 0.5 * s, side * (2.0 - 0.5 * s));
|
||||
let (nx, ny) = (-side * s, s);
|
||||
for (d, inside) in [(0.45, true), (-0.45, true), (0.55, false), (-0.55, false)] {
|
||||
let w = h.outline.winding(at(mx + nx * d, my + ny * d));
|
||||
assert_eq!(w != 0, inside, "arm {side} at {d} across");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_arrows_barb_on_their_own_side() {
|
||||
// Pointing along +x on a y-down page, left is −y.
|
||||
|
||||
@@ -222,3 +222,17 @@ fn smooth_points_of_curves_stay_smooth_with_any_join() {
|
||||
assert!((a - want).abs() / want < 0.005, "{join:?}: {a} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_pattern_restarted_on_many_short_subpaths_draws_solid() {
|
||||
// Each subpath walks the zero-length entries in full although it is far shorter than a period.
|
||||
let mut pattern = vec![0.0; 2000];
|
||||
pattern[0] = 1.0;
|
||||
let mut b = BezPath::new();
|
||||
for i in 0..20_000 {
|
||||
b.move_to((i as f64 * 2.0, 0.0));
|
||||
b.line_to((i as f64 * 2.0 + 0.01, 0.0));
|
||||
}
|
||||
let d = Dash { pattern, offset: 0.0, align_corners: false };
|
||||
assert!(dash(&b, &d).is_none());
|
||||
}
|
||||
|
||||
@@ -56,6 +56,28 @@ fn check(bp: &BezPath, st: &StrokeLayer, what: &str) {
|
||||
assert!(checked > 0, "{what}: no samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_butt_capped_open_path_is_measured_where_it_paints() {
|
||||
let mut vertical = BezPath::new();
|
||||
vertical.move_to((50.0, 10.0));
|
||||
vertical.line_to((50.0, 90.0));
|
||||
let mut diagonal = BezPath::new();
|
||||
diagonal.move_to((10.0, 10.0));
|
||||
diagonal.line_to((40.0, 50.0));
|
||||
let mut arc = BezPath::new();
|
||||
arc.move_to((10.0, 50.0));
|
||||
arc.curve_to((10.0, 22.4), (32.4, 0.0), (60.0, 0.0));
|
||||
let mut stroke = StrokeLayer::new(Paint::solid(Color::BLACK), 4.0);
|
||||
stroke.cap = LineCap::Butt;
|
||||
for (name, bp) in [("vertical", vertical), ("diagonal", diagonal), ("arc", arc)] {
|
||||
let painted = outline_region(&PathData::from_bezpath(&bp), FillRule::NonZero, &stroke).to_bezpath().bounding_box();
|
||||
let bounds = stroke.bounds_on(&bp, bp.bounding_box());
|
||||
for (got, want) in [(bounds.x0, painted.x0), (bounds.y0, painted.y0), (bounds.x1, painted.x1), (bounds.y1, painted.y1)] {
|
||||
assert!((got - want).abs() < 0.05, "{name}: measured {bounds:?}, paints {painted:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_and_clicks_cover_what_every_stroke_paints() {
|
||||
let profiles = [None, Some(WidthProfile::lens()), Some(WidthProfile { points: vec![(0.0, 2.0, 0.5), (0.6, 0.8, 1.6), (1.0, 1.2, 0.3)] })];
|
||||
|
||||
@@ -1101,8 +1101,9 @@ pub fn with_id(xml: &str, id: &str) -> Option<String> {
|
||||
let f = filter_element(&doc).ok()?;
|
||||
let at = f.attributes().find(|a| a.name() == "id" && a.namespace().is_none())?.range_value();
|
||||
let (start, end) = (at.start.checked_sub(head)?, at.end.checked_sub(head)?);
|
||||
// The value may sit in single quotes: those are escaped too.
|
||||
let id = escaped(id).replace('\'', "'");
|
||||
// The value may sit in single quotes: those are escaped too. A tab, line feed or carriage
|
||||
// return is written as a character reference, which attribute-value normalization keeps.
|
||||
let id = escaped(id).replace('\'', "'").replace('\t', "	").replace('\n', " ").replace('\r', " ");
|
||||
Some(format!("{}{}{}", xml.get(..start)?, id, xml.get(end..)?))
|
||||
}
|
||||
|
||||
|
||||
@@ -105,7 +105,7 @@ fn each_copy_scales_its_stroke_once_more() {
|
||||
let n = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let widths: Vec<f64> = leaves(&art).iter().map(|p| stroke_of(p).0).collect();
|
||||
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
|
||||
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
|
||||
// Without the option the copies keep the original's weight.
|
||||
let plain = stroked(json!({"scaleH": 200, "scaleV": 200, "copies": 2}));
|
||||
assert!(!has_transform_art(&plain));
|
||||
@@ -127,7 +127,7 @@ fn transform_patterns_moves_each_copys_pattern() {
|
||||
assert!(has_transform_options(&n));
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let xs: Vec<f64> = leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
|
||||
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
|
||||
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
|
||||
// Without it, every copy's pattern stays put (the tiles don't follow the copies).
|
||||
let n = patterned(json!({"moveH": 30, "patterns": false, "strokes": false, "objects": true}));
|
||||
assert!(!has_transform_art(&n));
|
||||
@@ -218,7 +218,7 @@ fn each_copy_of_a_strokes_own_transform_scales_its_weight_once_more() {
|
||||
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 2, "strokes": true}));
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let widths: Vec<f64> = stroked_leaves(&art).iter().map(|p| stroke_of(p).0).collect();
|
||||
assert_eq!(widths, vec![2.0, 4.0, 8.0]);
|
||||
assert_eq!(widths, vec![8.0, 4.0, 2.0], "the original on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -268,7 +268,7 @@ fn a_fills_own_transform_moves_each_copys_pattern_with_transform_patterns() {
|
||||
assert!(has_transform_options(&n));
|
||||
let art = reshape(None, &n, None).unwrap();
|
||||
let xs: Vec<f64> = pattern_leaves(&art).iter().map(|p| pattern_xf(p).as_coeffs()[4]).collect();
|
||||
assert_eq!(xs, vec![0.0, 30.0, 60.0]);
|
||||
assert_eq!(xs, vec![60.0, 30.0, 0.0], "the original on top");
|
||||
// Off, the pattern stays put.
|
||||
assert!(!has_transform_art(&fill_with_effect(json!({"moveH": 30, "copies": 2}))));
|
||||
}
|
||||
@@ -294,7 +294,7 @@ fn a_strokes_own_transform_draws_bakes_expands_and_measures_alike() {
|
||||
let n = stroke_with_effect(json!({"scaleH": 200, "scaleV": 200, "copies": 1, "strokes": true}));
|
||||
let drawn = reshape(None, &n, None).unwrap();
|
||||
let widths = |art: &Node| stroked_leaves(art).iter().map(|p| stroke_of(p).0).collect::<Vec<f64>>();
|
||||
assert_eq!(widths(&drawn), vec![2.0, 4.0]);
|
||||
assert_eq!(widths(&drawn), vec![4.0, 2.0], "the original on top");
|
||||
// Expand Appearance.
|
||||
assert_eq!(widths(&expand_outlined(None, &n, None).unwrap()), widths(&drawn));
|
||||
// Export.
|
||||
@@ -394,9 +394,9 @@ fn filled_and_stroked(fill: Paint, params: Value) -> Node {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
|
||||
// Black fill, white stroke, each copy scaled and turned (#5920): each copy is an object of its
|
||||
// own above the one before, so its fill covers the strokes of the copies below.
|
||||
fn copies_with_a_fill_and_a_stroke_are_drawn_one_below_another() {
|
||||
// Black fill, white stroke, each copy scaled and turned (#5920, #6316): each copy is an object
|
||||
// of its own below the one before, so the fills above cover the strokes of the copies below.
|
||||
let params = json!({"scaleH": 97, "scaleV": 97, "rotate": 10, "copies": 25});
|
||||
let n = filled_and_stroked(Paint::solid(Color::BLACK), params.clone());
|
||||
assert!(has_transform_art(&n));
|
||||
@@ -407,10 +407,10 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
|
||||
assert!(matches!(p.kind, NodeKind::Path { .. }), "one shape each, not one path of every copy");
|
||||
assert!(!p.appearance.fill_paint().is_none() && p.appearance.stroke().is_some());
|
||||
}
|
||||
// The original is at the bottom, the smallest copy on top.
|
||||
assert_eq!(paths[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let top = paths[25].geometric_bounds().unwrap();
|
||||
assert!(top.width() < 100.0 && top.height() < 100.0, "{top:?}");
|
||||
// The smallest copy is at the bottom, the original on top.
|
||||
assert_eq!(paths[25].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
let bottom = paths[0].geometric_bounds().unwrap();
|
||||
assert!(bottom.width() < 100.0 && bottom.height() < 100.0, "{bottom:?}");
|
||||
// Drawn as Expand Appearance makes it.
|
||||
assert_eq!(leaves(&expand_outlined(None, &n, None).unwrap()).len(), 26);
|
||||
|
||||
@@ -427,3 +427,252 @@ fn copies_with_a_fill_and_a_stroke_are_drawn_one_above_another() {
|
||||
// No copies: nothing to stack.
|
||||
assert!(!has_transform_art(&filled_and_stroked(Paint::solid(Color::BLACK), json!({"rotate": 10}))));
|
||||
}
|
||||
|
||||
/// A group of two squares side by side, each with a black fill and a white stroke, with the
|
||||
/// Transform effect `params`.
|
||||
fn two_squares(params: Value) -> Node {
|
||||
let square = |id, x| {
|
||||
let a = Appearance::basic(Paint::solid(Color::BLACK), Paint::solid(Color::WHITE), 4.0);
|
||||
std::sync::Arc::new(Node::path(NodeId(id), shapes::rectangle(Rect::new(x, 0.0, x + 100.0, 100.0)), a))
|
||||
};
|
||||
let mut g = Node::group(NodeId(20), vec![square(21, 0.0), square(22, 120.0)]);
|
||||
g.appearance.effects.push(transform(params));
|
||||
g
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_copies_are_whole_groups_below_the_original() {
|
||||
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 95, "scaleV": 95, "copies": 10}))).unwrap();
|
||||
let copies = art.children().unwrap();
|
||||
assert_eq!(copies.len(), 11, "the original and 10 copies");
|
||||
for c in copies {
|
||||
assert_eq!(c.children().map(Vec::len), Some(2), "each copy holds both squares");
|
||||
}
|
||||
// The smallest copy is at the bottom, the original on top with its members in their order.
|
||||
let width = |i: usize| copies[i].geometric_bounds().unwrap().width();
|
||||
assert!((0..10).all(|i| width(i) < width(i + 1)), "{:?}", (0..11).map(width).collect::<Vec<_>>());
|
||||
assert_eq!(copies[10].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 220.0, 100.0));
|
||||
let members = copies[10].children().unwrap();
|
||||
assert_eq!(members[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 100.0, 100.0));
|
||||
assert_eq!(members[1].geometric_bounds().unwrap(), Rect::new(120.0, 0.0, 220.0, 100.0));
|
||||
|
||||
// Without copies the members stay members of the art.
|
||||
let art = crate::evaluate_container(None, &two_squares(json!({"scaleH": 50, "scaleV": 50}))).unwrap();
|
||||
assert_eq!(art.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [NodeId(21), NodeId(22)]);
|
||||
|
||||
// A group's own fill paints the shape of all its copies, so each member keeps its place.
|
||||
let mut own_fill = two_squares(json!({}));
|
||||
own_fill.appearance = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0).into();
|
||||
let copies = transform(json!({"moveH": 300, "copies": 2}));
|
||||
crate::reshape::reshape_leaves_within(&mut own_fill, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
assert_eq!(own_fill.children().map(Vec::len), Some(2), "a stack of copies per member");
|
||||
|
||||
// A guide in the group stays one guide.
|
||||
let mut guided = two_squares(json!({}));
|
||||
let mut guide = Node::path(NodeId(23), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
|
||||
if let NodeKind::Path { guide: on, .. } = &mut guide.kind {
|
||||
*on = true;
|
||||
}
|
||||
if let Some(members) = guided.children_mut() {
|
||||
members.push(std::sync::Arc::new(guide));
|
||||
}
|
||||
guided.appearance.effects.clear();
|
||||
crate::reshape::reshape_leaves_within(&mut guided, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
assert_eq!(guided.children().map(Vec::len), Some(3), "the original and 2 copies");
|
||||
let mut guides = 0;
|
||||
guided.walk(&mut |n| guides += usize::from(matches!(n.kind, NodeKind::Path { guide: true, .. })));
|
||||
assert_eq!(guides, 1);
|
||||
|
||||
// A clipping group's first member stays its clip.
|
||||
let mut clipping = two_squares(json!({}));
|
||||
if let NodeKind::Group { clip, .. } = &mut clipping.kind {
|
||||
*clip = true;
|
||||
}
|
||||
clipping.appearance.effects.clear();
|
||||
crate::reshape::reshape_leaves_within(&mut clipping, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
assert_eq!(clipping.children().map(Vec::len), Some(2), "the clip and the clipped member");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_copies_stop_at_the_node_limit() {
|
||||
// Empty groups cost no anchors, so only the count of nodes stops their copies.
|
||||
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![])));
|
||||
let mut g = two_squares(json!({}));
|
||||
if let Some(members) = g.children_mut() {
|
||||
members.extend(empties);
|
||||
}
|
||||
g.appearance.effects.clear();
|
||||
let copies = transform(json!({"moveH": 1, "copies": 1000}));
|
||||
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
let mut nodes = 0usize;
|
||||
g.walk(&mut |_| nodes += 1);
|
||||
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
|
||||
assert!(g.children().unwrap().len() < 1001, "the limit stopped the copies");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_copies_count_the_art_of_opacity_masks() {
|
||||
let empties = (0..5_000).map(|i| std::sync::Arc::new(Node::group(NodeId(100 + i), vec![]))).collect();
|
||||
let mut g = two_squares(json!({}));
|
||||
if let Some(members) = g.children_mut() {
|
||||
let mask = vectorcraft_doc::OpacityMask::new(Node::group(NodeId(99), empties), true);
|
||||
std::sync::Arc::make_mut(&mut members[0]).extras_mut().mask = Some(Box::new(mask));
|
||||
}
|
||||
g.appearance.effects.clear();
|
||||
let copies = transform(json!({"moveH": 1, "copies": 1000}));
|
||||
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
let nodes = g.count_with_masks();
|
||||
assert!(nodes <= crate::distort::MAX_COPY_OBJECTS + 1, "{nodes}");
|
||||
}
|
||||
|
||||
/// The anchors `n` draws with every effect inside it evaluated, group by group and member by
|
||||
/// member as drawing and baking evaluate them. The count stops once it passes `limit`.
|
||||
fn drawn_anchors(n: &Node, limit: usize) -> usize {
|
||||
fn count(n: &Node, limit: usize, total: &mut usize) {
|
||||
if *total > limit {
|
||||
return;
|
||||
}
|
||||
if n.children().is_some() && !matches!(n.kind, NodeKind::Compound { .. }) {
|
||||
let art = crate::evaluate_container(None, n).unwrap_or_else(|| n.clone());
|
||||
art.children().into_iter().flatten().for_each(|c| count(c, limit, total));
|
||||
return;
|
||||
}
|
||||
if let Some(art) = reshape(None, n, None) {
|
||||
count(&art, limit, total);
|
||||
return;
|
||||
}
|
||||
let Some((path, _)) = crate::reshape::leaf_geometry(n) else { return };
|
||||
let Some(bounds) = path.bounds() else { return };
|
||||
let own = crate::apply_geometry(&n.appearance.effects, &path, bounds);
|
||||
let items: Vec<usize> = n
|
||||
.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| crate::has_geometry(i.effects()))
|
||||
.filter_map(|i| Some(crate::apply_geometry(i.effects(), &own, own.bounds()?).anchor_count()))
|
||||
.collect();
|
||||
*total += if items.is_empty() { own.anchor_count() } else { items.iter().sum() };
|
||||
}
|
||||
let mut total = 0;
|
||||
count(n, limit, &mut total);
|
||||
total
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_copies_keep_what_the_effects_inside_them_make_to_the_budget() {
|
||||
let most = crate::geometry::MAX_EFFECT_ANCHORS;
|
||||
let copies = |key: &str| transform(json!({key: 1, "copies": 1000}));
|
||||
|
||||
// Copies of a group whose member group makes copies of its own.
|
||||
let inner = two_squares(json!({"moveH": 1, "copies": 1000}));
|
||||
let mut outer = Node::group(NodeId(30), vec![std::sync::Arc::new(inner)]);
|
||||
outer.appearance.effects.push(copies("moveV"));
|
||||
let drawn = drawn_anchors(&outer, 2 * most);
|
||||
assert!(drawn <= most, "a group inside: {drawn}");
|
||||
|
||||
// Copies of a group whose member path makes copies of its own.
|
||||
let mut own = two_squares(json!({"moveH": 1, "copies": 1000}));
|
||||
if let Some(members) = own.children_mut() {
|
||||
std::sync::Arc::make_mut(&mut members[0]).appearance.effects.push(copies("moveV"));
|
||||
}
|
||||
let drawn = drawn_anchors(&own, 2 * most);
|
||||
assert!(drawn <= most, "a path's own effect: {drawn}");
|
||||
|
||||
// Copies of a group whose member's fill makes copies of its own.
|
||||
let mut item = two_squares(json!({"moveH": 1, "copies": 1000}));
|
||||
if let Some(members) = item.children_mut() {
|
||||
std::sync::Arc::make_mut(&mut members[0]).appearance.items[0].effects_mut().push(copies("moveV"));
|
||||
}
|
||||
let drawn = drawn_anchors(&item, 2 * most);
|
||||
assert!(drawn <= most, "a fill's own effect: {drawn}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_with_scaled_effects_keep_what_each_one_makes_to_the_budget() {
|
||||
// Each copy's Roughen is twice as large as the last's, and the rough edges take more lines.
|
||||
let roughen = Effect { id: "distort.roughen".into(), params: json!({"size": 40, "relative": false, "detail": 20}), visible: true };
|
||||
let lines = |budget: crate::geometry::Budget| {
|
||||
let mut g = two_squares(json!({}));
|
||||
if let Some(members) = g.children_mut() {
|
||||
let m = std::sync::Arc::make_mut(&mut members[0]);
|
||||
m.appearance.items.retain(AppearanceItem::is_fill);
|
||||
m.appearance.effects.push(roughen.clone());
|
||||
members.truncate(1);
|
||||
}
|
||||
g.appearance.effects.clear();
|
||||
let copies = transform(json!({"scaleH": 200, "scaleV": 200, "copies": 5, "strokes": true}));
|
||||
crate::reshape::reshape_leaves_within(&mut g, &[&copies], budget, None);
|
||||
let (mut count, mut lines) = (0, 0);
|
||||
for leaf in leaves(&g) {
|
||||
count += 1;
|
||||
let Some((path, _)) = crate::reshape::leaf_geometry(leaf) else { continue };
|
||||
let drawn = crate::apply_geometry(&leaf.appearance.effects, &path, path.bounds().unwrap());
|
||||
lines += crate::geometry::Budget::of(&drawn).lines;
|
||||
}
|
||||
(count, lines)
|
||||
};
|
||||
let (all, most) = lines(crate::geometry::Budget::EFFECTS);
|
||||
assert_eq!(all, 6, "the original and 5 copies");
|
||||
let cap = most / 2;
|
||||
let budget = crate::geometry::Budget { anchors: crate::geometry::MAX_EFFECT_ANCHORS, lines: cap };
|
||||
let (count, drawn) = lines(budget);
|
||||
assert!(count < all, "the budget stopped the copies");
|
||||
assert!(drawn <= cap, "{drawn} > {cap}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_groups_copies_are_whole_only_when_nothing_inside_makes_more_art() {
|
||||
let copies = transform(json!({"moveH": 300, "copies": 2}));
|
||||
let effect = |id: &str| Effect { id: id.into(), params: json!({}), visible: true };
|
||||
let roughen = effect("distort.roughen");
|
||||
// Two squares under a group with `change` made to the first member.
|
||||
let whole = |change: &dyn Fn(&mut Node)| {
|
||||
let mut g = two_squares(json!({}));
|
||||
if let Some(members) = g.children_mut() {
|
||||
change(std::sync::Arc::make_mut(&mut members[0]));
|
||||
}
|
||||
g.appearance.effects.clear();
|
||||
crate::reshape::reshape_leaves_within(&mut g, &[&copies], crate::geometry::Budget::EFFECTS, None);
|
||||
g.children().unwrap().len() == 3
|
||||
};
|
||||
// A group of one square with effect `e`, in place of the member.
|
||||
let in_group = |m: &mut Node, e: &Effect| {
|
||||
let square = Node::path(NodeId(40), shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default());
|
||||
let mut g = Node::group(NodeId(41), vec![std::sync::Arc::new(square), std::sync::Arc::new(m.clone())]);
|
||||
g.appearance.effects.push(e.clone());
|
||||
*m = g;
|
||||
};
|
||||
assert!(whole(&|_| {}));
|
||||
// Effects that only change pixels, and effects turned off, are copied with the art.
|
||||
assert!(whole(&|m| m.appearance.effects.push(effect("stylize.dropShadow"))));
|
||||
assert!(whole(&|m| m.appearance.effects.push(Effect { visible: false, ..roughen.clone() })));
|
||||
// Effects that draw nothing are copied with the art as well.
|
||||
assert!(
|
||||
whole(&|m| {
|
||||
m.visible = false;
|
||||
m.appearance.effects.push(roughen.clone());
|
||||
}),
|
||||
"a hidden member's effect"
|
||||
);
|
||||
assert!(
|
||||
whole(&|m| {
|
||||
*m.appearance.items[0].paint_mut() = Paint::None;
|
||||
m.appearance.items[0].effects_mut().push(roughen.clone());
|
||||
}),
|
||||
"the effect of a fill that paints nothing"
|
||||
);
|
||||
assert!(whole(&|m| m.appearance.effects.push(effect("no.such.effect"))), "an unknown effect");
|
||||
// Effects that make more art than a copy holds keep each member's copies in its place.
|
||||
assert!(!whole(&|m| m.appearance.effects.push(roughen.clone())), "a member's own effect");
|
||||
assert!(!whole(&|m| m.appearance.items[0].effects_mut().push(roughen.clone())), "a fill's own effect");
|
||||
assert!(!whole(&|m| in_group(m, &roughen)), "a group's own effect");
|
||||
assert!(!whole(&|m| in_group(m, &effect("pathfinder.add"))), "a Pathfinder effect");
|
||||
assert!(
|
||||
!whole(&|m| {
|
||||
let mut art = Node::group(NodeId(43), vec![]);
|
||||
in_group(&mut art, &roughen);
|
||||
m.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(art, true)));
|
||||
}),
|
||||
"an effect in an opacity mask"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,34 +218,66 @@ fn visible(sources: &[TrapSource]) -> Vec<PathData> {
|
||||
/// The pairs `(i, j)`, `i < j`, in ascending order, of `rects` (`None`: left out) that come within
|
||||
/// `reach_x` of each other across x and `reach_y` across y. A sweep over the rects sorted by their
|
||||
/// left edge, so shapes spread over the page cost about one check each rather than one per pair.
|
||||
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Vec<(usize, usize)> {
|
||||
/// `None` when there are more than [`MAX_NEAR_PAIRS`] (many shapes all within reach of each other
|
||||
/// make the pair list grow with the square of their count) or the sweep examines more than
|
||||
/// `MAX_NEAR_SCAN` pairs (many shapes sharing an x range, far apart in y).
|
||||
fn near_pairs(rects: &[Option<Rect>], reach_x: f64, reach_y: f64) -> Option<Vec<(usize, usize)>> {
|
||||
near_pairs_within(rects, reach_x, reach_y, MAX_NEAR_SCAN)
|
||||
}
|
||||
|
||||
fn near_pairs_within(rects: &[Option<Rect>], reach_x: f64, reach_y: f64, max_scan: usize) -> Option<Vec<(usize, usize)>> {
|
||||
let mut order: Vec<(usize, Rect)> = rects.iter().enumerate().filter_map(|(i, r)| r.map(|r| (i, r))).collect();
|
||||
order.sort_unstable_by(|a, b| a.1.x0.total_cmp(&b.1.x0).then(a.0.cmp(&b.0)));
|
||||
let mut pairs = vec![];
|
||||
let mut scanned = 0usize;
|
||||
for (k, &(i, a)) in order.iter().enumerate() {
|
||||
// Later rects start at or right of `a`, so only how far right they start can rule them out.
|
||||
for &(j, b) in order[k + 1..].iter().take_while(|(_, b)| b.x0 <= a.x1 + reach_x) {
|
||||
// Rects sharing an x range but far apart in y are examined without being stored.
|
||||
scanned += 1;
|
||||
if scanned > max_scan {
|
||||
return None;
|
||||
}
|
||||
if b.y0 <= a.y1 + reach_y && a.y0 <= b.y1 + reach_y {
|
||||
if pairs.len() >= MAX_NEAR_PAIRS {
|
||||
return None;
|
||||
}
|
||||
pairs.push((i.min(j), i.max(j)));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs.sort_unstable();
|
||||
pairs
|
||||
Some(pairs)
|
||||
}
|
||||
|
||||
/// Most pairs the sweep in `near_pairs` examines, stored or not (a plain bounds check each).
|
||||
const MAX_NEAR_SCAN: usize = 1 << 27;
|
||||
|
||||
/// Most candidate pairs one trap evaluation keeps; past it no traps are made (the art draws as is).
|
||||
/// Each pair costs a boolean trap band, so real art stays far below this.
|
||||
pub const MAX_NEAR_PAIRS: usize = 1 << 20;
|
||||
|
||||
/// The traps between `sources` (back to front) with options `o`: for each two sources of
|
||||
/// different solid colours whose visible parts meet, the strip of the darker one's visible part
|
||||
/// next to the lighter one's (the other way round with [`TrapOptions::reverse`]), painted per
|
||||
/// [`trap_paint`]. Empty when no visible edges of different colours meet.
|
||||
/// [`trap_paint`]. Empty when no visible edges of different colours meet, or when there are more
|
||||
/// than [`MAX_NEAR_PAIRS`] candidate pairs or the pair scan is too long (see [`try_traps`] to tell
|
||||
/// those apart).
|
||||
pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
|
||||
try_traps(sources, o).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`traps`], but `None` when there are more than [`MAX_NEAR_PAIRS`] candidate pairs or the scan for
|
||||
/// them is too long (`MAX_NEAR_SCAN` pairs examined), as opposed to
|
||||
/// `Some` of an empty list when no edges meet.
|
||||
pub fn try_traps(sources: &[TrapSource], o: &TrapOptions) -> Option<Vec<TrapPiece>> {
|
||||
let colors: Vec<Option<Color>> = sources.iter().map(|s| trap_color(&s.fill)).collect();
|
||||
if colors.iter().flatten().count() < 2 {
|
||||
return vec![];
|
||||
return Some(vec![]);
|
||||
}
|
||||
let (w, r) = (o.thickness, o.height_width);
|
||||
if !(w.is_finite() && w > 0.0 && r.is_finite() && r > 0.0) {
|
||||
return vec![]; // `trap_band` cuts nothing with these.
|
||||
return Some(vec![]); // `trap_band` cuts nothing with these.
|
||||
}
|
||||
let vis = visible(sources);
|
||||
// Only pairs whose visible parts come within the trap's reach can trap (`trap_band` rejects
|
||||
@@ -257,7 +289,8 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
|
||||
let extent = rects.iter().flatten().flat_map(|b| [b.x0, b.y0, b.x1, b.y1]).fold(0.0_f64, |m, v| m.max(v.abs()));
|
||||
let hair = 1e-9 * (1.0 + extent + w * r.max(1.0));
|
||||
let mut out = vec![];
|
||||
for (i, j) in near_pairs(&rects, w + hair, w * r + hair) {
|
||||
let pairs = near_pairs(&rects, w + hair, w * r + hair)?;
|
||||
for (i, j) in pairs {
|
||||
let (Some(ci), Some(cj)) = (colors[i], colors[j]) else { continue };
|
||||
if vis[i].is_empty() || vis[j].is_empty() || same_color(&sources[i].fill, &sources[j].fill, ci, cj) {
|
||||
continue;
|
||||
@@ -276,7 +309,7 @@ pub fn traps(sources: &[TrapSource], o: &TrapOptions) -> Vec<TrapPiece> {
|
||||
let Some(color) = colors[from] else { continue };
|
||||
out.push(TrapPiece { path: band, paint: trap_paint(&sources[from].fill, color, o), from, into });
|
||||
}
|
||||
out
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A trap's appearance: one overprinting fill of `paint`, no stroke.
|
||||
@@ -379,10 +412,19 @@ mod tests {
|
||||
assert!(near(po::area(&t[0].path, FillRule::NonZero), 22.0 * 22.0 - 400.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pair_scan_is_bounded_even_when_no_pair_is_near() {
|
||||
// Eleven rects share an x range but lie far apart in y: 55 pairs are examined, none stored.
|
||||
let rects: Vec<Option<Rect>> = (0..11).map(|k| Some(Rect::new(0.0, k as f64 * 1000.0, 10.0, k as f64 * 1000.0 + 10.0))).collect();
|
||||
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 100), Some(vec![]));
|
||||
assert_eq!(near_pairs_within(&rects, 1.0, 1.0, 54), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_trap_without_adjoining_edges_of_different_colours() {
|
||||
let apart = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), yellow()), src(Rect::new(30.0, 0.0, 40.0, 10.0), blue())];
|
||||
assert!(traps(&apart, &TrapOptions::default()).is_empty());
|
||||
assert_eq!(try_traps(&apart, &TrapOptions::default()).map(|t| t.len()), Some(0), "no edges meet: empty, not over the cap");
|
||||
let same = vec![src(Rect::new(0.0, 0.0, 10.0, 10.0), blue()), src(Rect::new(10.0, 0.0, 20.0, 10.0), blue())];
|
||||
assert!(traps(&same, &TrapOptions::default()).is_empty());
|
||||
let mut unfilled = abutting();
|
||||
@@ -431,10 +473,13 @@ mod tests {
|
||||
fn only_shapes_within_reach_are_paired() {
|
||||
let s = many_apart_and_one_abutting();
|
||||
let rects: Vec<Option<Rect>> = s.iter().map(|t| t.path.bounds()).collect();
|
||||
assert_eq!(near_pairs(&rects, 1.0, 1.0), vec![(0, 2000)], "the squares 10 pt apart are never paired");
|
||||
assert_eq!(near_pairs(&rects, 1.0, 1.0), Some(vec![(0, 2000)]), "the squares 10 pt apart are never paired");
|
||||
// Reaching 10 pt, neighbours in a row pair, in ascending order; left-out rects never do.
|
||||
let row: Vec<Option<Rect>> = [3, 1, 0, 2].iter().map(|&k| s[k].path.bounds()).chain([None]).collect();
|
||||
assert_eq!(near_pairs(&row, 10.0, 10.0), vec![(0, 3), (1, 2), (1, 3)]);
|
||||
assert_eq!(near_pairs(&row, 10.0, 10.0), Some(vec![(0, 3), (1, 2), (1, 3)]));
|
||||
// Past the allowance the pair list stops growing instead of being built.
|
||||
let packed = vec![Some(Rect::new(0.0, 0.0, 1.0, 1.0)); 1500];
|
||||
assert_eq!(near_pairs(&packed, 1.0, 1.0), None);
|
||||
let t = traps(&s, &TrapOptions { thickness: 1.0, ..Default::default() });
|
||||
assert_eq!(t.len(), 1, "only the abutting pair traps");
|
||||
assert_eq!((t[0].from, t[0].into), (2000, 0));
|
||||
|
||||
@@ -40,6 +40,9 @@ vectorcraft-metafile = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-affinity = { workspace = true }
|
||||
vectorcraft-scriptcheck = { workspace = true }
|
||||
vectorcraft-js = { workspace = true }
|
||||
# The commands' parameter schemas (schemas.rs).
|
||||
vectorcraft-apischema = { workspace = true }
|
||||
image = { workspace = true }
|
||||
jxl-oxide = { workspace = true }
|
||||
jxl-color = { workspace = true }
|
||||
@@ -86,3 +89,5 @@ zune-jpegxl = { workspace = true }
|
||||
zune-core = { workspace = true }
|
||||
# The JavaScript parser's lexer, compared with the script checker's on the same scripts.
|
||||
vectorcraft-js = { workspace = true }
|
||||
# A bundled font's own glyph boxes, for the glyph bounds tests.
|
||||
skrifa = { workspace = true }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod actionset;
|
||||
mod addons;
|
||||
mod apiref;
|
||||
pub(crate) mod appearance;
|
||||
mod assets;
|
||||
mod attributes;
|
||||
@@ -69,6 +70,7 @@ pub mod pdfcmds;
|
||||
pub mod perspgrid;
|
||||
pub mod place;
|
||||
pub mod plugin;
|
||||
pub(crate) mod plugin_call;
|
||||
pub mod prefscmds;
|
||||
pub mod print;
|
||||
pub(crate) mod printadvanced;
|
||||
@@ -130,6 +132,9 @@ pub struct CommandSpec {
|
||||
pub run: Run,
|
||||
/// Record in the journal (false for queries and selection-only helpers).
|
||||
pub journal: bool,
|
||||
/// The parameters' schema: [`Session::execute`] checks the params against it before the
|
||||
/// command runs, and `params` is the text it writes ([`crate::schemas`]).
|
||||
pub schema: Option<&'static vectorcraft_apischema::ParamSchema>,
|
||||
}
|
||||
|
||||
/// Serializable command metadata.
|
||||
@@ -143,11 +148,29 @@ pub struct CommandInfo {
|
||||
pub enabled: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disabled_reason: Option<String>,
|
||||
/// The parameters' schema as a JSON Schema 2020-12 object schema, for a command that has one.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub schema: Option<Value>,
|
||||
/// The same schema in the form an MCP tool's `inputSchema` takes ([`input_schema`]).
|
||||
#[serde(rename = "inputSchema", skip_serializing_if = "Option::is_none")]
|
||||
pub input_schema: Option<Value>,
|
||||
}
|
||||
|
||||
/// `schema` as an MCP tool input schema: the root `type` is `"object"`, since the arguments are
|
||||
/// always an object, and every other keyword is kept. The root of [`CommandInfo::schema`] also
|
||||
/// takes null when no parameter is required.
|
||||
pub(crate) fn input_schema(schema: &Value) -> Value {
|
||||
let mut o = schema.clone();
|
||||
if let Some(map) = o.as_object_mut() {
|
||||
map.insert("type".into(), "object".into());
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
let schema = self.schema.map(vectorcraft_apischema::ParamSchema::to_json);
|
||||
CommandInfo {
|
||||
id: self.id,
|
||||
label: self.label,
|
||||
@@ -156,6 +179,8 @@ impl CommandSpec {
|
||||
params: self.params,
|
||||
enabled: e.is_ok(),
|
||||
disabled_reason: e.err(),
|
||||
input_schema: schema.as_ref().map(input_schema),
|
||||
schema,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -238,10 +263,17 @@ pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
|
||||
|
||||
macro_rules! cmd {
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, schema: None }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
|
||||
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, schema: None }
|
||||
};
|
||||
// With a parameter schema: `schema: &SCHEMA` after the run function.
|
||||
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
|
||||
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!($id, $label, [$($m),*], $sc, $params, $en, $run) }
|
||||
};
|
||||
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr, schema: $schema:expr) => {
|
||||
$crate::cmd::CommandSpec { schema: Some($schema), ..$crate::cmd::cmd!(query $id, $label, [$($m),*], $sc, $params, $en, $run) }
|
||||
};
|
||||
}
|
||||
pub(crate) use cmd;
|
||||
@@ -354,6 +386,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(actionset::specs());
|
||||
v.extend(importmatches::specs());
|
||||
v.extend(addons::specs());
|
||||
// Last, so the reference it writes ends with its own section.
|
||||
v.extend(apiref::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
@@ -370,6 +404,14 @@ pub(crate) fn run_spec(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result
|
||||
if plugin::is_command_id(spec.id) { plugin::run_command(s, spec.id, p) } else { (spec.run)(s, p) }
|
||||
}
|
||||
|
||||
/// [`run_spec`] for a command run outside [`Session::execute`]: with params checked against the
|
||||
/// command's schema, and the defaults of omitted values filled in, as `execute` does
|
||||
/// ([`crate::schemas::check`]).
|
||||
pub(crate) fn run_checked(s: &mut Session, spec: &CommandSpec, p: &Value) -> Result<Value> {
|
||||
let checked = crate::schemas::check(spec, p, s.replaying())?;
|
||||
run_spec(s, spec, checked.as_ref().unwrap_or(p))
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -275,6 +275,7 @@ fn same_art(a: &NodeKind, b: &NodeKind) -> bool {
|
||||
moved.xf = a.xf;
|
||||
moved.cached_bounds = a.cached_bounds;
|
||||
moved.cached_baselines.clone_from(&a.cached_baselines);
|
||||
moved.cached_ink = a.cached_ink;
|
||||
**a == moved
|
||||
}
|
||||
}
|
||||
|
||||
@@ -544,7 +544,11 @@ fn merge(chain: &[&Piece]) -> std::result::Result<TextObject, usize> {
|
||||
for (k, w) in off.windows(2).enumerate() {
|
||||
let (delta, Some(&(s, e))) = (w[1] - w[0], before.get(k)) else { continue };
|
||||
if delta.abs() > 1e-9 {
|
||||
edit::style_range(&mut m.runs, s, e, |st| st.tracking += delta / st.size.max(1e-6) * 1000.0);
|
||||
// Thousandths of the em as the Horizontal Scale draws it (`spacing_scale`).
|
||||
let vertical = m.vertical;
|
||||
edit::style_range(&mut m.runs, s, e, |st| {
|
||||
st.tracking += delta / (st.size * vectorcraft_text::spacing_scale(st, vertical)).max(1e-6) * 1000.0;
|
||||
});
|
||||
if !TRACKING.contains(&edit::style_at(&m.runs, s).tracking) {
|
||||
return Err(k + 1);
|
||||
}
|
||||
|
||||
@@ -176,6 +176,24 @@ fn combine_joins_only_pieces_on_one_line_turned_alike() {
|
||||
assert!(other(s.execute("addon.cleanup.combineText", &json!({}))).contains("combine"));
|
||||
}
|
||||
|
||||
/// Pieces in horizontally scaled type: the tracking that puts each back where it was is measured in
|
||||
/// the em as the Horizontal Scale draws it (`vectorcraft_text::spacing_scale`), as layout spaces it.
|
||||
#[test]
|
||||
fn combine_puts_horizontally_scaled_pieces_back_where_they_were() {
|
||||
let mut s = session();
|
||||
let a = text(&mut s, json!({"x": 50, "y": 100, "text": "Left", "size": 20}));
|
||||
let b = text(&mut s, json!({"x": 160, "y": 100, "text": "Right", "size": 20}));
|
||||
for id in [a, b] {
|
||||
s.execute("text.setRangeStyle", &json!({"id": id.0, "hScale": 200})).unwrap();
|
||||
}
|
||||
let was = glyphs(&s, &[a, b]);
|
||||
select(&mut s, &[a, b]);
|
||||
let r = s.execute("addon.cleanup.combineText", &json!({})).unwrap();
|
||||
assert_eq!(ids(&r), [a]);
|
||||
assert_eq!(obj(&s, a).plain_text(), "Left Right");
|
||||
assert_same_places(&was, &glyphs(&s, &[a]));
|
||||
}
|
||||
|
||||
/// Join Point Text makes one object of stacked one-line pieces: lines top to bottom as paragraphs,
|
||||
/// pieces on a line left to right, each line's baseline where it was (its leading is the distance
|
||||
/// from the line above), in one undo step. A piece turned unlike the top-left one stays.
|
||||
|
||||
@@ -12,8 +12,8 @@ use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use super::super::edit::{copy_above, selected_roots};
|
||||
use super::super::pathops::{len_param, num_param};
|
||||
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, str_param};
|
||||
use super::super::pathops::{length_given, number_given};
|
||||
use super::super::{CommandSpec, EngineError, NodeId, Result, Session, bad, cmd, has_selection, text_param};
|
||||
|
||||
const C: &str = "addon.contour.set";
|
||||
/// The live effect that does the offsetting (the one Effect › Path › Offset Path applies).
|
||||
@@ -44,12 +44,12 @@ fn joins_for(corners: Option<&str>) -> Result<&'static str> {
|
||||
}
|
||||
|
||||
fn contour(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let offset = len_param(p, "offset").ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
|
||||
let offset = length_given(p, "offset", C)?.ok_or_else(|| bad(C, "missing `offset` (a distance in points)"))?;
|
||||
if offset.abs() > MAX_OFFSET {
|
||||
return Err(bad(C, "offset out of range"));
|
||||
}
|
||||
let joins = joins_for(str_param(p, "corners"))?;
|
||||
let miter = num_param(p, "miterLimit").unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let joins = joins_for(text_param(p, "corners", C)?)?;
|
||||
let miter = number_given(p, "miterLimit", C)?.unwrap_or(4.0).clamp(1.0, 500.0);
|
||||
let effect = effects::new_effect(EFFECT, &json!({"offset": offset, "joins": joins, "miterLimit": miter}))
|
||||
.ok_or_else(|| EngineError::Other("Contour: the Offset Path effect is not available".into()))?;
|
||||
let roots = selected_roots(s)?;
|
||||
|
||||
@@ -158,6 +158,39 @@ fn contour_refuses_what_it_cannot_offset() {
|
||||
assert!(err.contains("select paths or shapes"), "{err}");
|
||||
}
|
||||
|
||||
/// A param of the wrong type is an error naming the param, and leaves the document, the selection
|
||||
/// and the undo history as they were. Null keeps a param's default, and `offset` still takes a
|
||||
/// string with a unit.
|
||||
#[test]
|
||||
fn contour_refuses_a_param_of_the_wrong_type() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 100.0, 100.0, 50.0, 40.0);
|
||||
let state = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
(st.doc.clone(), st.selection.objects.clone(), st.history.undo.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
for (p, want) in [
|
||||
(json!({"offset": true}), "`offset` must be a number, not true"),
|
||||
(json!({"offset": "abc"}), "`offset` must be a number, not \"abc\""),
|
||||
(json!({"offset": [5]}), "`offset` must be a number, not [5]"),
|
||||
(json!({"offset": 5, "corners": 3}), "`corners` must be a string"),
|
||||
(json!({"offset": 5, "miterLimit": "high"}), "`miterLimit` must be a number, not \"high\""),
|
||||
(json!({"offset": 5, "miterLimit": false}), "`miterLimit` must be a number, not false"),
|
||||
] {
|
||||
let r = s.execute("addon.contour.set", &p);
|
||||
assert!(matches!(&r, Err(EngineError::BadParams { msg, .. }) if msg == want), "{p}: {r:?}");
|
||||
assert!(state(&s) == before, "{p} changed the document, the selection or the undo history");
|
||||
}
|
||||
// A null `offset` is a missing one.
|
||||
let err = s.execute("addon.contour.set", &json!({"offset": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("missing `offset`"), "{err}");
|
||||
// Null corners and miter limit keep sharp corners at 4; the offset takes a unit.
|
||||
let r = s.execute("addon.contour.set", &json!({"offset": "1 in", "corners": null, "miterLimit": null})).unwrap();
|
||||
let fx = effects_of(&s, ids_of(&r, "created")[0]);
|
||||
assert_eq!((fx[0].1["offset"].clone(), fx[0].1["joins"].clone(), fx[0].1["miterLimit"].clone()), (json!(72.0), json!("miter"), json!(4.0)));
|
||||
}
|
||||
|
||||
/// The border-with-a-gap recipe: two Contours of one shape (the gap, then the border), baked, then
|
||||
/// the gap taken out of the border with Minus Front, leaves a ring between the two.
|
||||
#[test]
|
||||
|
||||
@@ -774,8 +774,9 @@ fn divide_dashes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let only = (segs.len() < sp.segment_count()).then_some(&segs);
|
||||
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made))
|
||||
.ok_or_else(|| bad(C, "that makes too many pieces (at most 100000): make the dashes longer"))?;
|
||||
let out = pt::divide_dashes(sp, only, spec, MAX_PIECES.saturating_sub(made)).ok_or_else(|| {
|
||||
bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer")
|
||||
})?;
|
||||
made += out.len();
|
||||
for (piece, kind) in out.into_iter().filter(|(_, k)| !(remove_gaps && *k == pt::Piece::Gap)) {
|
||||
dashes += usize::from(kind == pt::Piece::Dash);
|
||||
@@ -825,7 +826,7 @@ fn dashed_paths(d: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
|
||||
fn expand_dashes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "addon.pathtools.expandDashes";
|
||||
let too_many = || bad(C, "that makes too many pieces (at most 100000): make the dashes longer");
|
||||
let too_many = || bad(C, "that makes too many pieces (at most 100000) or dash steps: simplify the dash pattern or make the dashes longer");
|
||||
let roots = checked_ids_or_selection(s, p, C)?;
|
||||
let d = &s.doc()?.doc;
|
||||
// Each dashed path and its dashes, as the canvas draws them.
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
//! `engine.apiReference`: the command API reference in Markdown, written when it is asked for from
|
||||
//! the parameter schemas of the built-in commands ([`crate::schemas`]). Each command that has a
|
||||
//! schema gets a section, in registry order, with its parameters in the schema's order; a command
|
||||
//! without a schema, a plug-in's included, is left out. The text depends only on the registry, so
|
||||
//! every call in one build returns the same bytes.
|
||||
//!
|
||||
//! Text from a schema is escaped before it goes into a heading, a list item or a table cell: the
|
||||
//! Markdown punctuation characters get a backslash (CommonMark 0.31.2, backslash escapes,
|
||||
//! <https://spec.commonmark.org/0.31.2/#backslash-escapes>), so a description shows as plain text and
|
||||
//! cannot end a table cell or make a link, an image or HTML; line breaks, tabs and other control
|
||||
//! characters become spaces.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_apischema::{Bound, MAX_DEPTH, MAX_ID, Omitted, Param, ParamSchema, Type, Writes};
|
||||
|
||||
use super::{CommandSpec, Result, Session, always, cmd, command_specs};
|
||||
use crate::EngineError;
|
||||
|
||||
const ID: &str = "engine.apiReference";
|
||||
|
||||
/// The largest reference the command returns, in bytes of UTF-8.
|
||||
pub const MAX_BYTES: usize = 1 << 20;
|
||||
|
||||
static REFERENCE: ParamSchema = ParamSchema::new(
|
||||
&[],
|
||||
"the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order",
|
||||
)
|
||||
.returns("{markdown: string}")
|
||||
.writes(Writes::Nothing)
|
||||
.since(2);
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "engine.apiReference",
|
||||
"Command API Reference",
|
||||
[],
|
||||
None,
|
||||
"{} the command API reference in Markdown, written from the built-in commands' parameter schemas: a section for each command that has one, in registry order → {markdown: string}",
|
||||
always,
|
||||
api_reference,
|
||||
schema: &REFERENCE
|
||||
)]
|
||||
}
|
||||
|
||||
fn api_reference(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(json!({ "markdown": reference(command_specs())? }))
|
||||
}
|
||||
|
||||
const HEADER: &str = "# VectorCraft command API reference\n\nThis reference lists only the commands that have a parameter schema, in registry order. `engine.commands` lists every command, with its params text and whether it can run now.\n";
|
||||
|
||||
/// The reference for the commands of `specs` that have a schema, or an error when it would be
|
||||
/// larger than [`MAX_BYTES`]; a reference that is too large is never cut short.
|
||||
pub(crate) fn reference(specs: &[CommandSpec]) -> Result<String> {
|
||||
let mut out = String::from(HEADER);
|
||||
let mut sections = 0;
|
||||
for spec in specs {
|
||||
let Some(schema) = spec.schema else { continue };
|
||||
let text = section(spec, schema);
|
||||
if out.len() + text.len() > MAX_BYTES {
|
||||
return Err(EngineError::Other(format!("`{ID}`: the reference would be larger than {MAX_BYTES} bytes")));
|
||||
}
|
||||
out.push_str(&text);
|
||||
sections += 1;
|
||||
}
|
||||
if sections == 0 {
|
||||
out.push_str("\nNo commands have parameter schemas.\n");
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One command's section: its heading, a list of what it is, and its parameters.
|
||||
fn section(spec: &CommandSpec, schema: &ParamSchema) -> String {
|
||||
let menu = if spec.menu.is_empty() { "None".to_string() } else { spec.menu.iter().map(|m| escape(m)).collect::<Vec<_>>().join(" › ") };
|
||||
let mut s = format!("\n## {}\n\n", escape(spec.id));
|
||||
s.push_str(&format!("- Label: {}\n", escape(spec.label)));
|
||||
s.push_str(&format!("- Menu: {menu}\n"));
|
||||
s.push_str(&format!("- API level: {}\n", schema.since));
|
||||
s.push_str(&format!("- Deprecated: {}\n", level_text(schema.deprecated)));
|
||||
s.push_str(&format!("- Writes: {}\n", writes_text(schema.writes)));
|
||||
s.push_str(&format!("- Cost: {}\n", schema.cost));
|
||||
s.push_str(&format!("- Summary: {}\n", text_or_none(schema.summary)));
|
||||
s.push_str(&format!("- Returns: {}\n\n", text_or_none(schema.returns)));
|
||||
if schema.params.is_empty() {
|
||||
s.push_str("No parameters.\n");
|
||||
return s;
|
||||
}
|
||||
s.push_str("| Parameter | Type | Range | Unit | Default | Required | Since | Deprecated | Description |\n");
|
||||
s.push_str("|---|---|---|---|---|---|---|---|---|\n");
|
||||
rows("", schema.params, 0, &mut s);
|
||||
s
|
||||
}
|
||||
|
||||
/// A table row for each of `params`, and after each one the rows of the members of an object it
|
||||
/// holds: `edits[].name` for an object in a list, `options.size` for an object.
|
||||
fn rows(prefix: &str, params: &[Param], depth: usize, out: &mut String) {
|
||||
for p in params {
|
||||
let name = if prefix.is_empty() { p.name.to_string() } else { format!("{prefix}.{}", p.name) };
|
||||
let (range, unit) = range_and_unit(&p.ty);
|
||||
out.push_str(&format!(
|
||||
"| {} | {} | {range} | {unit} | {} | {} | {} | {} | {} |\n",
|
||||
escape(&name),
|
||||
type_text(&p.ty, depth),
|
||||
default_text(p.omitted),
|
||||
if p.required { "Yes" } else { "No" },
|
||||
p.since,
|
||||
level_text(p.deprecated),
|
||||
description(p),
|
||||
));
|
||||
if depth < MAX_DEPTH
|
||||
&& let Some((members, path)) = members(&p.ty, name, depth)
|
||||
{
|
||||
rows(&path, members, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameters of the object `ty` holds, directly or as the items of lists, with the path of
|
||||
/// its members' prefix (`name`, `name[]`, `name[][]`).
|
||||
fn members(ty: &Type, mut path: String, depth: usize) -> Option<(&'static [Param], String)> {
|
||||
let mut ty = ty;
|
||||
for _ in depth..MAX_DEPTH {
|
||||
match ty {
|
||||
Type::Object(params) => return Some((params, path)),
|
||||
Type::Array(item) => {
|
||||
path.push_str("[]");
|
||||
ty = item;
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn type_text(ty: &Type, depth: usize) -> String {
|
||||
match ty {
|
||||
Type::Number { .. } => "number".into(),
|
||||
Type::Integer { .. } => "integer".into(),
|
||||
Type::Boolean => "boolean".into(),
|
||||
Type::String => "string".into(),
|
||||
Type::Enum(names) => format!("one of {}", names.iter().map(|n| format!("\"{}\"", escape(n))).collect::<Vec<_>>().join(", ")),
|
||||
Type::Id => "object id (integer)".into(),
|
||||
Type::Ids => "list of object ids".into(),
|
||||
Type::Color => escape("color (a string, [r, g, b] or an object)"),
|
||||
Type::Point => escape("point ([x, y], two numbers)"),
|
||||
Type::Object(_) => "object".into(),
|
||||
Type::Array(_) if depth >= MAX_DEPTH => "list".into(),
|
||||
Type::Array(item) => format!("list of {}", type_text(item, depth + 1)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The range and unit cells of a value of type `ty`.
|
||||
fn range_and_unit(ty: &Type) -> (String, String) {
|
||||
let ends = |low: Option<String>, high: Option<String>| {
|
||||
let both: Vec<String> = low.into_iter().chain(high).collect();
|
||||
if both.is_empty() { "None".to_string() } else { both.join(", ") }
|
||||
};
|
||||
match ty {
|
||||
Type::Number { min, max, unit } => {
|
||||
let low = min.map(|b| match b {
|
||||
Bound::Inclusive(a) => format!("at least {a}"),
|
||||
Bound::Exclusive(a) => format!("more than {a}"),
|
||||
});
|
||||
let high = max.map(|b| match b {
|
||||
Bound::Inclusive(a) => format!("at most {a}"),
|
||||
Bound::Exclusive(a) => format!("less than {a}"),
|
||||
});
|
||||
(ends(low, high), unit.map_or("None".to_string(), escape))
|
||||
}
|
||||
Type::Integer { min, max } => (ends(min.map(|a| format!("at least {a}")), max.map(|a| format!("at most {a}"))), "None".into()),
|
||||
Type::Id => (format!("at least 0, at most {MAX_ID}"), "None".into()),
|
||||
_ => ("None".into(), "None".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_text(omitted: Omitted) -> String {
|
||||
match omitted {
|
||||
Omitted::Nothing => "None".into(),
|
||||
Omitted::Value(json) => format!("{} (JSON)", escape(json)),
|
||||
Omitted::Note(note) => format!("{} (worked out by the command)", escape(note)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The parameter's doc text, and what the schema says about how its value is checked.
|
||||
fn description(p: &Param) -> String {
|
||||
let mut parts = Vec::new();
|
||||
if !p.doc.is_empty() {
|
||||
parts.push(escape(p.doc));
|
||||
}
|
||||
if p.null_omitted {
|
||||
parts.push("null reads as omitted".to_string());
|
||||
}
|
||||
if p.lenient {
|
||||
parts.push("lenient (a value of another type or out of range is passed to the command, not refused)".to_string());
|
||||
}
|
||||
if parts.is_empty() { "None".to_string() } else { parts.join("; ") }
|
||||
}
|
||||
|
||||
fn level_text(level: Option<u32>) -> String {
|
||||
level.map_or("None".to_string(), |l| format!("API level {l}"))
|
||||
}
|
||||
|
||||
fn writes_text(writes: Writes) -> &'static str {
|
||||
match writes {
|
||||
Writes::Nothing => "nothing",
|
||||
Writes::Session => "session",
|
||||
Writes::Preferences => "preferences",
|
||||
Writes::Document => "document",
|
||||
}
|
||||
}
|
||||
|
||||
fn text_or_none(text: &str) -> String {
|
||||
if text.is_empty() { "None".to_string() } else { escape(text) }
|
||||
}
|
||||
|
||||
/// The characters [`escape`] puts a backslash before.
|
||||
const ESCAPED: &[char] = &['\\', '|', '[', ']', '<', '>', '`', '*', '_', '!', '&', '#', '~', ':', '@'];
|
||||
|
||||
/// `text` as plain Markdown text on one line: each of [`ESCAPED`] with a backslash before it, the
|
||||
/// `.` after `www` too (so no autolink starts), and each control character as a space.
|
||||
pub(crate) fn escape(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if c.is_control() {
|
||||
out.push(' ');
|
||||
continue;
|
||||
}
|
||||
let after_www = out.len().checked_sub(3).and_then(|at| out.as_bytes().get(at..)).is_some_and(|b| b.eq_ignore_ascii_case(b"www"));
|
||||
if ESCAPED.contains(&c) || (c == '.' && after_www) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn run(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn spec(id: &'static str, menu: &'static [&'static str], schema: Option<&'static ParamSchema>) -> CommandSpec {
|
||||
CommandSpec { id, label: "Label", menu, shortcut: None, params: "", enabled: always, run, journal: false, schema }
|
||||
}
|
||||
|
||||
/// The cells of a table row as a GFM table reads them: split at each `|` that has no backslash
|
||||
/// before it, with the outer pipes and the spaces around each cell taken off.
|
||||
fn cells(row: &str) -> Vec<String> {
|
||||
let inner = row.strip_prefix('|').and_then(|r| r.strip_suffix('|')).unwrap_or_else(|| panic!("not a table row: {row}"));
|
||||
let (mut out, mut cell, mut chars) = (Vec::new(), String::new(), inner.chars());
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\\' => {
|
||||
cell.push(c);
|
||||
cell.extend(chars.next());
|
||||
}
|
||||
'|' => out.push(std::mem::take(&mut cell).trim().to_string()),
|
||||
_ => cell.push(c),
|
||||
}
|
||||
}
|
||||
out.push(cell.trim().to_string());
|
||||
out
|
||||
}
|
||||
|
||||
/// What `text` shows as: each escaped character as itself. Panics on a Markdown punctuation
|
||||
/// character without its backslash, or a backslash before a character that needs none.
|
||||
fn literal(text: &str) -> String {
|
||||
let (mut out, mut chars) = (String::new(), text.chars());
|
||||
while let Some(c) = chars.next() {
|
||||
assert!(!ESCAPED.contains(&c) || c == '\\', "`{c}` is not escaped in {text}");
|
||||
if c == '\\' {
|
||||
let next = chars.next().unwrap_or_else(|| panic!("a backslash ends {text}"));
|
||||
assert!(ESCAPED.contains(&next) || next == '.', "`\\{next}` in {text}");
|
||||
out.push(next);
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The shown cells of the row of parameter `name`.
|
||||
fn row(markdown: &str, name: &str) -> Vec<String> {
|
||||
let found = markdown.lines().filter(|l| l.starts_with('|')).find(|l| cells(l).first().map(|c| literal(c)).as_deref() == Some(name));
|
||||
let row = found.unwrap_or_else(|| panic!("no row for {name} in\n{markdown}"));
|
||||
let cells = cells(row);
|
||||
assert_eq!(cells.len(), 9, "{row}");
|
||||
cells.iter().map(|c| literal(c)).collect()
|
||||
}
|
||||
|
||||
/// The text after `- {label}: ` in the section's list.
|
||||
fn item(markdown: &str, label: &str) -> String {
|
||||
let prefix = format!("- {label}: ");
|
||||
let line = markdown.lines().find_map(|l| l.strip_prefix(prefix.as_str()));
|
||||
literal(line.unwrap_or_else(|| panic!("no {label} in\n{markdown}")))
|
||||
}
|
||||
|
||||
/// Two calls give the same bytes, with no document open, and the reference has one section
|
||||
/// for each built-in command that has a schema, in registry order, ending with this command's.
|
||||
#[test]
|
||||
fn the_reference_lists_each_schema_once_in_registry_order_and_is_the_same_each_time() {
|
||||
let mut s = Session::new();
|
||||
let first = s.execute(ID, &json!({})).unwrap();
|
||||
let second = s.execute(ID, &Value::Null).unwrap();
|
||||
assert_eq!(first, second);
|
||||
let markdown = first["markdown"].as_str().unwrap();
|
||||
assert_eq!(markdown, reference(command_specs()).unwrap());
|
||||
assert!(markdown.len() < MAX_BYTES / 10, "{} bytes", markdown.len());
|
||||
let headings: Vec<String> = markdown.lines().filter_map(|l| l.strip_prefix("## ")).map(literal).collect();
|
||||
let expected: Vec<&str> = command_specs().iter().filter(|c| c.schema.is_some()).map(|c| c.id).collect();
|
||||
assert_eq!(headings, expected);
|
||||
assert_eq!(headings.last().map(String::as_str), Some(ID));
|
||||
assert!(expected.contains(&"view.saved.new") && !headings.iter().any(|h| h == "file.new"));
|
||||
let new_view = &markdown[markdown.find("## view.saved.new").unwrap()..];
|
||||
let names: Vec<String> = new_view
|
||||
.lines()
|
||||
.skip_while(|l| !l.starts_with("|---"))
|
||||
.skip(1)
|
||||
.take_while(|l| l.starts_with('|'))
|
||||
.map(|l| literal(&cells(l)[0]))
|
||||
.collect();
|
||||
assert_eq!(names, ["name", "center", "zoom", "rotation"]);
|
||||
let edit = &markdown[markdown.find("## view.saved.edit").unwrap()..];
|
||||
assert_eq!(row(edit, "edits[].newName")[8], "null reads as omitted");
|
||||
assert_eq!(row(edit, "newName")[8], "rename; null reads as omitted");
|
||||
}
|
||||
|
||||
const INNER: &[Param] =
|
||||
&[Param::new("size", Type::Integer { min: Some(1), max: Some(10) }), Param::new("mode", Type::Enum(&["a", "b"])).defaults_to("\"a\"")];
|
||||
|
||||
static FULL: ParamSchema = ParamSchema::new(
|
||||
&[
|
||||
Param::new("zoom", Type::Number { min: Some(Bound::Exclusive(0.0)), max: Some(Bound::Inclusive(64.0)), unit: Some("pt") })
|
||||
.doc("1 = 100%"),
|
||||
Param::new("name", Type::String).default_note("the first free name").null_omitted(),
|
||||
Param::new("old", Type::Boolean).defaults_to("true").since(2).deprecated(3).lenient(),
|
||||
Param::new("edits", Type::Array(&Type::Object(INNER))).optional(),
|
||||
Param::new("ids", Type::Ids).optional(),
|
||||
Param::new("at", Type::Point).optional(),
|
||||
Param::new("color", Type::Color).optional(),
|
||||
Param::new("id", Type::Id).optional(),
|
||||
Param::new("ratio", Type::Number { min: Some(Bound::Inclusive(-1.5)), max: Some(Bound::Exclusive(2.0)), unit: None }).optional(),
|
||||
],
|
||||
"do the thing",
|
||||
)
|
||||
.returns("{done}")
|
||||
.writes(Writes::Preferences)
|
||||
.since(2)
|
||||
.deprecated(4)
|
||||
.cost(5);
|
||||
|
||||
/// Each field of a schema shows, nested members in their order under dotted names.
|
||||
#[test]
|
||||
fn every_field_of_a_schema_is_shown() {
|
||||
let markdown = reference(&[spec("test.full", &["View", "Saved"], Some(&FULL))]).unwrap();
|
||||
for (label, shown) in [
|
||||
("Label", "Label"),
|
||||
("Menu", "View › Saved"),
|
||||
("API level", "2"),
|
||||
("Deprecated", "API level 4"),
|
||||
("Writes", "preferences"),
|
||||
("Cost", "5"),
|
||||
("Summary", "do the thing"),
|
||||
("Returns", "{done}"),
|
||||
] {
|
||||
assert_eq!(item(&markdown, label), shown, "{label}");
|
||||
}
|
||||
let lenient = "lenient (a value of another type or out of range is passed to the command, not refused)";
|
||||
let expected: [[&str; 9]; 11] = [
|
||||
["zoom", "number", "more than 0, at most 64", "pt", "None", "Yes", "1", "None", "1 = 100%"],
|
||||
["name", "string", "None", "None", "the first free name (worked out by the command)", "No", "1", "None", "null reads as omitted"],
|
||||
["old", "boolean", "None", "None", "true (JSON)", "No", "2", "API level 3", lenient],
|
||||
["edits", "list of object", "None", "None", "None", "No", "1", "None", "None"],
|
||||
["edits[].size", "integer", "at least 1, at most 10", "None", "None", "Yes", "1", "None", "None"],
|
||||
["edits[].mode", "one of \"a\", \"b\"", "None", "None", "\"a\" (JSON)", "No", "1", "None", "None"],
|
||||
["ids", "list of object ids", "None", "None", "None", "No", "1", "None", "None"],
|
||||
["at", "point ([x, y], two numbers)", "None", "None", "None", "No", "1", "None", "None"],
|
||||
["color", "color (a string, [r, g, b] or an object)", "None", "None", "None", "No", "1", "None", "None"],
|
||||
["id", "object id (integer)", "at least 0, at most 9007199254740991", "None", "None", "No", "1", "None", "None"],
|
||||
["ratio", "number", "at least -1.5, less than 2", "None", "None", "No", "1", "None", "None"],
|
||||
];
|
||||
let names: Vec<String> =
|
||||
markdown.lines().filter(|l| l.starts_with("| ") && !l.starts_with("| Parameter")).map(|l| literal(&cells(l)[0])).collect();
|
||||
assert_eq!(names, expected.map(|r| r[0]));
|
||||
for want in expected {
|
||||
assert_eq!(row(&markdown, want[0]), want, "{}", want[0]);
|
||||
}
|
||||
}
|
||||
|
||||
const HOSTILE: &str = "a | b [link](https://evil.example) <script>x</script> `code` \\ *em* _u_  & ~~s~~ # h www.example.com me@example.com\r\nnext\tline";
|
||||
|
||||
static ESCAPES: ParamSchema =
|
||||
ParamSchema::new(&[Param::new("a|b", Type::String).doc(HOSTILE).defaults_to("\"<b>|</b>\"")], HOSTILE).returns(HOSTILE);
|
||||
|
||||
/// Markdown in a schema's text shows as plain text: a description stays in its own cell of a
|
||||
/// nine-cell row, every punctuation character is escaped, and line breaks and tabs are spaces.
|
||||
#[test]
|
||||
fn markdown_in_schema_text_stays_literal_in_its_cell() {
|
||||
let markdown = reference(&[spec("test.escapes", &["A|B", "[x](y)"], Some(&ESCAPES))]).unwrap();
|
||||
let shown = HOSTILE.replace(['\r', '\n', '\t'], " ");
|
||||
let cells = row(&markdown, "a|b");
|
||||
assert_eq!(cells[4], "\"<b>|</b>\" (JSON)");
|
||||
assert_eq!(cells[8], shown);
|
||||
assert_eq!(item(&markdown, "Summary"), shown);
|
||||
assert_eq!(item(&markdown, "Returns"), shown);
|
||||
assert_eq!(item(&markdown, "Menu"), "A|B › [x](y)");
|
||||
assert!(markdown.contains("www\\.example"), "{markdown}");
|
||||
let table: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect();
|
||||
assert_eq!(table.len(), 3, "the header, the delimiter row and one row: {table:?}");
|
||||
assert!(!markdown.contains('\r') && !markdown.contains('\t'));
|
||||
}
|
||||
|
||||
/// A registry with no schema gives the header and a line that says so; a command without a
|
||||
/// schema adds nothing.
|
||||
#[test]
|
||||
fn a_registry_without_schemas_gives_a_document_that_says_so() {
|
||||
let empty = format!("{HEADER}\nNo commands have parameter schemas.\n");
|
||||
assert_eq!(reference(&[]).unwrap(), empty);
|
||||
assert_eq!(reference(&[spec("test.none", &["View"], None)]).unwrap(), empty);
|
||||
let one = reference(&[spec("test.none", &[], None), spec("test.full", &[], Some(&FULL)), spec("test.other", &[], None)]).unwrap();
|
||||
assert_eq!(one, reference(&[spec("test.full", &[], Some(&FULL))]).unwrap());
|
||||
}
|
||||
|
||||
/// A reference of exactly [`MAX_BYTES`] is returned; one byte more is an error naming the
|
||||
/// command, with no part of the reference.
|
||||
#[test]
|
||||
fn a_reference_past_the_limit_is_an_error() {
|
||||
let with = |summary: String| -> Result<String> {
|
||||
let schema: &'static ParamSchema = Box::leak(Box::new(ParamSchema::new(&[], Box::leak(summary.into_boxed_str()))));
|
||||
reference(&[spec("test.big", &[], Some(schema))])
|
||||
};
|
||||
let base = with("x".into()).unwrap().len();
|
||||
let fits = with("x".repeat(MAX_BYTES - base + 1)).unwrap();
|
||||
assert_eq!(fits.len(), MAX_BYTES);
|
||||
let e = with("x".repeat(MAX_BYTES - base + 2)).unwrap_err().to_string();
|
||||
assert!(e.contains(ID) && e.contains("larger than 1048576 bytes"), "{e}");
|
||||
assert!(e.len() < 200, "{e}");
|
||||
}
|
||||
|
||||
/// An unknown key is refused with a parameter error naming it, and the command needs no
|
||||
/// document.
|
||||
#[test]
|
||||
fn an_unknown_parameter_is_refused_naming_it() {
|
||||
let mut s = Session::new();
|
||||
let e = s.execute(ID, &json!({"prefix": "view."})).unwrap_err().to_string();
|
||||
assert!(e.contains(ID) && e.contains("`prefix`"), "{e}");
|
||||
assert!(s.execute(ID, &json!([])).is_err());
|
||||
assert!(s.active().is_none());
|
||||
assert!(s.execute(ID, &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_marks_each_punctuation_character_and_flattens_control_characters() {
|
||||
assert_eq!(escape("a|b"), "a\\|b");
|
||||
assert_eq!(escape("WWW.x and w.x"), "WWW\\.x and w.x");
|
||||
assert_eq!(escape("é\u{7}\u{85}z"), "é z");
|
||||
assert_eq!(escape("1.5 > 1"), "1.5 \\> 1");
|
||||
}
|
||||
}
|
||||
@@ -939,7 +939,10 @@ fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let painted = super::graph::capture_series_top_paints(d, &top_paint_changed(&before, d, &ids));
|
||||
Ok(count_changed(d, &before, &ids) + usize::from(kept || painted))
|
||||
})?;
|
||||
s.remap_appearance_item(&ids, |a| (!idx.contains(&a)).then(|| a - idx.iter().filter(|i| **i < a).count()));
|
||||
// The rows removed from the active row's object, which need not come first in `ids`.
|
||||
let active = s.active_appearance_item.as_ref().and_then(|a| a.objects.first().copied());
|
||||
let gone = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
|
||||
s.remap_appearance_item(&ids, |a| (!gone.contains(&a)).then(|| a - gone.iter().filter(|i| **i < a).count()));
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -1023,10 +1026,12 @@ fn duplicate_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Rows at or above each copy move up by one (by the block, for copies placed at `to`).
|
||||
let m = idx.len();
|
||||
let landed = active_at.or(first_at);
|
||||
// The rows copied in the active row's object, which need not come first in `ids`.
|
||||
let copied = active.and_then(|a| ids.iter().position(|id| *id == a)).and_then(|k| per.get(k)).unwrap_or(&idx);
|
||||
s.remap_appearance_item(&ids, |a| {
|
||||
Some(match to {
|
||||
Some(_) => a + if landed.is_some_and(|at| at <= a) { m } else { 0 },
|
||||
None => a + idx.iter().filter(|i| **i < a).count(),
|
||||
None => a + copied.iter().filter(|i| **i < a).count(),
|
||||
})
|
||||
});
|
||||
ok()
|
||||
|
||||
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Assets",
|
||||
[],
|
||||
None,
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
|
||||
"{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included, with the clips of the groups and layers that hold it), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
|
||||
has_doc,
|
||||
list
|
||||
),
|
||||
@@ -213,7 +213,7 @@ fn add_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids.dedup();
|
||||
for id in &ids {
|
||||
let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?;
|
||||
if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) {
|
||||
if let Err(e) = vectorcraft_render::effects::three_d_meshes_in(Some(d), n) {
|
||||
let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0));
|
||||
return Err(bad(C, format!("{what} can't be exported in 3D: {e}")));
|
||||
}
|
||||
@@ -281,7 +281,8 @@ fn export_3d(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.nodes.iter().map(|node| d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))).collect::<Result<Vec<_>>>()?;
|
||||
// The asset's objects share one budget of triangles.
|
||||
let what = &a.name;
|
||||
let parts = vectorcraft_render::effects::three_d_meshes_of(nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
|
||||
let parts =
|
||||
vectorcraft_render::effects::three_d_meshes_of_in(Some(d), nodes).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?;
|
||||
let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into());
|
||||
let mut stem = base.clone();
|
||||
let mut k = 1;
|
||||
|
||||
@@ -165,13 +165,13 @@ pub struct BuiltinBrushLibrary {
|
||||
make: fn() -> BrushLibrary,
|
||||
}
|
||||
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code, one library
|
||||
/// per kind of brush.
|
||||
/// The built-in brush libraries, in menu order: original brushes generated in code.
|
||||
pub const BUILTIN: &[BuiltinBrushLibrary] = &[
|
||||
BuiltinBrushLibrary { id: "art-ribbons", name: "Art Ribbons", make: art_ribbons },
|
||||
BuiltinBrushLibrary { id: "calligraphic-nibs", name: "Calligraphic Nibs", make: calligraphic_nibs },
|
||||
BuiltinBrushLibrary { id: "pattern-borders", name: "Pattern Borders", make: pattern_borders },
|
||||
BuiltinBrushLibrary { id: "scatter-shapes", name: "Scatter Shapes", make: scatter_shapes },
|
||||
BuiltinBrushLibrary { id: "game-trails", name: "Game Trails", make: game_trails },
|
||||
];
|
||||
|
||||
/// Built-in library `id`, made once.
|
||||
@@ -378,6 +378,44 @@ fn scatter_shapes() -> BrushLibrary {
|
||||
built("Scatter Shapes", doc, brushes)
|
||||
}
|
||||
|
||||
/// Game Trails: art brushes for motion lines and impact streaks (original game HUD / VFX art).
|
||||
fn game_trails() -> BrushLibrary {
|
||||
let speed = {
|
||||
let n = 32;
|
||||
let half = |x: f64| 2.2 * (1.0 - x / 100.0).powf(1.2).max(0.08);
|
||||
let xs = |i: usize| 100.0 * i as f64 / n as f64;
|
||||
let mut pts: Vec<Point> = (0..=n).map(xs).map(|x| Point::new(x, -half(x))).collect();
|
||||
pts.extend((0..n).rev().map(xs).map(|x| Point::new(x, half(x))));
|
||||
PathData::single(SubPath::polyline(&pts, true))
|
||||
};
|
||||
let dash = group(vec![
|
||||
black(shapes::rounded_rectangle(Rect::new(0.0, -1.0, 14.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(22.0, -1.0, 36.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(44.0, -1.0, 58.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(66.0, -1.0, 80.0, 1.0), 1.0)),
|
||||
black(shapes::rounded_rectangle(Rect::new(88.0, -1.0, 100.0, 1.0), 1.0)),
|
||||
]);
|
||||
let burst = {
|
||||
// A wedge that flares from a tip at x=0 to a wide end at x=100.
|
||||
let tip = Point::new(0.0, 0.0);
|
||||
let top = Point::new(100.0, -6.0);
|
||||
let bot = Point::new(100.0, 6.0);
|
||||
PathData::single(SubPath::polyline(&[tip, top, bot], true))
|
||||
};
|
||||
let zigzag = band(100.0, 1.8, 40, |x| {
|
||||
let t = (x / 12.5).floor();
|
||||
let local = (x / 12.5) - t;
|
||||
if (t as i32) % 2 == 0 { -3.0 + 6.0 * local } else { 3.0 - 6.0 * local }
|
||||
});
|
||||
let brushes = vec![
|
||||
art("Speed Line", black(speed), Colorization::Tints),
|
||||
art("Dash Trail", dash, Colorization::Tints),
|
||||
art("Burst Ray", black(burst), Colorization::Tints),
|
||||
art("Zigzag", black(zigzag), Colorization::Tints),
|
||||
];
|
||||
built("Game Trails", Document::new(100.0, 100.0), brushes)
|
||||
}
|
||||
|
||||
// ---------- commands ----------
|
||||
|
||||
fn builtin_info(b: &BuiltinBrushLibrary) -> LibraryInfo {
|
||||
@@ -458,7 +496,7 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// nothing to apply is no edit, and a click that only applies brushes the document has is
|
||||
// named for that.
|
||||
let mut probe = (*st.doc).clone();
|
||||
let imported = clip.import_into(&mut probe, &choices, false);
|
||||
let imported = clip.import_into(&mut probe, &choices, false)?;
|
||||
let doc_names: Vec<String> = picked.iter().map(|n| target(&imported.renamed, n)).collect();
|
||||
let paths = if apply { brush_targets(&st.doc, &roots(s, p)?) } else { vec![] };
|
||||
let paint = stroke_paint(s);
|
||||
|
||||
@@ -208,7 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Break Link to Symbol",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask → {ids}",
|
||||
"{ids?|id?} expand selected instances into plain art (with their appearance changes to a dynamic symbol), placed through the symbol's 9-slice guides when it has them, with the instance's opacity mask; a hidden or locked instance's art stays hidden or locked and is not selected → {ids}",
|
||||
has_selection,
|
||||
symbol_break_link
|
||||
),
|
||||
|
||||
@@ -510,7 +510,7 @@ pub(super) fn expand_brush(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn brush_freehand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "brush").map(str::to_string).filter(|n| s.doc().is_ok_and(|st| brush::find(&st.doc, n).is_some()));
|
||||
let spec = find_command("path.freehand").ok_or_else(|| EngineError::UnknownCommand("path.freehand".into()))?;
|
||||
let r = (spec.run)(s, p)?;
|
||||
let r = crate::cmd::run_checked(s, spec, p)?;
|
||||
let (Some(name), Some(id)) = (name, id_param(&r, "id")) else { return Ok(r) };
|
||||
s.edit("Paintbrush", |d, _| {
|
||||
if let Some(st) = d.node_mut(id).and_then(|n| n.appearance.stroke_mut()) {
|
||||
|
||||
@@ -390,10 +390,11 @@ fn expanded_bound(d: &Document, inst: &Node, own: bool) -> u64 {
|
||||
most.saturating_add(1).saturating_add(mask)
|
||||
}
|
||||
|
||||
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). Returns the
|
||||
/// art's ids. Fails before any edit when the instances together would make more than
|
||||
/// Replace instances `ids` with their art ([`expanded_instance`], `own` as there). A hidden or
|
||||
/// locked instance's art stays hidden or locked. Returns each replaced instance's id with its art's
|
||||
/// id. Fails before any edit when the instances together would make more than
|
||||
/// [`MAX_EXPAND_NODES`] objects.
|
||||
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId>> {
|
||||
fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<(NodeId, NodeId)>> {
|
||||
let total = ids.iter().filter_map(|id| d.node(*id)).fold(0u64, |sum, n| sum.saturating_add(expanded_bound(d, n, own)));
|
||||
if total > MAX_EXPAND_NODES as u64 {
|
||||
return Err(EngineError::Other("Too many pieces to make at once".into()));
|
||||
@@ -401,12 +402,14 @@ fn break_links(d: &mut Document, ids: &[NodeId], own: bool) -> Result<Vec<NodeId
|
||||
let mut out = vec![];
|
||||
for id in ids {
|
||||
let Some(inst) = d.node(*id).cloned() else { continue };
|
||||
let Some(art) = expanded_instance(d, &inst, own) else { continue };
|
||||
let Some(mut art) = expanded_instance(d, &inst, own) else { continue };
|
||||
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
|
||||
art.visible &= inst.visible;
|
||||
art.locked |= inst.locked;
|
||||
let aid = art.id;
|
||||
d.remove(*id)?;
|
||||
d.insert(par, idx, art)?;
|
||||
out.push(aid);
|
||||
out.push((*id, aid));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -491,10 +494,22 @@ pub(super) fn symbol_break_link(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if ids.is_empty() {
|
||||
return Err(EngineError::Other("no symbol instances selected".into()));
|
||||
}
|
||||
let iso = s.doc()?.isolation;
|
||||
let out = s.edit("Break Link to Symbol", |d, sel| {
|
||||
let out = break_links(d, &ids, true)?;
|
||||
sel.set(out.iter().copied());
|
||||
Ok(out)
|
||||
let fresh = d.new_art.get();
|
||||
let made = break_links(d, &ids, true)?;
|
||||
// Art that is hidden or locked, or in a hidden or locked group or layer, is left out of the
|
||||
// selection, as is art on a locked artboard unless the instance it replaces was new art.
|
||||
let locks = d.locked_artboards();
|
||||
sel.set(made.iter().filter_map(|(inst, art)| {
|
||||
let keep = if fresh.contains(inst) {
|
||||
crate::cmd::select::is_selectable_on_any_artboard(d, iso, *art)
|
||||
} else {
|
||||
crate::cmd::select::is_selectable(d, iso, &locks, *art)
|
||||
};
|
||||
keep.then_some(*art)
|
||||
}));
|
||||
Ok(made.into_iter().map(|(_, art)| art).collect::<Vec<_>>())
|
||||
})?;
|
||||
Ok(json!({ "ids": ids_json(&out) }))
|
||||
}
|
||||
|
||||
@@ -115,7 +115,8 @@ pub(crate) fn subpath_selected_segments(
|
||||
let n = sp.anchors.len();
|
||||
let mut selected_segs = BTreeSet::new();
|
||||
|
||||
let has_explicit_segs = seg_refs.is_some_and(|segments| segments.iter().any(|&(s, _)| s == si));
|
||||
// Segments picked anywhere on the path leave anchors out of it, as Clear does.
|
||||
let has_explicit_segs = seg_refs.is_some_and(|segments| !segments.is_empty());
|
||||
if has_explicit_segs {
|
||||
if let Some(segments) = seg_refs {
|
||||
for &(s, seg) in segments {
|
||||
@@ -209,6 +210,24 @@ pub(crate) fn extract_direct_selection(node: &Node, selection: &Selection) -> Op
|
||||
if pieces.is_empty() { None } else { Some(pieces) }
|
||||
}
|
||||
|
||||
/// Does Copy take `node` unchanged under direct selection, as [`extract_direct_selection`]
|
||||
/// returning `None` means: no anchors or segments of it are picked, it is not a path, or the
|
||||
/// picked ones cover every segment?
|
||||
pub(crate) fn copied_whole(node: &Node, selection: &Selection) -> bool {
|
||||
let seg_refs = selection.segments.get(&node.id);
|
||||
let anchor_refs = selection.anchors.get(&node.id);
|
||||
if seg_refs.is_none() && anchor_refs.is_none() {
|
||||
return true;
|
||||
}
|
||||
let Some(path) = node.path_data() else { return true };
|
||||
let (mut total_segs, mut total_selected) = (0, 0);
|
||||
for (si, sp) in path.subpaths.iter().enumerate() {
|
||||
total_segs += sp.segment_count();
|
||||
total_selected += subpath_selected_segments(sp, si, seg_refs, anchor_refs).len();
|
||||
}
|
||||
total_selected == 0 || total_selected == total_segs
|
||||
}
|
||||
|
||||
impl Clipboard {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.nodes.is_empty() && self.artboards.is_empty() && self.guides.is_empty()
|
||||
@@ -233,15 +252,47 @@ impl Clipboard {
|
||||
let d = &*st.doc;
|
||||
let mut nodes = vec![];
|
||||
let mut source_layers = vec![];
|
||||
// Each compound's bounds, measured once however many of its members are copied.
|
||||
let mut compound_bounds: std::collections::HashMap<NodeId, Option<vectorcraft_geom::Rect>> = std::collections::HashMap::new();
|
||||
for id in roots {
|
||||
let (Some(n), Some(anc)) = (d.node(*id), d.ancestry(*id)) else { continue };
|
||||
let layer_name = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|l| l.is_layer())).and_then(|l| l.name.clone());
|
||||
let compound_parent = anc.iter().rev().skip(1).find_map(|a| d.node(*a).filter(|p| matches!(p.kind, NodeKind::Compound { .. })));
|
||||
// Gradients fitted to the compound's bounds are pinned there, as a split pins them.
|
||||
let gradient_box = compound_parent
|
||||
.filter(|p| p.appearance.has_unplaced_gradient())
|
||||
.and_then(|p| *compound_bounds.entry(p.id).or_insert_with(|| p.geometric_bounds()));
|
||||
// A compound's member or piece copied on its own takes the compound's look (its paint,
|
||||
// transparency, opacity mask, pixel alignment and fill rule), so it pastes as it looked.
|
||||
// It is linked to the compound's graphic style while it still has that style's look
|
||||
// (a gradient pinned to the compound's bounds does not), else to none.
|
||||
let inherit_appearance = |mut piece: Node| {
|
||||
if let Some(parent) = compound_parent
|
||||
&& piece.appearance.items.is_empty()
|
||||
&& let (NodeKind::Compound { rule: compound_rule, .. }, NodeKind::Path { rule, .. }) = (&parent.kind, &mut piece.kind)
|
||||
{
|
||||
*rule = *compound_rule;
|
||||
piece.appearance = parent.appearance.clone();
|
||||
if let Some(bounds) = gradient_box {
|
||||
piece.appearance.pin_gradients(bounds);
|
||||
}
|
||||
piece.opacity = parent.opacity;
|
||||
piece.blend = parent.blend;
|
||||
piece.isolate = parent.isolate;
|
||||
piece.knockout = parent.knockout;
|
||||
piece.knockout_shape = parent.knockout_shape;
|
||||
let (mask, pixel_aligned) = (parent.extras().mask.clone(), parent.extras().pixel_aligned);
|
||||
{
|
||||
let mut extras = piece.extras_mut();
|
||||
extras.mask = mask;
|
||||
extras.pixel_aligned = pixel_aligned;
|
||||
}
|
||||
let link = parent
|
||||
.extras()
|
||||
.graphic_style
|
||||
.and_then(|g| d.graphic_style_by_id(g))
|
||||
.filter(|g| super::style::in_sync(&piece, g))
|
||||
.map(|g| g.id);
|
||||
piece.extras_mut().graphic_style = link;
|
||||
}
|
||||
piece
|
||||
};
|
||||
|
||||
@@ -21,14 +21,14 @@ use serde_json::{Map, Value, json};
|
||||
use vectorcraft_brush::Brush;
|
||||
use vectorcraft_color::{Color, Paint, Swatch};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Document, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice, SymbolRegistration,
|
||||
TextStyleDef,
|
||||
Appearance, AppearanceItem, Document, Effect, GraphicStyle, Node, NodeKind, PatternDef, Symbol, SymbolExportType, SymbolNineSlice,
|
||||
SymbolRegistration, TextStyleDef,
|
||||
};
|
||||
|
||||
use super::super::brushsym::{set_natural_size, stored_natural_size};
|
||||
|
||||
use super::super::patterncmds::add_pattern;
|
||||
use super::super::{Result, bad, unique_name};
|
||||
use super::super::{EngineError, Result, bad, unique_name};
|
||||
use super::Clipboard;
|
||||
|
||||
/// A resource kind objects refer to (graphic styles by id, the others by name).
|
||||
@@ -140,6 +140,48 @@ fn node_refs(n: &Node, f: Visit) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The `(name, xml)` of `e` when it is an SVG filter effect.
|
||||
fn filter_of(e: &Effect) -> Option<(String, String)> {
|
||||
let text = |k: &str| e.params.get(k).and_then(Value::as_str).map(str::to_string);
|
||||
if e.id != Appearance::SVG_FILTER {
|
||||
return None;
|
||||
}
|
||||
Some((text("name")?, text("xml")?))
|
||||
}
|
||||
|
||||
/// The SVG filter effects of appearance `a` (its own and its fills' and strokes').
|
||||
fn appearance_filters(a: &Appearance, out: &mut BTreeSet<(String, String)>) {
|
||||
let items = a.items.iter().flat_map(|i| i.effects().iter());
|
||||
out.extend(a.effects.iter().chain(items).filter_map(filter_of));
|
||||
}
|
||||
|
||||
/// The SVG filter effects in the subtree of `n` (opacity-mask art included).
|
||||
fn node_filters(n: &Node, out: &mut BTreeSet<(String, String)>) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_filters(&m.appearance, out);
|
||||
if let Some(mask) = &m.extras().mask {
|
||||
node_filters(&mask.art, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The names the SVG filter effects of appearance `a` give (its own and its fills' and strokes').
|
||||
fn appearance_filter_names(a: &Appearance, out: &mut BTreeSet<String>) {
|
||||
let items = a.items.iter().flat_map(|i| i.effects().iter());
|
||||
let named = a.effects.iter().chain(items).filter(|e| e.id == Appearance::SVG_FILTER);
|
||||
out.extend(named.filter_map(|e| e.params.get("name").and_then(Value::as_str)).map(str::to_string));
|
||||
}
|
||||
|
||||
/// The names the SVG filter effects in the subtree of `n` give (opacity-mask art included).
|
||||
fn node_filter_names(n: &Node, out: &mut BTreeSet<String>) {
|
||||
n.walk(&mut |m| {
|
||||
appearance_filter_names(&m.appearance, out);
|
||||
if let Some(mask) = &m.extras().mask {
|
||||
node_filter_names(&mask.art, out);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A global or spot colour of its own (not a tint of another swatch): what colours link to.
|
||||
fn is_base(w: &Swatch) -> bool {
|
||||
(w.global || w.spot) && matches!(w.paint, Paint::Solid { swatch: None, .. })
|
||||
@@ -527,6 +569,9 @@ impl SwatchChoices {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Relink {
|
||||
renames: BTreeMap<Res, BTreeMap<String, String>>,
|
||||
/// An SVG filter effect's `(name, xml)` on the clipboard → its `(name, xml)` in the document
|
||||
/// ([`Clipboard::filter_links`]).
|
||||
filters: BTreeMap<(String, String), (String, String)>,
|
||||
/// Graphic style id on the clipboard → its id in the document. Links to other ids are dropped.
|
||||
styles: BTreeMap<u32, u32>,
|
||||
/// Swatch (its name in the document) → the colour linked colours show at 100 %.
|
||||
@@ -566,6 +611,15 @@ impl Relink {
|
||||
});
|
||||
}
|
||||
fn appearance(&self, a: &mut Appearance) {
|
||||
if !self.filters.is_empty() {
|
||||
let items = a.items.iter_mut().flat_map(|i| i.effects_mut().iter_mut());
|
||||
for e in a.effects.iter_mut().chain(items) {
|
||||
if let Some((name, xml)) = filter_of(e).and_then(|k| self.filters.get(&k)) {
|
||||
e.params["name"] = json!(name);
|
||||
e.params["xml"] = json!(xml);
|
||||
}
|
||||
}
|
||||
}
|
||||
for it in &mut a.items {
|
||||
self.paint(it.paint_mut());
|
||||
if let AppearanceItem::Stroke(l) = it
|
||||
@@ -1001,8 +1055,9 @@ impl Clipboard {
|
||||
}
|
||||
|
||||
/// Add the resources the objects use to `d` (inside the paste's undo step) and work out how
|
||||
/// pasted copies relink to them.
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Imported {
|
||||
/// pasted copies relink to them. An error, with `d` unchanged, when a pasted SVG filter finds no
|
||||
/// free name ([`Self::filter_links`]).
|
||||
pub(crate) fn import_into(&self, d: &mut Document, choices: &SwatchChoices, same_doc: bool) -> Result<Imported> {
|
||||
self.import_used(d, &self.used(), choices, same_doc, false)
|
||||
}
|
||||
|
||||
@@ -1011,8 +1066,9 @@ impl Clipboard {
|
||||
/// each style, symbol, pattern and brush comes after the styles, symbols, patterns and brushes it uses
|
||||
/// ([`use_order`]), so that it is compared and relinked with their names here, and each loop
|
||||
/// of definitions that use each other is matched on its own ([`loop_groups`]).
|
||||
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Imported {
|
||||
fn import_used(&self, d: &mut Document, used: &Used, choices: &SwatchChoices, same_doc: bool, in_use_order: bool) -> Result<Imported> {
|
||||
let mut out = Imported::default();
|
||||
out.relink.filters = self.filter_links(d, same_doc)?;
|
||||
self.import_images(d, used, same_doc, &mut out);
|
||||
self.import_swatches(d, used, choices, same_doc, &mut out);
|
||||
// Brushes whose art shows no symbols or patterns, before the symbols and patterns whose art
|
||||
@@ -1170,8 +1226,83 @@ impl Clipboard {
|
||||
}
|
||||
vectorcraft_brush::store(d, &lib);
|
||||
}
|
||||
out.relinks = !(out.relink.renames.is_empty() && out.relink.styles.is_empty() && out.relink.colors.is_empty() && used.styles.is_empty());
|
||||
out
|
||||
out.relinks = !(out.relink.renames.is_empty()
|
||||
&& out.relink.filters.is_empty()
|
||||
&& out.relink.styles.is_empty()
|
||||
&& out.relink.colors.is_empty()
|
||||
&& used.styles.is_empty());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// How the pasted SVG filter effects relink ([`Relink::filters`]). Each names the document's
|
||||
/// filter of its name; one that draws another definition than that filter takes the document's
|
||||
/// filter when pasted back into the document it came from, and elsewhere keeps its own under a
|
||||
/// name no filter or effect of the document gives, so editing or deleting the document's filter leaves it alone. When that name would
|
||||
/// make the filter too long to draw, the free name is a shorter start of its name ("Shif"), else
|
||||
/// a free letter or digit; the filter's id is always its name. An error when no free name fits.
|
||||
fn filter_links(&self, d: &Document, same_doc: bool) -> Result<BTreeMap<(String, String), (String, String)>> {
|
||||
let mut found = BTreeSet::new();
|
||||
for n in &self.nodes {
|
||||
node_filters(n, &mut found);
|
||||
}
|
||||
for s in &self.symbols {
|
||||
node_filters(&s.art, &mut found);
|
||||
}
|
||||
for a in self.patterns.iter().flat_map(|p| p.art.iter()) {
|
||||
node_filters(a, &mut found);
|
||||
}
|
||||
for a in self.brushes.iter().flat_map(Brush::art) {
|
||||
node_filters(a, &mut found);
|
||||
}
|
||||
for g in &self.graphic_styles {
|
||||
appearance_filters(&g.appearance, &mut found);
|
||||
}
|
||||
let mut taken: BTreeSet<String> = d.svg_filters.iter().map(|f| f.name.clone()).chain(found.iter().map(|(n, _)| n.clone())).collect();
|
||||
let mut effects_read = false;
|
||||
let mut links = BTreeMap::new();
|
||||
for (name, xml) in found {
|
||||
let Some(held) = d.svg_filters.iter().find(|f| f.name == name).filter(|f| f.xml != xml.trim()) else { continue };
|
||||
let to = if same_doc {
|
||||
(name.clone(), held.xml.clone())
|
||||
} else {
|
||||
// The names the document's effects give are taken too, those whose filter was
|
||||
// deleted included: defining a filter of that name later changes every effect that
|
||||
// names it. They are read at the first rename only.
|
||||
if !effects_read {
|
||||
effects_read = true;
|
||||
for root in d.art_roots() {
|
||||
node_filter_names(root, &mut taken);
|
||||
}
|
||||
for a in vectorcraft_brush::library(d).iter().flat_map(Brush::art) {
|
||||
node_filter_names(a, &mut taken);
|
||||
}
|
||||
for g in &d.graphic_styles {
|
||||
appearance_filter_names(&g.appearance, &mut taken);
|
||||
}
|
||||
}
|
||||
// Free names made from the starts of the name, longest first, then free letters and
|
||||
// digits: the first under which the filter still draws.
|
||||
let mut ends: Vec<usize> = name.char_indices().map(|(i, _)| i).skip(1).collect();
|
||||
ends.push(name.len());
|
||||
let starts = ends.iter().rev().filter_map(|&end| name.get(..end).map(str::trim_end)).filter(|b| !b.is_empty());
|
||||
let shorter = starts.map(|b| unique_name(b, |n| taken.contains(n)));
|
||||
let single = ('a'..='z').chain('0'..='9').map(String::from).filter(|n| !taken.contains(n));
|
||||
let renamed = shorter.chain(single).find_map(|new| {
|
||||
let drawn = vectorcraft_render::effects::svg_filter::with_id(&xml, &new)
|
||||
.filter(|x| vectorcraft_render::effects::svg_filter::check(x).is_ok_and(|id| id == new))?;
|
||||
Some((new, drawn))
|
||||
});
|
||||
let Some((new, drawn)) = renamed else {
|
||||
return Err(EngineError::Other(format!(
|
||||
"the pasted SVG filter {name:?} draws differently from the document's filter of that name, and no free name fits it"
|
||||
)));
|
||||
};
|
||||
taken.insert(new.clone());
|
||||
(new, drawn)
|
||||
};
|
||||
links.insert((name, xml), to);
|
||||
}
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
/// Image blobs: a different blob under a key the document uses gets a new key.
|
||||
@@ -1286,13 +1417,13 @@ pub(crate) struct LoadedStyle {
|
||||
/// a pattern added under a free name and a pattern follows a style renamed on the way in. A style
|
||||
/// `d` has under its name, or a numbered copy of it ("Body 2"), that is the same once relinked is
|
||||
/// used; any other is added under a free name. → each style of `src` placed, in order, character
|
||||
/// styles first.
|
||||
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Vec<LoadedStyle> {
|
||||
/// styles first. An error, as [`Clipboard::import_into`] gives.
|
||||
pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool, paras: bool) -> Result<Vec<LoadedStyle>> {
|
||||
let library = OnceCell::new();
|
||||
let used = Used::of_text_styles(&Defs::of(src, &library), chars, paras);
|
||||
let clip = Clipboard::with_used(src, &used, &library, vec![]);
|
||||
let before = [&d.char_styles, &d.para_styles].map(|l| l.iter().map(|s| s.name.clone()).collect::<BTreeSet<_>>());
|
||||
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true);
|
||||
let out = clip.import_used(d, &used, &SwatchChoices::default(), false, true)?;
|
||||
let mut placed: Vec<LoadedStyle> = vec![];
|
||||
for (para, r, list) in [(false, Res::CharStyle, &clip.char_styles), (true, Res::ParaStyle, &clip.para_styles)] {
|
||||
for s in list {
|
||||
@@ -1302,7 +1433,7 @@ pub(crate) fn import_text_styles(src: &Document, d: &mut Document, chars: bool,
|
||||
placed.push(LoadedStyle { para, name, added });
|
||||
}
|
||||
}
|
||||
placed
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// Swatch `w` in `d` ([`existing`]), else added under a free name (swatches and colour groups
|
||||
|
||||
@@ -48,9 +48,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Convert to Grayscale",
|
||||
["Edit", "Edit Colors"],
|
||||
None,
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert) → {changed, notChanged, warning?}",
|
||||
"{fill?, stroke?, includeImages?, includePatterns?, ids?} (as edit.colors.invert; an embedded image is stored with one gray channel) → {changed, notChanged, warning?}",
|
||||
has_selection,
|
||||
|s, p| recolor(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|
||||
|s, p| recolor_gray(s, p, "edit.colors.toGrayscale", "Convert to Grayscale", &to_gray)
|
||||
),
|
||||
cmd!(
|
||||
"edit.colors.toRGB",
|
||||
@@ -187,6 +187,8 @@ pub(crate) struct Recolor<'a> {
|
||||
/// Maps solid colours and gradient stops with their link and tint in place of `f`.
|
||||
links: Option<&'a LinkMap<'a>>,
|
||||
scope: Scope,
|
||||
/// Recolored images are stored with one gray channel ([`ImageBlob::map_gray`]).
|
||||
gray_images: bool,
|
||||
/// Image key → the key of its recoloured copy (`None`: unchanged).
|
||||
images: HashMap<String, Option<String>>,
|
||||
/// Pattern name → the name of its recoloured copy (`None`: unchanged, or being recoloured).
|
||||
@@ -198,7 +200,17 @@ pub(crate) struct Recolor<'a> {
|
||||
|
||||
impl<'a> Recolor<'a> {
|
||||
pub(crate) fn new(scope: Scope, f: &'a dyn Fn(Color) -> Color) -> Self {
|
||||
Self { f, links: None, scope, images: HashMap::new(), patterns: HashMap::new(), new_images: vec![], new_patterns: vec![], changed: 0 }
|
||||
Self {
|
||||
f,
|
||||
links: None,
|
||||
scope,
|
||||
gray_images: false,
|
||||
images: HashMap::new(),
|
||||
patterns: HashMap::new(),
|
||||
new_images: vec![],
|
||||
new_patterns: vec![],
|
||||
changed: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A pass that maps only solid colours and gradient stops, with their swatch link and tint
|
||||
@@ -215,7 +227,7 @@ impl<'a> Recolor<'a> {
|
||||
|
||||
/// A pass that maps colors as this one does, with nothing recolored yet.
|
||||
fn again(&self) -> Self {
|
||||
Self { links: self.links, ..Self::new(self.scope, self.f) }
|
||||
Self { links: self.links, gray_images: self.gray_images, ..Self::new(self.scope, self.f) }
|
||||
}
|
||||
|
||||
/// The entries of `objects` (ids, each with a flag) whose object this pass would change, other
|
||||
@@ -313,10 +325,15 @@ impl<'a> Recolor<'a> {
|
||||
return done.clone();
|
||||
}
|
||||
let f = self.f;
|
||||
let new = d.images.get(key)?.map_rgb(|[r, g, b]| {
|
||||
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
});
|
||||
let blob = d.images.get(key)?;
|
||||
let new = if self.gray_images {
|
||||
blob.map_gray(|[r, g, b]| f(Color::rgb8(r, g, b)).to_rgba8(1.0)[0])
|
||||
} else {
|
||||
blob.map_rgb(|[r, g, b]| {
|
||||
let [r, g, b, _] = f(Color::rgb8(r, g, b)).to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
})
|
||||
};
|
||||
let new = new.map(|blob| {
|
||||
let k = blob.content_key();
|
||||
self.new_images.push((k.clone(), blob));
|
||||
@@ -400,6 +417,11 @@ pub(crate) fn recolor(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dy
|
||||
run_pass(s, p, cmd, label, Recolor::new(Scope::of(p), f))
|
||||
}
|
||||
|
||||
/// [`recolor`] where `f` gives gray colors, so embedded images are stored with one gray channel.
|
||||
fn recolor_gray(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color) -> Result<Value> {
|
||||
run_pass(s, p, cmd, label, Recolor { gray_images: true, ..Recolor::new(Scope::of(p), f) })
|
||||
}
|
||||
|
||||
/// [`recolor`] with solid colours and gradient stops mapped through `links`, with their swatch
|
||||
/// link and tint.
|
||||
pub(crate) fn recolor_links(s: &mut Session, p: &Value, cmd: &str, label: &str, f: &dyn Fn(Color) -> Color, links: &LinkMap) -> Result<Value> {
|
||||
@@ -575,10 +597,11 @@ fn adjust_balance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
other => return Err(bad(C, format!("unknown mode `{other}` (rgb|cmyk|gray|global)"))),
|
||||
};
|
||||
let convert = bool_or(p, "convert", false);
|
||||
recolor(s, p, C, "Adjust Colors", &move |c| {
|
||||
let f = move |c| {
|
||||
let out = adjust(c);
|
||||
if convert { out } else { keep_model(c, out) }
|
||||
})
|
||||
};
|
||||
if convert && mode == "gray" { recolor_gray(s, p, C, "Adjust Colors", &f) } else { recolor(s, p, C, "Adjust Colors", &f) }
|
||||
}
|
||||
|
||||
enum BlendOrder {
|
||||
|
||||
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Color Settings…",
|
||||
["Edit"],
|
||||
Some("Cmd+Shift+K"),
|
||||
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}",
|
||||
"{rgb?: profile, cmyk?: profile, intent?: \"perceptual\"|\"relative\"|\"saturation\"|\"absolute\", bpc?: bool} set the working spaces → {rgb, cmyk, intent, bpc, profiles: [{name, kind, builtin}]}. The desktop app and vectorcraft-cli keep them in `Color Settings.json` next to the preferences and use them again at the next start (not with VECTORCRAFT_NO_PREFS); saved settings naming a profile that is gone leave the defaults",
|
||||
always,
|
||||
color_settings
|
||||
),
|
||||
@@ -192,6 +192,8 @@ fn color_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
st.bpc = switch_or(p, "bpc", st.bpc, C)?;
|
||||
if st != before {
|
||||
cms::set_active(&st).map_err(|e| bad(C, e.to_string()))?;
|
||||
// Saved for the next start when the app attached a settings file (#6335).
|
||||
s.store_color_settings(&st);
|
||||
touch_all(s);
|
||||
}
|
||||
Ok(settings_json(&st))
|
||||
|
||||
@@ -478,6 +478,32 @@ fn an_opened_file_measures_its_type_in_its_own_composite_fonts() {
|
||||
assert_eq!(actual.cached_bounds, Some(expected));
|
||||
}
|
||||
|
||||
/// Threaded type read from a file flows through its frames in the file's own composite fonts, when
|
||||
/// no open document defines them.
|
||||
#[test]
|
||||
fn an_opened_file_flows_its_threads_in_its_own_composite_fonts() {
|
||||
let mut s = session();
|
||||
s.execute("type.compositeFonts.set", &json!({"fonts": [font("CF Threaded")]})).unwrap();
|
||||
let story = "The quick brown fox jumps over the lazy dog and keeps running. ".repeat(4);
|
||||
let a = s
|
||||
.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 20, "font": "CF Threaded", "area": {"width": 150, "height": 60}}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 300})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
s.execute("text.thread.create", &json!({})).unwrap();
|
||||
let first = text(&s, a).plain_text();
|
||||
assert!(!first.is_empty() && first.len() < story.len());
|
||||
let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc);
|
||||
// A new front document without the font: the font database no longer holds it.
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
assert!(vectorcraft_text::FontDb::global().composite_font("CF Threaded").is_none());
|
||||
let file = crate::cmd::fileio::read_for_open("threaded.vectorcraft", &bytes, None, &json!({}), false, false).unwrap();
|
||||
crate::cmd::fileio::open_read(&mut s, file).unwrap();
|
||||
assert_eq!(text(&s, a).plain_text(), first);
|
||||
}
|
||||
|
||||
/// A document replaced while in the back (File › Revert) is measured in its own composite fonts
|
||||
/// when it comes to the front, not in the front document's.
|
||||
#[test]
|
||||
|
||||
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export CSS",
|
||||
[],
|
||||
None,
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
|
||||
has_doc,
|
||||
export
|
||||
),
|
||||
|
||||
@@ -3,7 +3,9 @@ use super::{CommandSpec, EngineError, Result, Session, cmd, has_doc};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::dimension::{ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, TextAlignment, TextPosition};
|
||||
use vectorcraft_doc::dimension::{
|
||||
ArrowStyle, Dimension, DimensionMode, DimensionOptions, DimensionSource, LineType, Orientation, TextAlignment, TextPosition,
|
||||
};
|
||||
use vectorcraft_doc::{Appearance, Arrowhead, CharStyle, ColorMode, Dash, Document, Node, NodeId, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Affine, BezPath, PathData, Point, Vec2};
|
||||
|
||||
@@ -14,7 +16,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Create Dimension",
|
||||
[],
|
||||
None,
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}}, offset?: pt, options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
"{mode?: linear|angular|radius|diameter, a?: [x,y], b?: [x,y], vertex?: [x,y], source?: {kind: segment, edge: {node,subpath,segment}}|{kind: angle, first: edge, second: edge}|{kind: circle, node}|{kind: arc, edge}|{kind: points, a?, b?, vertex?: {node,subpath,anchor}, freeA?, freeB?: a point that is not an anchor, {kind: onSegment, edge: {node,subpath,segment}, t: 0..1 (curve parameter)}|{kind: anchors, node, anchors: [{subpath,anchor,part?: point|in|out (the anchor, default, or its in or out handle),weight}] (2 or 3 points of one path whose weights add up to 1), across?: (2 anchors only: times the first-to-second anchor vector (x,y) turned to (-y,x), which points down when the vector points right)} (an end is an anchor or a free point, not both)}, orientation?: aligned|horizontal|vertical (linear only: the distance between the points, or only its horizontal or vertical part; default aligned), offset?: pt (the line's distance from the first point: along the left normal of a → b when aligned, downward when horizontal, leftward when vertical; negative goes the other way), options?: {units: in|mm|pt|pc|px|cm|m|ft|yd, precision: 0-6, scale: drawing:real, hideUnits, lineWeight, lineType: solid|dashed|dotted, lineColor: [r,g,b], arrowStyle: filled|open|tick|none, arrowScale: %, extensionWeight, extensionType, extensionColor, extensionOffset, extensionLength, hideExtensions, fontFamily, fontStyle, fontSize, textColor, textPosition: away|between, textAlignment: left|center|right, textOffset}} → {id,label}; linked measurements follow source edits",
|
||||
has_doc,
|
||||
create
|
||||
),
|
||||
@@ -23,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Update Dimensions",
|
||||
[],
|
||||
None,
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, offset?: pt} restyle or move existing dimensions → {ids}",
|
||||
"{ids?: [dimension ids] (default selection), options?: dimension options, orientation?: aligned|horizontal|vertical (linear dimensions only; the line stays on the side of the points it was on), offset?: pt (as in dimension.create)} restyle, turn or move existing dimensions → {ids}",
|
||||
has_doc,
|
||||
update
|
||||
),
|
||||
@@ -92,11 +94,36 @@ fn recipe(s: &Session, p: &Value) -> Result<Dimension> {
|
||||
} else {
|
||||
Point::ZERO
|
||||
};
|
||||
let mut d = Dimension { mode, a, b, vertex, offset: distance(p, 30.0)?, length: a.distance(b), options: options(s, p)?, source, moved: None };
|
||||
let orientation = orientation(p, mode)?.unwrap_or_default();
|
||||
let mut d = Dimension {
|
||||
mode,
|
||||
a,
|
||||
b,
|
||||
vertex,
|
||||
offset: distance(p, 30.0)?,
|
||||
length: a.distance(b),
|
||||
options: options(s, p)?,
|
||||
source,
|
||||
orientation,
|
||||
moved: None,
|
||||
};
|
||||
if mode == DimensionMode::Linear {
|
||||
d.length = d.measured();
|
||||
}
|
||||
d.resolve(&s.doc()?.doc).map_err(error)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// The `orientation` param, if given: only a linear dimension is horizontal or vertical.
|
||||
fn orientation(p: &Value, mode: DimensionMode) -> Result<Option<Orientation>> {
|
||||
let Some(v) = p.get("orientation") else { return Ok(None) };
|
||||
let orientation: Orientation = serde_json::from_value(v.clone()).map_err(|_| error("Unknown dimension orientation"))?;
|
||||
if orientation != Orientation::Aligned && mode != DimensionMode::Linear {
|
||||
return Err(error("Only linear dimensions are horizontal or vertical"));
|
||||
}
|
||||
Ok(Some(orientation))
|
||||
}
|
||||
|
||||
fn insert(doc: &mut Document, parent: Option<NodeId>, dim: Dimension) -> Result<NodeId> {
|
||||
let id = doc.alloc_id();
|
||||
let mut group = Node::new(id, NodeKind::Group { children: vec![], clip: false });
|
||||
@@ -134,21 +161,38 @@ fn update(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(error("Select dimensions to update"));
|
||||
}
|
||||
let o = if p.get("options").is_some() { Some(options(s, p)?) } else { None };
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
// Every dimension is checked before anything changes, and an update that changes none of
|
||||
// them records no undo step.
|
||||
let mut next = Vec::with_capacity(ids.len());
|
||||
{
|
||||
let doc = &s.doc()?.doc;
|
||||
for id in &ids {
|
||||
if !doc.is_editable(*id) {
|
||||
return Err(error("Dimension is locked or hidden"));
|
||||
}
|
||||
let mut dim = dimension_of(doc, *id)?;
|
||||
let old = dimension_of(doc, *id)?;
|
||||
let mut dim = old.clone();
|
||||
if let Some(o) = &o {
|
||||
dim.options = o.clone();
|
||||
}
|
||||
if let Some(orientation) = orientation(p, dim.mode)? {
|
||||
dim.set_orientation(orientation);
|
||||
}
|
||||
dim.offset = distance(p, dim.offset)?;
|
||||
dim.resolve(doc).map_err(error)?;
|
||||
replace(doc, *id, dim)?;
|
||||
if dim != old {
|
||||
next.push((*id, dim));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
if !next.is_empty() {
|
||||
s.edit("Update Dimensions", |doc, _| {
|
||||
for (id, dim) in next {
|
||||
replace(doc, id, dim)?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Ok(json!({"ids":ids}))
|
||||
}
|
||||
|
||||
@@ -187,9 +231,10 @@ fn apply_to_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn replace(doc: &mut Document, id: NodeId, dim: Dimension) -> Result<()> {
|
||||
let old: Vec<NodeId> = doc.node(id).and_then(Node::children).map(|ch| ch.iter().map(|n| n.id).collect()).unwrap_or_default();
|
||||
let children = artwork(doc, &dim, &old)?;
|
||||
let name = format!("Dimension · {}", dim.label().map_err(error)?);
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.kind = NodeKind::Group { children, clip: false };
|
||||
n.name = Some(format!("Dimension · {}", dim.label().map_err(error)?));
|
||||
n.name = Some(name);
|
||||
n.extras_mut().dimension = Some(Box::new(dim));
|
||||
}
|
||||
Ok(())
|
||||
@@ -227,8 +272,9 @@ impl EndedLink {
|
||||
}
|
||||
|
||||
/// The [`reached`] dimensions become plain groups, whose art does not redraw or follow what it
|
||||
/// measured. A command that replaces art calls this before it changes anything, since replacing
|
||||
/// the art a dimension measures would remove the dimension, and then [`restore_unchanged`].
|
||||
/// measured. A command that replaces art calls this before it changes anything and
|
||||
/// [`restore_unchanged`] after, so that a dimension whose own art the command changed stays a
|
||||
/// plain group.
|
||||
pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink> {
|
||||
let mut ended = Vec::new();
|
||||
for id in reached(doc, roots) {
|
||||
@@ -241,18 +287,156 @@ pub(crate) fn end_links(doc: &mut Document, roots: &[NodeId]) -> Vec<EndedLink>
|
||||
ended
|
||||
}
|
||||
|
||||
/// The dimensions in `ended` whose art is as it was are live again.
|
||||
pub(crate) fn restore_unchanged(doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
/// The dimensions in `ended` whose own art is as it was are live again. One whose measured art the
|
||||
/// command changed from `before` so that the art no longer fits its link stays where it was, no
|
||||
/// longer linked, as [`refresh`] leaves a dimension whose art was deleted.
|
||||
pub(crate) fn restore_unchanged(before: &Document, doc: &mut Document, ended: Vec<EndedLink>) {
|
||||
for e in ended {
|
||||
if doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art)
|
||||
&& let Some(n) = doc.node_mut(e.id)
|
||||
{
|
||||
n.extras_mut().dimension = Some(Box::new(e.recipe));
|
||||
if !doc.node(e.id).and_then(Node::children).is_some_and(|art| *art == e.art) {
|
||||
continue;
|
||||
}
|
||||
let recipe = if e.source_kept(before, doc) { e.recipe } else { unlinked(&e.recipe) };
|
||||
if let Some(n) = doc.node_mut(e.id) {
|
||||
n.extras_mut().dimension = Some(Box::new(recipe));
|
||||
doc.dimensions = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `old` where it was, no longer linked. A move it had on its own and not yet drawn is kept when
|
||||
/// the moved dimension is valid.
|
||||
fn unlinked(old: &Dimension) -> Dimension {
|
||||
let mut unlinked = old.clone();
|
||||
unlinked.source = None;
|
||||
unlinked.moved = None;
|
||||
if let Some(v) = old.moved {
|
||||
let mut shifted = unlinked.clone();
|
||||
(shifted.a, shifted.b, shifted.vertex) = (shifted.a + v, shifted.b + v, shifted.vertex + v);
|
||||
if shifted.validate().is_ok() {
|
||||
unlinked = shifted;
|
||||
}
|
||||
}
|
||||
unlinked
|
||||
}
|
||||
|
||||
/// The whole dimensions under `roots` that are selected together with art they measure (art under
|
||||
/// `roots`), each one a root or held by groups without fills, strokes or effects of their own.
|
||||
/// Object › Expand, Object › Expand Appearance and Object › Path › Outline Stroke leave their
|
||||
/// lines and label as they are. A group with an appearance of its own changes everything it holds
|
||||
/// when it expands, its dimensions included.
|
||||
pub(crate) fn with_their_art(doc: &Document, roots: &[NodeId]) -> Vec<NodeId> {
|
||||
if !doc.dimensions {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
let Some(n) = doc.node(*root) else { continue };
|
||||
n.walk(&mut |c| {
|
||||
if let Some(source) = c.extras().dimension.as_ref().and_then(|d| d.source.as_ref()) {
|
||||
found.push((*root, c.id, source.nodes()));
|
||||
}
|
||||
});
|
||||
}
|
||||
let under_roots = |id: NodeId| doc.ancestry(id).is_some_and(|a| a.iter().any(|a| roots.contains(a)));
|
||||
found
|
||||
.into_iter()
|
||||
.filter(|(root, id, _)| {
|
||||
let Some(ancestry) = doc.ancestry(*id) else { return false };
|
||||
let from = ancestry.iter().position(|a| a == root).unwrap_or(ancestry.len());
|
||||
ancestry.get(from..ancestry.len().saturating_sub(1)).unwrap_or_default().iter().all(|a| doc.node(*a).is_some_and(plain_group))
|
||||
})
|
||||
.filter(|(_, _, measured)| measured.iter().any(|m| under_roots(*m)))
|
||||
.map(|(_, id, _)| id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A group or layer without fills, strokes or effects of its own.
|
||||
fn plain_group(n: &Node) -> bool {
|
||||
matches!(n.kind, NodeKind::Group { .. } | NodeKind::Layer { .. }) && n.appearance.items.is_empty() && n.appearance.effects.is_empty()
|
||||
}
|
||||
|
||||
/// A dimension [`lift`] took out of the document, with its place, the export assets that list it or
|
||||
/// its parts (with their places in the asset list) and its parts' variable bindings.
|
||||
pub(crate) struct Lifted {
|
||||
parent: Option<NodeId>,
|
||||
index: usize,
|
||||
node: Arc<Node>,
|
||||
assets: Vec<(usize, vectorcraft_doc::assets::ExportAsset)>,
|
||||
bindings: Vec<(NodeId, std::collections::BTreeSet<String>)>,
|
||||
}
|
||||
|
||||
impl Lifted {
|
||||
pub(crate) fn id(&self) -> NodeId {
|
||||
self.node.id
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes the [`with_their_art`] dimensions under `roots` out of `doc`, so a command that replaces
|
||||
/// art does not reach them; [`put_back`] puts them back.
|
||||
pub(crate) fn lift(doc: &mut Document, roots: &[NodeId]) -> Vec<Lifted> {
|
||||
let mut lifted = Vec::new();
|
||||
for id in with_their_art(doc, roots) {
|
||||
let Some((parent, index, _)) = doc.position(id) else { continue };
|
||||
let mut parts = std::collections::BTreeSet::new();
|
||||
if let Some(n) = doc.node(id) {
|
||||
n.walk(&mut |c| {
|
||||
parts.insert(c.id);
|
||||
});
|
||||
}
|
||||
let assets = doc.assets.iter().enumerate().filter(|(_, a)| a.nodes.iter().any(|n| parts.contains(n))).map(|(i, a)| (i, a.clone())).collect();
|
||||
let bindings = doc.variables.bindings.iter().filter(|(n, _)| parts.contains(n)).map(|(n, b)| (*n, b.clone())).collect();
|
||||
if let Ok(node) = doc.remove(id) {
|
||||
lifted.push(Lifted { parent, index, node, assets, bindings });
|
||||
}
|
||||
}
|
||||
lifted
|
||||
}
|
||||
|
||||
/// Each dimension [`lift`] took out, as it was, back in its place in `doc`, with its parts back in
|
||||
/// the export assets that listed them and their variable bindings back: edits made while it was out
|
||||
/// let go of them. A dimension whose group is gone goes on top of the first layer.
|
||||
pub(crate) fn put_back(doc: &mut Document, lifted: Vec<Lifted>) {
|
||||
for l in lifted.into_iter().rev() {
|
||||
let parent = if doc.children(l.parent).is_some() { l.parent } else { doc.layers.first().map(|layer| layer.id) };
|
||||
let Ok(ch) = doc.children_mut(parent) else { continue };
|
||||
let i = if parent == l.parent { l.index.min(ch.len()) } else { ch.len() };
|
||||
ch.insert(i, l.node);
|
||||
doc.dimensions = true;
|
||||
for (at, saved) in l.assets {
|
||||
let there: Vec<NodeId> = saved.nodes.iter().copied().filter(|n| doc.node(*n).is_some()).collect();
|
||||
if let Some(a) = doc.assets.iter_mut().find(|a| a.id == saved.id) {
|
||||
for (k, n) in there.iter().enumerate() {
|
||||
if !a.nodes.contains(n) {
|
||||
let k = k.min(a.nodes.len());
|
||||
a.nodes.insert(k, *n);
|
||||
}
|
||||
}
|
||||
} else if !there.is_empty() {
|
||||
let at = at.min(doc.assets.len());
|
||||
doc.assets.insert(at, vectorcraft_doc::assets::ExportAsset { nodes: there, ..saved });
|
||||
}
|
||||
}
|
||||
for (n, b) in l.bindings {
|
||||
doc.variables.bindings.entry(n).or_insert(b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `doc` without the [`with_their_art`] dimensions under `roots`, to see what a command that leaves
|
||||
/// them alone would change: `doc` itself when there are none.
|
||||
pub(crate) fn without_kept<'a>(doc: &'a Document, roots: &[NodeId]) -> std::borrow::Cow<'a, Document> {
|
||||
let ids = with_their_art(doc, roots);
|
||||
if ids.is_empty() {
|
||||
return std::borrow::Cow::Borrowed(doc);
|
||||
}
|
||||
let mut view = doc.clone();
|
||||
for id in ids {
|
||||
// A dimension already gone (inside another one removed) needs nothing more.
|
||||
let _ = view.remove(id);
|
||||
}
|
||||
std::borrow::Cow::Owned(view)
|
||||
}
|
||||
|
||||
/// Called inside every edit, so dimensions and their undo history stay in step with the art they
|
||||
/// measure. Returns at once in documents without dimensions.
|
||||
pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
@@ -271,17 +455,27 @@ pub(crate) fn refresh(before: &Document, doc: &mut Document) -> Result<bool> {
|
||||
let mut next = old.clone();
|
||||
let moved = next.moved.take();
|
||||
if next.source.as_ref().is_some_and(|s| s.topology_changed(before, doc)) || next.resolve(doc).is_err() {
|
||||
// A deleted/degenerate source has no valid measurement. Remove its annotation in
|
||||
// this same undo step instead of keeping a silently wrong number.
|
||||
doc.remove(id)?;
|
||||
// The art it measured was deleted, or rebuilt with other anchors, or no longer fits
|
||||
// (an ellipse that was a circle): it stays where it was, no longer linked, in this
|
||||
// same undo step, rather than attaching to a different edge.
|
||||
if replace(doc, id, unlinked(&old)).is_err()
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
// Its recipe cannot be drawn again: its art stays as it is, as a plain group.
|
||||
n.extras_mut().dimension = None;
|
||||
}
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next.keep_gap(&old);
|
||||
if let Some(xf) = next.source.as_ref().and_then(|s| s.reflection(before, doc)) {
|
||||
next.mirror_line(&old, xf * old.handle());
|
||||
}
|
||||
// Moved on its own (the art it measures stayed put): it keeps its new place.
|
||||
if let Some(v) = moved
|
||||
&& (next.a, next.b, next.vertex) == (old.a, old.b, old.vertex)
|
||||
{
|
||||
next.place(next.handle() + v);
|
||||
next.place(old.handle() + v);
|
||||
if next.resolve(doc).is_err() {
|
||||
next = old.clone();
|
||||
next.moved = None;
|
||||
@@ -424,17 +618,19 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
text_at = d.vertex + dir * if away { r + gap } else { (r - gap).max(o.font_size * 0.5) };
|
||||
} else {
|
||||
let delta = d.b - d.a;
|
||||
let u = delta / delta.hypot();
|
||||
// A linear dimension measures along its direction (a → b, or the horizontal or vertical axis).
|
||||
let u = if d.mode == DimensionMode::Linear { d.direction() } else { delta / delta.hypot() };
|
||||
let n = Vec2::new(-u.y, u.x);
|
||||
let (a, b) = match d.mode {
|
||||
DimensionMode::Radius => (d.a, d.b),
|
||||
DimensionMode::Diameter => (d.a - delta, d.b),
|
||||
_ => (d.a + n * d.offset, d.b + n * d.offset),
|
||||
_ => d.line_ends(),
|
||||
};
|
||||
// The side of the line facing away from the object.
|
||||
// The side of the line facing away from the object: the side the line is on, seen from
|
||||
// the middle of the measured points.
|
||||
let outward = match d.mode {
|
||||
DimensionMode::Linear => {
|
||||
if d.offset < 0.0 {
|
||||
if (a.midpoint(b) - d.a.midpoint(d.b)).dot(n) < 0.0 {
|
||||
-n
|
||||
} else {
|
||||
n
|
||||
@@ -443,8 +639,16 @@ fn artwork(doc: &mut Document, d: &Dimension, old: &[NodeId]) -> Result<Vec<Arc<
|
||||
_ => n,
|
||||
};
|
||||
if d.mode == DimensionMode::Linear && !o.hide_extensions {
|
||||
// Each extension line runs from its point toward the dimension line, which can lie on
|
||||
// different sides of the two points of a horizontal or vertical dimension.
|
||||
for (p, q) in [(d.a, a), (d.b, b)] {
|
||||
paths.push((line(p + outward * o.extension_offset, q + outward * o.extension_length), appearance(o, mode, true, false)));
|
||||
let side = (q - p).dot(n);
|
||||
let toward = match side {
|
||||
s if s < 0.0 => -n,
|
||||
s if s > 0.0 => n,
|
||||
_ => outward,
|
||||
};
|
||||
paths.push((line(p + toward * o.extension_offset, q + toward * o.extension_length), appearance(o, mode, true, false)));
|
||||
}
|
||||
}
|
||||
angle = u.atan2();
|
||||
|
||||
@@ -263,6 +263,22 @@ fn record_again(s: &mut Session, again: Value) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `d` with each of `ids` that is not in perspective attached to `fallback` as [`persp_edit`] attaches
|
||||
/// it; `d` itself when every one is in perspective already, there is no `fallback`, or one of them
|
||||
/// cannot attach.
|
||||
fn with_fallback<'a>(d: &'a Document, ids: &[NodeId], fallback: Option<Plane>) -> std::borrow::Cow<'a, Document> {
|
||||
let mut g = grid_of(d);
|
||||
let loose: Vec<NodeId> = ids.iter().copied().filter(|id| g.attachment_of(d, *id).is_none()).collect();
|
||||
let Some(plane) = fallback.filter(|_| !loose.is_empty()) else { return std::borrow::Cow::Borrowed(d) };
|
||||
let mut out = d.clone();
|
||||
for id in loose {
|
||||
if attach_in(&mut out, &mut g, id, plane, false, None).is_err() {
|
||||
return std::borrow::Cow::Borrowed(d);
|
||||
}
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() })
|
||||
}
|
||||
@@ -285,9 +301,11 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let constrain = switch_or(p, "constrain", false, C)?;
|
||||
// `snap_fix` uses Snap to Grid's setting for a `snap` that is not true or false.
|
||||
check_switches(p, &["snap"], C)?;
|
||||
// Shift and Snap to Grid read the objects as they are once those not in perspective are attached.
|
||||
let doc = if perp { std::borrow::Cow::Borrowed(&*s.doc()?.doc) } else { with_fallback(&s.doc()?.doc, &ids, fallback) };
|
||||
// Shift: the constrained end point, read on the first object's plane and depth.
|
||||
let snap_to = if constrain && !perp {
|
||||
let doc = &s.doc()?.doc;
|
||||
let doc = &*doc;
|
||||
let g = grid_of(doc);
|
||||
g.plane_bounds(doc, &ids)
|
||||
.and_then(|(plane, depth, _)| {
|
||||
@@ -305,10 +323,10 @@ pub(super) fn persp_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fix = if perp {
|
||||
None
|
||||
} else {
|
||||
let doc = &s.doc()?.doc;
|
||||
crate::cmd::perspgrid::snap_fix(doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
crate::cmd::perspgrid::snap_fix(&doc, &ids, p, (from, snap_to))
|
||||
.and_then(|(plane, depth, correction)| Some((plane, grid_of(&doc).plane_delta(plane, depth, from, to)?, correction)))
|
||||
};
|
||||
drop(doc);
|
||||
let snapped = move |pl: Plane, raw: vectorcraft_geom::Vec2| {
|
||||
let raw = fix.filter(|f| f.0 == pl).map_or(raw, |f| f.1);
|
||||
let (direction, dv) =
|
||||
@@ -635,6 +653,7 @@ pub(crate) fn finish_edit_text(st: &mut crate::DocState, id: NodeId) {
|
||||
}
|
||||
};
|
||||
st.change_unsaved(clear);
|
||||
st.history.rewrites += 1;
|
||||
for e in st.history.undo.iter_mut().chain(st.history.redo.iter_mut()) {
|
||||
let saved = Arc::ptr_eq(&e.doc, &st.saved_doc);
|
||||
clear(&mut e.doc);
|
||||
|
||||
@@ -183,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Lock Guides",
|
||||
["View", "Guides"],
|
||||
Some("Cmd+Alt+;"),
|
||||
"{locked?: bool} toggle (or set) the session's guide lock; locking deselects the ruler guides and guide paths in every open document → {locked}",
|
||||
"{locked?: bool} toggle (or set) the session's guide lock, leaving the lockGuidesByDefault preference as it is; locking deselects the ruler guides and guide paths in every open document; guide.add and a guide dragged out of a ruler still make new guides while locked → {locked}",
|
||||
has_doc,
|
||||
lock_guides
|
||||
),
|
||||
@@ -277,6 +277,27 @@ impl Session {
|
||||
pub fn guides_locked(&self) -> bool {
|
||||
self.menu.guides_locked
|
||||
}
|
||||
|
||||
/// Set View → Guides → Lock Guides. Locked guides can't stay selected, ruler guides or guide
|
||||
/// paths, in any open document ([`Session::select`] lets those go in the active one).
|
||||
pub(crate) fn set_guides_locked(&mut self, locked: bool) {
|
||||
self.menu.guides_locked = locked;
|
||||
if !locked {
|
||||
return;
|
||||
}
|
||||
let guides_selected =
|
||||
self.doc().is_ok_and(|st| !st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id)));
|
||||
// It fails only without a document, and then no guide is selected.
|
||||
if guides_selected {
|
||||
let _ = self.select(|_, sel| sel.guides.clear());
|
||||
}
|
||||
let active = self.active;
|
||||
for (i, st) in self.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_hidden(&self.menu, &st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Select ----------
|
||||
@@ -717,21 +738,7 @@ fn release_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn lock_guides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = switch_or(p, "locked", !s.menu.guides_locked, "view.guides.lock")?;
|
||||
s.menu.guides_locked = v;
|
||||
// Locked guides can't stay selected, ruler guides or guide paths, in any open document
|
||||
// ([`Session::select`] lets those go in the active one).
|
||||
let st = s.doc()?;
|
||||
if v && (!st.selection.guides.is_empty() || st.selection.objects.iter().any(|id| crate::is_guide_path(&st.doc, *id))) {
|
||||
s.select(|_, sel| sel.guides.clear())?;
|
||||
}
|
||||
if v {
|
||||
let active = s.active;
|
||||
for (i, st) in s.docs.iter_mut().enumerate() {
|
||||
if Some(i) != active && crate::deselect_hidden(&s.menu, &st.doc, &mut st.selection) {
|
||||
st.revision += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
s.set_guides_locked(v);
|
||||
Ok(json!({ "locked": v }))
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Split Path",
|
||||
[],
|
||||
None,
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two → {ids}",
|
||||
"{id, subpath?, segment, t} or {id, subpath?, anchor}: closed paths open there, open paths become two; one of the two new end points is left selected → {ids}",
|
||||
has_doc,
|
||||
split
|
||||
),
|
||||
|
||||
@@ -200,9 +200,16 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(EngineError::Other("no such segment".into())),
|
||||
};
|
||||
let mut pieces = sp.cut_at(&std::collections::BTreeSet::from([k]));
|
||||
// The cut leaves one of its two coincident end points selected, as `path.cutAtAnchors`
|
||||
// does, so a Direct Selection drag pulls the path apart there.
|
||||
let pick = |sel: &mut vectorcraft_doc::Selection, id: NodeId, si: usize| {
|
||||
sel.clear();
|
||||
sel.add(id);
|
||||
sel.anchors.entry(id).or_default().insert((si, 0));
|
||||
};
|
||||
if sp.closed {
|
||||
path.subpaths[si] = pieces.pop().ok_or_else(|| EngineError::Other("no such anchor".into()))?;
|
||||
sel.set([id]);
|
||||
pick(sel, id, si);
|
||||
return Ok(vec![id]);
|
||||
}
|
||||
let [left, right] = <[SubPath; 2]>::try_from(pieces).map_err(|_| EngineError::Other("cannot split an open path at its end point".into()))?;
|
||||
@@ -211,12 +218,12 @@ pub(super) fn split(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let node = sibling_with(d, id, PathData::single(right))?;
|
||||
let (parent, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
|
||||
let nid = d.insert(parent, idx + 1, node)?;
|
||||
sel.set([id, nid]);
|
||||
pick(sel, nid, 0);
|
||||
Ok(vec![id, nid])
|
||||
} else {
|
||||
path.subpaths[si] = left;
|
||||
path.subpaths.insert(si + 1, right);
|
||||
sel.set([id]);
|
||||
pick(sel, id, si + 1);
|
||||
Ok(vec![id])
|
||||
}
|
||||
})?;
|
||||
|
||||
@@ -157,7 +157,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Cut",
|
||||
["Edit"],
|
||||
Some("Cmd+X"),
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
"{selectedArtboards?: [n…], art?: bool, lockedAndHidden?: bool (the Artboard tool chosen, as artboard.cut)} (as edit.copy) removes the objects it copies, in one undo step: a compound-path member selected on its own leaves the compound's other members in the compound (selected ruler guides go too; the Artboard tool chosen, or selectedArtboards given: artboard.cut); refused while an object of a symbol instance's art is selected",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
cut
|
||||
),
|
||||
@@ -166,7 +166,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Copy",
|
||||
["Edit"],
|
||||
Some("Cmd+C"),
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses, and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
"{selectedArtboards?: [n…] (the artboards the Artboard tool had selected, which the journal records for a replay; it runs artboard.copy with any tool chosen), art?: bool (default: the Artboard tool's moveArt option; the journal records it beside selectedArtboards), lockedAndHidden?: bool (the Artboard tool chosen, as artboard.copy)} the selection with what it uses (a compound-path member selected on its own, whole or in part, with Direct or Group Selection, is copied as an ordinary path with the compound's paint, transparency, opacity mask, fill rule and graphic style link (while it has that style's look), a gradient keeping the compound's fit; the compound is copied when it is selected, or when every member is selected whole outside isolation mode), and the selected ruler guides (pasted on the layer new art goes to, moved as the objects are; alone, where they were); the Artboard tool chosen: artboard.copy; with an object of a symbol instance's art selected, the instance is copied whole → {copied, guides}",
|
||||
has_selection_guides_or_artboard_tool,
|
||||
copy
|
||||
),
|
||||
@@ -220,7 +220,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Clear",
|
||||
["Edit"],
|
||||
Some("Delete"),
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides); without ids, refused while an object of a symbol instance's art is selected",
|
||||
"{ids?, guides?: bool (with ids: also delete the selected ruler guides)} delete the objects `ids`, else the selection (fully selected objects and direct-selected anchors together, else ruler guides; a compound-path member selected on its own is deleted alone, and a compound left with no members goes too); without ids, refused while an object of a symbol instance's art is selected",
|
||||
has_selection_or_guides,
|
||||
clear
|
||||
),
|
||||
@@ -931,7 +931,11 @@ fn without_inner(doc: &Document, placed: Vec<(NodeId, Option<Vec<usize>>)>) -> i
|
||||
})
|
||||
}
|
||||
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations.
|
||||
/// The direct-selection flag and resolved root nodes for Copy and Cut operations. A compound-path
|
||||
/// member selected on its own (whole, with Direct or Group Selection, or by some of its anchors or
|
||||
/// segments) is taken as itself, so Copy copies it as an ordinary path and Cut removes it alone. The
|
||||
/// compound is taken whole when it is selected, or when every one of its members is selected whole
|
||||
/// outside isolation mode; in an isolated compound every member is taken as itself.
|
||||
fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let st = s.doc()?;
|
||||
// The Pen and the Curvature tool leave the anchor they placed or edited selected: Copy takes
|
||||
@@ -940,9 +944,19 @@ fn copy_cut_targets(s: &Session) -> Result<(bool, Vec<NodeId>)> {
|
||||
let in_drawing_tool = matches!(s.tool_id(), "pen" | "curvature") && !picked;
|
||||
let direct = !in_drawing_tool && (!st.selection.anchors.is_empty() || !st.selection.segments.is_empty());
|
||||
let in_isolated_compound = st.isolation.is_some_and(|iso| st.doc.node(iso).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })));
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |_compound, member| {
|
||||
in_isolated_compound || (direct && (st.selection.partial(member).is_some() || st.selection.segments.contains_key(&member)))
|
||||
});
|
||||
// A member counts as selected whole when Copy would take it unchanged: no anchors or segments
|
||||
// of it are picked, the picked ones cover every segment, or a drawing tool's own anchor is
|
||||
// all that is picked.
|
||||
let selected: std::collections::HashSet<NodeId> = st.selection.objects.iter().copied().collect();
|
||||
let whole = |m: &vectorcraft_doc::Node| selected.contains(&m.id) && (!direct || super::clipboard::copied_whole(m, &st.selection));
|
||||
// Worked out once per compound: every selected member asks about its compound.
|
||||
let checked = std::cell::RefCell::new(std::collections::HashMap::new());
|
||||
let all_members_whole = |compound: NodeId| {
|
||||
*checked.borrow_mut().entry(compound).or_insert_with(|| {
|
||||
st.doc.node(compound).and_then(|n| n.children()).is_some_and(|members| !members.is_empty() && members.iter().all(|m| whole(m)))
|
||||
})
|
||||
};
|
||||
let roots = roots_within(&st.doc, st.selection.in_paint_order(&st.doc), |compound, _member| in_isolated_compound || !all_members_whole(compound));
|
||||
Ok((direct, roots))
|
||||
}
|
||||
|
||||
@@ -1016,8 +1030,8 @@ fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
copy(s, p)?;
|
||||
return clear(s, &json!({}));
|
||||
}
|
||||
// Copy promotes a compound member to its compound. Cut must remove the same objects it
|
||||
// copied, while Clear on its own deletes exactly the selected members.
|
||||
// Cut removes the same objects Copy takes: a compound member selected on its own, or the
|
||||
// whole compound when it is selected or every member is selected whole.
|
||||
let (direct, roots) = copy_cut_targets(s)?;
|
||||
let res = copy(s, p)?;
|
||||
if res.get("copied").and_then(Value::as_u64) == Some(0) {
|
||||
@@ -1045,12 +1059,8 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
for id in roots {
|
||||
let Some(node) = d.node(*id) else { continue };
|
||||
let has_direct = sel.anchors.contains_key(id) || sel.segments.contains_key(id);
|
||||
if !has_direct {
|
||||
d.remove(*id)?;
|
||||
continue;
|
||||
}
|
||||
if node.path_data().is_none() {
|
||||
d.remove(*id)?;
|
||||
if !has_direct || node.path_data().is_none() {
|
||||
remove_leaving_no_empty_compound(d, *id)?;
|
||||
continue;
|
||||
}
|
||||
let original = super::path::deletion_source(d, *id)?;
|
||||
@@ -1063,19 +1073,17 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
let cuts = super::clipboard::subpath_selected_segments(&sp, si, seg_refs, anchor_refs);
|
||||
if cuts.is_empty() {
|
||||
subpaths.push(sp);
|
||||
} else {
|
||||
} else if seg_refs.is_some_and(|segments| !segments.is_empty()) {
|
||||
subpaths.extend(super::path::without_segments(sp, &cuts));
|
||||
} else {
|
||||
// Picked anchors leave the path with their connections, as Clear removes them.
|
||||
let gone: BTreeSet<usize> = anchor_refs.into_iter().flatten().filter_map(|&(s, a)| (s == si).then_some(a)).collect();
|
||||
subpaths.extend(super::path::without_anchors(sp, &gone));
|
||||
}
|
||||
}
|
||||
subpaths.retain(|sp| sp.anchors.len() >= 2);
|
||||
if subpaths.is_empty() {
|
||||
let parent = d.parent_of(*id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
d.remove(*id)?;
|
||||
if let Some(p) = parent
|
||||
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
|
||||
{
|
||||
d.remove(p)?;
|
||||
}
|
||||
remove_leaving_no_empty_compound(d, *id)?;
|
||||
} else {
|
||||
survivors.extend(super::path::replace_deleted_pieces(d, &mut pieces, original, subpaths)?);
|
||||
}
|
||||
@@ -1089,6 +1097,19 @@ fn cut_direct_selection(s: &mut Session, roots: &[NodeId]) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Remove object `id`, and the compound path that held it when it was the compound's last member.
|
||||
/// A compound left with one member stays a compound path, as after Clear.
|
||||
fn remove_leaving_no_empty_compound(d: &mut Document, id: NodeId) -> Result<()> {
|
||||
let parent = d.parent_of(id).filter(|p| matches!(d.node(*p).map(|n| &n.kind), Some(NodeKind::Compound { .. })));
|
||||
d.remove(id)?;
|
||||
if let Some(p) = parent
|
||||
&& d.node(p).is_some_and(|n| n.children().is_some_and(|c| c.is_empty()))
|
||||
{
|
||||
d.remove(p)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let guides = switch_or(p, "guides", false, "edit.clear")?;
|
||||
let explicit = if p.get("ids").is_some() { Some(checked_ids_param(s, p, "ids", "edit.clear")?) } else { None };
|
||||
@@ -1311,7 +1332,7 @@ fn paste_clip(s: &mut Session, p: &Value, mode: PasteMode, clip: &Clipboard, cho
|
||||
vec![Affine::IDENTITY]
|
||||
};
|
||||
let (ids, imported, artboards, into) = s.edit(mode.label(), |d, sel| {
|
||||
let imported = clip.import_into(d, choices, same_doc);
|
||||
let imported = clip.import_into(d, choices, same_doc)?;
|
||||
// Draw Inside: the clip group of the object drawn into, made on the first paste.
|
||||
let into = inside.map(|t| super::create::draw_inside_group(d, t)).transpose()?;
|
||||
let artboards: Vec<usize> = boards
|
||||
|
||||
@@ -87,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand Appearance",
|
||||
["Object"],
|
||||
None,
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
"{ids?|id?: [..], target?} turn each object's appearance into objects: every fill and stroke becomes an object of its own (a stroke stays a stroke, with its weight, dashes, arrowheads and width profile, on its own copy of the path, which object.expand or object.path.outlineStroke outline; brushed strokes become their brush art) grouped in paint order under the object's id and transparency, geometry effects are baked, raster effects become an embedded image (shadows and outer glows below the art; blur, feather and inner glow replace it; an object whose only raster effect is its own Rasterize becomes the image object.rasterize makes with the effect's options), type with effects or fills of its own is outlined; Crop Marks become a group of the object and its marks; Vector Halftone (addon.color.halftone) becomes a group of its dot paths, one per ink; a group's or layer's own fills and strokes become objects among its members, which are expanded too. An outlined frame of threaded text leaves its thread with the text it shows, and a frame kept as type in a group stays in its thread. A dimension whose art it changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked. Hidden fills, strokes and effects are dropped. In Safe Mode, an object with a visible plug-in effect (in it, or in a symbol it places) is an error and nothing changes. Enabled when a selected or targeted object's appearance isn't basic (`ids` then pick which objects) → {ids, warning? (an image in the art couldn't be decoded, as object.rasterize says)}",
|
||||
can_expand,
|
||||
expand_appearance
|
||||
),
|
||||
@@ -109,9 +109,155 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
move_effect
|
||||
),
|
||||
cmd!(
|
||||
query "effect.deferredStatus",
|
||||
"Deferred Effects",
|
||||
[],
|
||||
None,
|
||||
"{} the live-effect plug-in stages the active document left undrawn: while a document is drawn on screen, the plug-in effects of its first view share a 2,000 ms budget (each run also keeps to its own 5,000 ms limit); a stage left out for want of time, or one that times out, fails or has no plug-in, draws its input art unchanged and stays listed until it gives a result (Effect › Refresh Deferred Effects, an edit to its input or parameters, or an export, which runs every stage under the per-call limit), or until its object is deleted or its effect is removed, moved or hidden (or its fill or stroke is hidden) → {budgetMs: 2000, effects: [{objectId (also an object in the art of a symbol, pattern, brush or opacity mask), effectIndex (in the object's effects, or in the fill's or stroke's), item? (the fill or stroke whose effect it is), pluginId, reason: budget|timeout|missing|failed|safeMode}]} (empty: none)",
|
||||
has_doc,
|
||||
deferred_status
|
||||
),
|
||||
cmd!(
|
||||
"effect.refreshDeferred",
|
||||
"Refresh Deferred Effects",
|
||||
["Effect"],
|
||||
None,
|
||||
"{ids?: [object id] (objects of the document; `id` names one; default: every object with a deferred stage)} run the deferred live-effect plug-in stages of those objects again (effect.deferredStatus), sharing a fresh 2,000 ms budget; a stage that gives a result is drawn with it; not an undo step. Enabled when a stage is deferred → {refreshed, stillDeferred (left out for want of time again, or not reached because the object, or a group or layer holding it, is hidden, or with a part of its input too small for the refresh's drawing), failed (timed out, failed or has no plug-in)}",
|
||||
has_deferred,
|
||||
refresh_deferred
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// The active document has deferred live-effect plug-in stages ([`deferred_status`]).
|
||||
fn has_deferred(s: &Session) -> std::result::Result<(), String> {
|
||||
let st = s.active().ok_or("no document open")?;
|
||||
if deferred_effects(&st.doc, &st.plugin_effects).is_empty() { Err("no plug-in effect is deferred".into()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// The objects the deferred stages of `fx` name, by id, each with whether it is in `d`'s layers.
|
||||
/// An object that is not is looked for in the art `d` draws from elsewhere, where live-effect
|
||||
/// plug-ins run too: the art of its symbols, patterns and brushes, and opacity masks.
|
||||
fn stage_objects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let mut out = std::collections::HashMap::new();
|
||||
let mut rest = Vec::new();
|
||||
for object in fx.list().into_iter().map(|x| x.stage.object) {
|
||||
match d.node_arc(NodeId(object)) {
|
||||
Some(n) => {
|
||||
out.insert(object, (n.clone(), true));
|
||||
}
|
||||
None => rest.push(object),
|
||||
}
|
||||
}
|
||||
if rest.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let symbols = d.symbols.iter().map(|s| &*s.art);
|
||||
let patterns = d.patterns.iter().flat_map(|p| p.art.iter().map(|a| &**a));
|
||||
let layers = d.layers.iter().map(|a| &**a);
|
||||
let mut look = |n: &Node| {
|
||||
if rest.contains(&n.id.0) {
|
||||
out.entry(n.id.0).or_insert_with(|| (Arc::new(n.clone()), false));
|
||||
}
|
||||
};
|
||||
for a in symbols.chain(patterns).chain(brushes.iter().flat_map(|b| b.art())).chain(layers) {
|
||||
vectorcraft_doc::swatches::walk_with_masks(a, &mut look);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Is plug-in `plugin`'s effect at `stage`'s place in `n` (`None`: no such object), and visible,
|
||||
/// on a visible fill or stroke when it is one's?
|
||||
fn stage_holds(n: Option<&Node>, stage: vectorcraft_plugins::deferred::Stage, plugin: &str) -> bool {
|
||||
let Some(n) = n else { return false };
|
||||
let list = match stage.item {
|
||||
None => Some(n.appearance.effects.as_slice()),
|
||||
Some(i) => n.appearance.items.get(i).filter(|it| it.visible()).map(|it| it.effects().as_slice()),
|
||||
};
|
||||
list.and_then(|l| l.get(stage.effect)).is_some_and(|e| e.visible && e.id == vectorcraft_plugins::effect::effect_id(plugin))
|
||||
}
|
||||
|
||||
/// Drops the records of `fx`'s stages whose object or plug-in effect is no longer at their place
|
||||
/// in `d` ([`stage_holds`]), and gives the objects of the others ([`stage_objects`]).
|
||||
fn prune(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> std::collections::HashMap<u64, (Arc<Node>, bool)> {
|
||||
let objects = stage_objects(d, fx);
|
||||
fx.retain(|stage, plugin| stage_holds(objects.get(&stage.object).map(|(n, _)| &**n), stage, plugin));
|
||||
objects
|
||||
}
|
||||
|
||||
/// The deferred stages of `d` as `effect.deferredStatus` lists them. The records of stages whose
|
||||
/// object or plug-in effect is no longer at their place in `d` are dropped first.
|
||||
fn deferred_effects(d: &Document, fx: &vectorcraft_plugins::deferred::Deferrals) -> Vec<Value> {
|
||||
prune(d, fx);
|
||||
fx.list()
|
||||
.into_iter()
|
||||
.map(|x| {
|
||||
let mut v = json!({"objectId": x.stage.object, "effectIndex": x.stage.effect, "pluginId": x.plugin, "reason": x.reason.name()});
|
||||
if let Some(i) = x.stage.item {
|
||||
v["item"] = json!(i);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `effect.deferredStatus`.
|
||||
fn deferred_status(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"budgetMs": vectorcraft_plugins::deferred::OPENING_BUDGET_MS, "effects": deferred_effects(&st.doc, &st.plugin_effects)}))
|
||||
}
|
||||
|
||||
/// Draws `n` alone, small, as the canvas would (its live-effect plug-ins run inside the view that
|
||||
/// applies): what matters is that it is drawn, not its pixels. Is every part of it drawn, none left
|
||||
/// out for its size?
|
||||
fn draw_small(renderer: &mut vectorcraft_render::Renderer, doc: &Document, n: &Arc<Node>) -> bool {
|
||||
let Some(b) = n.geometric_bounds() else { return false };
|
||||
let scale = 64.0 / b.width().max(b.height()).max(1.0);
|
||||
let view = vectorcraft_geom::Affine::scale(scale) * vectorcraft_geom::Affine::translate((1.0 - b.x0, 1.0 - b.y0));
|
||||
renderer.stats = Default::default();
|
||||
renderer.render_node(doc, n, 66, 66, view);
|
||||
renderer.stats.culled == 0
|
||||
}
|
||||
|
||||
/// `effect.refreshDeferred`: each object of the document's layers is drawn again alone, small,
|
||||
/// inside the document's view, so its stages are run with the input they were deferred with; a
|
||||
/// hidden object, or one in a hidden layer or group, is not drawn. An object in the art of a
|
||||
/// symbol, pattern, brush or opacity mask runs where that art is used: the visible layers are
|
||||
/// drawn then. A stage input left out of a small drawing for its size stays deferred.
|
||||
fn refresh_deferred(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let (doc, fx) = (st.doc.clone(), st.plugin_effects.clone());
|
||||
let ids = super::named_ids(p)?;
|
||||
let objects = prune(&doc, &fx);
|
||||
if let Some(id) = ids.iter().flatten().find(|id| doc.node(**id).is_none() && !objects.contains_key(&id.0)) {
|
||||
return Err(EngineError::NoNode(*id));
|
||||
}
|
||||
let ids: Option<Vec<u64>> = ids.map(|ids| ids.iter().map(|id| id.0).collect());
|
||||
let counts = fx.refresh(ids.as_deref(), |targets| {
|
||||
let mut renderer = vectorcraft_render::Renderer::new();
|
||||
let mut whole = Vec::new();
|
||||
fx.view(|| {
|
||||
let mut elsewhere = false;
|
||||
for id in targets {
|
||||
match objects.get(id) {
|
||||
Some((n, true)) if doc.is_visible(NodeId(*id)) && draw_small(&mut renderer, &doc, n) => whole.push(*id),
|
||||
Some((_, false)) => elsewhere = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if elsewhere {
|
||||
for layer in doc.layers.iter().filter(|l| l.visible) {
|
||||
draw_small(&mut renderer, &doc, layer);
|
||||
}
|
||||
}
|
||||
});
|
||||
whole
|
||||
});
|
||||
Ok(json!({"refreshed": counts.refreshed, "stillDeferred": counts.still_deferred, "failed": counts.failed}))
|
||||
}
|
||||
|
||||
fn ids_json(ids: &[NodeId]) -> Value {
|
||||
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
|
||||
}
|
||||
@@ -280,9 +426,14 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
if effects::is_three_d(id) {
|
||||
for target in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
effects::validate_three_d(n, id, &effect.params).map_err(|e| bad(C, e))?;
|
||||
let targets = appearance_targets(s, p)?;
|
||||
for (target, at) in targets.iter().copied().zip(item.per_target(s, &targets)) {
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(target).ok_or(EngineError::NoNode(target))?;
|
||||
// A new effect goes after the item's others.
|
||||
let on = at.and_then(|at| at.effects_item(&n.appearance, C).ok()).flatten();
|
||||
let on = on.and_then(|i| Some((i, n.appearance.items.get(i)?.effects().len())));
|
||||
effects::validate_three_d_at_in(Some(d), n, id, &effect.params, on).map_err(|e| bad(C, e))?;
|
||||
if id == effects::REVOLVE && effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
@@ -571,7 +722,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
params["name"] = json!(svg_filter_name(s, xml, C)?);
|
||||
}
|
||||
for &(id, target, index) in &targets {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let d = &s.doc()?.doc;
|
||||
let n = d.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let effect = n.appearance.effects_at(target).and_then(|fx| fx.get(index));
|
||||
if warp_id.is_some() && effect.is_some_and(|e| e.id.strip_prefix("warp.").and_then(effects::WarpStyle::from_id).is_none()) {
|
||||
return Err(bad(C, "warpStyle can only edit an applied Warp effect"));
|
||||
@@ -589,7 +741,7 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
cur.extend(new);
|
||||
}
|
||||
if visible != Some(false) {
|
||||
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
|
||||
effects::validate_three_d_at_in(Some(d), n, to, &prospective, target.map(|item| (item, index))).map_err(|e| bad(C, e))?;
|
||||
} else {
|
||||
// Hidden, the effect draws nothing, so its object need not fit. The values given are
|
||||
// checked, and after a change of 3D type so is every value it keeps.
|
||||
@@ -668,11 +820,19 @@ fn load_texture(params: &mut Value, cmd: &str) -> Result<()> {
|
||||
/// fills, strokes or effects of its own or such members, other objects with effects, or type with
|
||||
/// fills or strokes of its own?
|
||||
pub(crate) fn expandable(n: &Node) -> bool {
|
||||
expandable_except(n, &[])
|
||||
}
|
||||
|
||||
/// [`expandable`], leaving out the objects `skip` lists and what they hold.
|
||||
pub(crate) fn expandable_except(n: &Node, skip: &[NodeId]) -> bool {
|
||||
if skip.contains(&n.id) {
|
||||
return false;
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Path { clipping: true, .. } | NodeKind::Path { guide: true, .. } => false,
|
||||
NodeKind::Path { .. } | NodeKind::Compound { .. } => !n.appearance.is_basic() || vectorcraft_brush::has_brush(n),
|
||||
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => {
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable(c))
|
||||
!n.appearance.items.is_empty() || !n.appearance.effects.is_empty() || children.iter().any(|c| expandable_except(c, skip))
|
||||
}
|
||||
NodeKind::Text(_) => !n.appearance.items.is_empty() || !n.appearance.effects.is_empty(),
|
||||
NodeKind::Image(_) => effects::has_item_geometry(n) || !n.appearance.effects.is_empty(),
|
||||
@@ -682,9 +842,11 @@ pub(crate) fn expandable(n: &Node) -> bool {
|
||||
|
||||
fn can_expand(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
let ids = appearance_targets(s, &Value::Null).map_err(|e| e.to_string())?;
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(expandable) {
|
||||
let d = &s.doc().map_err(|e| e.to_string())?.doc;
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(d, &ids);
|
||||
if ids.iter().filter_map(|id| d.node(*id)).any(|n| expandable_except(n, &kept)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select (or target) objects whose appearance isn't basic".into())
|
||||
@@ -808,8 +970,15 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
let mut changed = false;
|
||||
// Is `n` in `out` yet?
|
||||
let mut listed_n = false;
|
||||
// A symbol instance's fill tints its art before the adjustment, as the canvas draws it.
|
||||
let symbol_art = match &n.kind {
|
||||
NodeKind::SymbolInstance { symbol, .. } if effects::has_adjustment(&n) => {
|
||||
d.symbols.iter().find(|s| s.name == *symbol).map(|s| vectorcraft_render::instance_art(&s.art, &n))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if effects::has_adjustment(&n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n)
|
||||
&& let Some(m) = effects::adjust_in_document(d, &n, symbol_art)
|
||||
{
|
||||
out.push((id, 0));
|
||||
listed_n = true;
|
||||
@@ -865,8 +1034,8 @@ fn expanded_node(d: &mut Document, mut n: Node, brushes: &[vectorcraft_brush::Br
|
||||
// keeps it as its only member).
|
||||
Some((image, false)) => Some(rasterfx::replace_with_image(v, image)),
|
||||
image => {
|
||||
let m = if container && effects::has_solid(&v) {
|
||||
// A group's or layer's 3D effect: the shaded faces of its members' solids.
|
||||
let m = if (container || matches!(n.kind, NodeKind::Text(_))) && (effects::has_solid(&v) || effects::has_revolve(&v)) {
|
||||
// A 3D effect on a group or layer, or on type: the shaded faces of its solids.
|
||||
effects::expand_leaf(d, &v, &mut stroke_art)
|
||||
} else if container {
|
||||
// A group's or layer's own fills and strokes become art among its members.
|
||||
@@ -962,7 +1131,8 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// In Safe Mode a plug-in effect draws nothing: expanding it would drop the effect and keep the
|
||||
// geometry it hasn't changed.
|
||||
if vectorcraft_plugins::safe_mode::active() {
|
||||
let d = &s.doc()?.doc;
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let d = &*super::dimension::without_kept(&s.doc()?.doc, &roots);
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
if roots.iter().filter_map(|id| d.node(*id)).any(|n| holds_plugin_effect(d, n, false, &mut seen)) {
|
||||
return Err(EngineError::Other("Expand Appearance: a plug-in effect in the selection can't be expanded in Safe Mode".into()));
|
||||
@@ -972,20 +1142,24 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let colorized_ok = super::paint::check_colorized(&s.doc()?.doc).is_ok();
|
||||
let ids = s.edit("Expand Appearance", |d, _| {
|
||||
let before = d.clone();
|
||||
let mut ended = super::dimension::end_links(d, &roots);
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out
|
||||
// of the document while the art expands.
|
||||
let lifted = super::dimension::lift(d, &roots);
|
||||
let kept = !lifted.is_empty();
|
||||
let ended = super::dimension::end_links(d, &roots);
|
||||
let brushes = vectorcraft_brush::library(d);
|
||||
let mut out = vec![];
|
||||
let threaded = super::threads::frames(d);
|
||||
for id in &roots {
|
||||
expand_node(d, *id, &brushes, &mut out);
|
||||
}
|
||||
// A dimension whose source was replaced stays plain: its link no longer fits that art.
|
||||
ended.retain(|e| e.source_kept(&before, d));
|
||||
super::dimension::restore_unchanged(d, ended);
|
||||
super::dimension::put_back(d, lifted);
|
||||
// A dimension whose source was replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
// Outlined frames of threaded text leave their threads with the text they show; type that
|
||||
// stays type under a new id takes its frame's place.
|
||||
super::threads::leave_threads(d, &threaded);
|
||||
if out.is_empty() {
|
||||
if out.is_empty() || (kept && d.layers == before.layers) {
|
||||
return Err(EngineError::Other("Expand Appearance: the selection has a basic appearance".into()));
|
||||
}
|
||||
super::paint::check_colorized_kept(colorized_ok, d).map_err(|e| bad("Expand Appearance", e))?;
|
||||
@@ -1002,6 +1176,9 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
mod lighting_presets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod three_d_source_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod three_d_type_tests;
|
||||
|
||||
|
||||
@@ -130,7 +130,8 @@ fn solid_effects_apply_save_edit_export_expand_and_undo() {
|
||||
}
|
||||
|
||||
/// #4506: a 3D effect on a group builds a solid from each member, turned about one shared axis;
|
||||
/// the members stay editable, and a group holding what can't become a solid is refused.
|
||||
/// the members stay editable. A group holding a clipping group builds from the art it shows, and a
|
||||
/// group holding what can't become a solid is refused.
|
||||
#[test]
|
||||
fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||||
for effect in effects::SOLIDS {
|
||||
@@ -151,17 +152,59 @@ fn solid_effects_apply_to_a_group_and_its_members_stay_editable() {
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
|
||||
assert!(!effects::has_solid(&node(&s, g)), "{effect}: expanded");
|
||||
}
|
||||
// A clipping group in the group: refused, nothing changed.
|
||||
// A clipping group in the group: each effect builds from the art it shows.
|
||||
for effect in effects::SOLIDS {
|
||||
let (mut s, _, g) = group_with_a_clipping_group();
|
||||
s.execute("effect.apply", &json!({"effect": effect, "ids": [g.0], "item": null})).unwrap_or_else(|e| panic!("{effect}: {e}"));
|
||||
let n = node(&s, g);
|
||||
assert_eq!(n.children().unwrap().len(), 2, "{effect}: the members stay as they were");
|
||||
let art = effects::solid_art_in(Some(&s.doc().unwrap().doc), &n);
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{effect}: draws its solid");
|
||||
}
|
||||
// An image in the group: refused, nothing changed.
|
||||
let (mut s, _, g) = group_with_an_image();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Image Trace"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
|
||||
/// A group of a clipping group (a rectangle clipped by an ellipse) and a rectangle: the session,
|
||||
/// the clipping group and the group.
|
||||
fn group_with_a_clipping_group() -> (Session, NodeId, NodeId) {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.ellipse", &json!({"x": 120, "y": 120, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let clip = s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].clone();
|
||||
let clip = NodeId(s.execute("object.clippingMask.make", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let c = s.execute("shape.rectangle", &json!({"x": 250, "y": 250, "width": 50, "height": 50})).unwrap()["id"].clone();
|
||||
s.execute("select.set", &json!({"ids": [clip, c]})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [clip.0, c]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("clipping groups"), "{err}");
|
||||
assert!(node(&s, g).appearance.effects.is_empty());
|
||||
(s, clip, g)
|
||||
}
|
||||
|
||||
/// A group of a rectangle and an image: the session, the image and the group.
|
||||
fn group_with_an_image() -> (Session, NodeId, NodeId) {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let image = s
|
||||
.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let img = vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::translate((250.0, 250.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let (parent, index, _) = d.position(a).unwrap();
|
||||
d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, image.0]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
(s, image, g)
|
||||
}
|
||||
|
||||
/// #4715: type extrudes as its glyph outlines, painted with its characters' colour, and stays
|
||||
@@ -193,6 +236,173 @@ fn type_extrudes_as_its_glyphs_and_stays_editable() {
|
||||
assert_ne!(effects::solid_art(&node(&s, id)).unwrap(), art, "the solid follows the edited text");
|
||||
}
|
||||
|
||||
/// #3617: Revolve takes live type and stays type, updating when the text changes, and saves,
|
||||
/// exports, expands and undoes as one step.
|
||||
#[test]
|
||||
fn type_revolves_as_its_glyphs_and_stays_editable() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": "O", "size": 72})).unwrap();
|
||||
let id = NodeId(t["id"].as_u64().unwrap());
|
||||
s.execute("paint.setFill", &json!({"ids": [id.0], "color": "#cc2200"})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null, "params": {"segments": 16}})).unwrap();
|
||||
let n = node(&s, id);
|
||||
assert!(matches!(n.kind, NodeKind::Text(_)), "still type");
|
||||
let art = effects::revolve_art(&n).expect("a surface of the glyphs");
|
||||
let mut tinted = false;
|
||||
art.walk(&mut |c| {
|
||||
if let Some([r, g, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
|
||||
tinted |= r > g && r > b;
|
||||
}
|
||||
});
|
||||
assert!(tinted, "shaded from the characters' red");
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), art, "the same art after a save");
|
||||
let meshes = effects::three_d_meshes(&n).unwrap();
|
||||
assert_eq!(meshes.len(), 1, "one mesh of every contour of the type");
|
||||
s.execute("text.setText", &json!({"ids": [id.0], "text": "OO"})).unwrap();
|
||||
assert_ne!(effects::revolve_art(&node(&s, id)).unwrap(), art, "the surface follows the edited text");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(!effects::has_revolve(&node(&s, id)), "expanded");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Text(_)));
|
||||
assert!(effects::has_revolve(&node(&s, id)));
|
||||
// Extrude & Bevel on type expands the same way.
|
||||
s.execute("effect.remove", &json!({"index": 0, "ids": [id.0], "item": null})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.extrude", "ids": [id.0], "item": null})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
|
||||
assert!(!effects::has_solid(&node(&s, id)), "expanded");
|
||||
}
|
||||
|
||||
/// Revolve refuses type with no outlines, and a group with no visible shape, without adding an
|
||||
/// effect. Extrude & Bevel gives the same reason for empty type.
|
||||
#[test]
|
||||
fn revolve_refuses_type_and_groups_without_a_shape() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
for text in ["", " "] {
|
||||
let t = s.execute("text.create", &json!({"x": 50, "y": 100, "text": text, "size": 72})).unwrap();
|
||||
let id = NodeId(t["id"].as_u64().unwrap());
|
||||
for effect in ["threeD.revolve", "threeD.extrude"] {
|
||||
let err = s.execute("effect.apply", &json!({"effect": effect, "ids": [id.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("at least two anchors"), "{effect}, {text:?}: {err}");
|
||||
assert!(node(&s, id).appearance.effects.is_empty());
|
||||
}
|
||||
}
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 300, "y": 300, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
for member in [a.0, b] {
|
||||
s.execute("select.set", &json!({"ids": [member]})).unwrap();
|
||||
s.execute("object.hide", &json!({})).unwrap();
|
||||
}
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("group holding a shape"), "{err}");
|
||||
assert!(node(&s, g).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
/// A Revolve on one fill is checked in that fill's paint, and `effect.setParams` on it as well.
|
||||
#[test]
|
||||
fn revolve_on_a_fill_is_checked_in_that_fills_paint() {
|
||||
use vectorcraft_color::{Color, Gradient, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer};
|
||||
let (mut s, id) = session_with_rect();
|
||||
// The paint copies of 100 faces reach the 256 MiB budget: 8 segments stay under, 64 pass it.
|
||||
let count = (256 << 20) / (4 * 100 * std::mem::size_of::<GradientStop>());
|
||||
let stops = (0..count).map(|i| GradientStop::new(i as f32 / count as f32, Color::rgb(1.0, 0.0, 0.0))).collect();
|
||||
let ramp = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops))));
|
||||
s.edit("fills", |d, _| {
|
||||
let n = d.node_mut(id).unwrap();
|
||||
n.appearance.items =
|
||||
vec![AppearanceItem::Fill(FillLayer::new(ramp)), AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(0.0, 0.0, 1.0))))];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0})).unwrap_err().to_string();
|
||||
assert!(err.contains("simplify the gradient"), "{err}");
|
||||
assert!(node(&s, id).appearance.items[0].effects().is_empty());
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": 0, "params": {"segments": 8}})).unwrap();
|
||||
let err = s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": 0, "params": {"segments": 128}})).unwrap_err().to_string();
|
||||
assert!(err.contains("simplify the gradient"), "{err}");
|
||||
}
|
||||
|
||||
/// A hidden Revolve still counts as the object's one Revolve: a second one is refused, and the
|
||||
/// hidden one can be shown again over the geometry effects after it.
|
||||
#[test]
|
||||
fn a_second_revolve_is_refused_over_a_hidden_one() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": false})).unwrap();
|
||||
s.execute("effect.apply", &json!({"effect": "distort.roughen", "ids": [id.0], "item": null})).unwrap();
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [id.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("one Revolve effect"), "{err}");
|
||||
let ids: Vec<_> = node(&s, id).appearance.effects.iter().map(|e| e.id.clone()).collect();
|
||||
assert_eq!(ids, ["threeD.revolve", "distort.roughen"]);
|
||||
s.execute("effect.setParams", &json!({"index": 0, "ids": [id.0], "item": null, "visible": true})).unwrap();
|
||||
assert!(node(&s, id).appearance.effects[0].visible);
|
||||
}
|
||||
|
||||
/// Revolve on a group sweeps every member about one axis on the left edge of their bounds, keeps
|
||||
/// each member's paint, follows an edited member, expands and undoes as one step.
|
||||
#[test]
|
||||
fn revolve_on_a_group_shares_one_axis_and_members_stay_editable() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
s.execute("paint.setFill", &json!({"ids": [a.0], "color": "#cc2200"})).unwrap();
|
||||
let b = s.execute("shape.rectangle", &json!({"x": 250, "y": 100, "width": 50, "height": 100})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("paint.setFill", &json!({"ids": [b], "color": "#0022cc"})).unwrap();
|
||||
s.execute("select.set", &json!({"ids": [a.0, b]})).unwrap();
|
||||
let g = NodeId(s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [g.0], "item": null, "params": {"segments": 16, "rotationX": 90}})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 2, "the members stay as they were");
|
||||
let art = effects::revolve_art(&node(&s, g)).expect("a surface of the members");
|
||||
// The axis is the left edge of both members together (x = 100), so the right member's far
|
||||
// wall sweeps out to 200 pt from it on either side: x = 300 and x = -100.
|
||||
let bounds = art.visual_bounds().unwrap();
|
||||
assert!((bounds.x1 - 300.0).abs() < 2.0 && (bounds.x0 + 100.0).abs() < 2.0, "{bounds:?}");
|
||||
let (mut red, mut blue) = (false, false);
|
||||
art.walk(&mut |c| {
|
||||
if let Some([r, _, b, _]) = c.appearance.fill_paint().color().map(|c| c.to_rgba8(1.0)) {
|
||||
red |= r > b;
|
||||
blue |= b > r;
|
||||
}
|
||||
});
|
||||
assert!(red && blue, "each member keeps its own paint");
|
||||
assert_eq!(effects::three_d_meshes(&node(&s, g)).unwrap().len(), 2, "a mesh for each member");
|
||||
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());
|
||||
assert!(vectorcraft_testkit::svg_shapes(&svg) >= 4, "both members exported as vector faces");
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
s.execute("object.move", &json!({"dx": -40, "dy": 0})).unwrap();
|
||||
assert_ne!(effects::revolve_art(&node(&s, g)).unwrap(), art, "a moved member reshapes the surface");
|
||||
s.execute("effect.expandAppearance", &json!({"ids": [g.0]})).unwrap();
|
||||
assert!(!effects::has_revolve(&node(&s, g)), "expanded");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(effects::has_revolve(&node(&s, g)));
|
||||
}
|
||||
|
||||
/// A group holding what can't be swept is refused as a whole, and an image is named. A clipping
|
||||
/// group, alone or in a group, sweeps the art it shows.
|
||||
#[test]
|
||||
fn revolve_refuses_a_group_with_a_member_it_can_not_sweep() {
|
||||
let (mut s, image, g) = group_with_an_image();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for target in [image, g] {
|
||||
let err = s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Revolve") && err.contains("Image Trace"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
for clipping_group_itself in [true, false] {
|
||||
let (mut s, clip, g) = group_with_a_clipping_group();
|
||||
let target = if clipping_group_itself { clip } else { g };
|
||||
s.execute("effect.apply", &json!({"effect": "threeD.revolve", "ids": [target.0], "item": null}))
|
||||
.unwrap_or_else(|e| panic!("{clipping_group_itself}: {e}"));
|
||||
let art = effects::revolve_art_in(Some(&s.doc().unwrap().doc), &node(&s, target));
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{clipping_group_itself}: draws its surface");
|
||||
}
|
||||
}
|
||||
|
||||
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
|
||||
/// stored as the angle that shows the same view, and a later angle replaces an earlier strength.
|
||||
#[test]
|
||||
@@ -581,7 +791,7 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
|
||||
let copies = n.children().unwrap();
|
||||
assert_eq!(copies.len(), 3, "the path and its two copies");
|
||||
let xs: Vec<f64> = copies.iter().map(|c| c.geometric_bounds().unwrap().x0).collect();
|
||||
assert_eq!(xs, vec![100.0, 250.0, 400.0]);
|
||||
assert_eq!(xs, vec![400.0, 250.0, 100.0], "the original on top");
|
||||
for c in copies {
|
||||
assert!(matches!(c.kind, NodeKind::Path { .. }), "{}", c.kind_label());
|
||||
assert_eq!(fill(c), Some(Color::rgb(1.0, 0.0, 0.0)));
|
||||
@@ -600,10 +810,10 @@ fn expand_appearance_makes_each_copy_of_a_transform_an_object() {
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let columns = node(&s, id).children().unwrap().to_vec();
|
||||
assert_eq!(columns.len(), 3);
|
||||
for (col, x) in columns.iter().zip([100.0, 250.0, 400.0]) {
|
||||
for (col, x) in columns.iter().zip([400.0, 250.0, 100.0]) {
|
||||
let cells = col.children().unwrap_or_else(|| panic!("a column is a group: {}", col.kind_label()));
|
||||
let ys: Vec<(f64, f64)> = cells.iter().map(|c| (c.geometric_bounds().unwrap().x0, c.geometric_bounds().unwrap().y0)).collect();
|
||||
assert_eq!(ys, vec![(x, 100.0), (x, 250.0)]);
|
||||
assert_eq!(ys, vec![(x, 250.0), (x, 100.0)]);
|
||||
assert!(cells.iter().all(|c| matches!(c.appearance.fill_paint(), Paint::Solid { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, NodeKind};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
const TYPES: [&str; 5] = ["threeD.extrude", "threeD.classicExtrude", "threeD.inflate", "threeD.rotate", "threeD.revolve"];
|
||||
|
||||
fn id_of(v: &Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
|
||||
id_of(&s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap())
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, ids: &[NodeId]) {
|
||||
s.execute("select.set", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap();
|
||||
}
|
||||
|
||||
/// A clipping group, a blend and a symbol instance, each made through its command.
|
||||
fn sources(s: &mut Session) -> Vec<(&'static str, NodeId)> {
|
||||
let art = rect(s, 20.0, 20.0, 60.0, 60.0);
|
||||
let clip = rect(s, 40.0, 10.0, 20.0, 80.0);
|
||||
select(s, &[art, clip]);
|
||||
s.execute("object.clippingMask.make", &json!({})).unwrap();
|
||||
let clipped = s.doc().unwrap().selection.objects[0];
|
||||
let a = rect(s, 120.0, 20.0, 20.0, 20.0);
|
||||
let b = rect(s, 200.0, 60.0, 30.0, 30.0);
|
||||
select(s, &[a, b]);
|
||||
let blend = id_of(&s.execute("object.blend.make", &json!({})).unwrap());
|
||||
let art = rect(s, 20.0, 200.0, 50.0, 40.0);
|
||||
let instance = id_of(&s.execute("symbol.new", &json!({"name": "Box", "ids": [art.0]})).unwrap());
|
||||
vec![("clipping group", clipped), ("blend", blend), ("symbol instance", instance)]
|
||||
}
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_three_d_type_applies_to_clipping_groups_blends_and_symbol_instances_in_one_undo_step() {
|
||||
for id in TYPES {
|
||||
let mut s = session();
|
||||
for (what, target) in sources(&mut s) {
|
||||
select(&mut s, &[target]);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_or_else(|e| panic!("{id} on a {what}: {e}"));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "{id} on a {what}");
|
||||
let n = s.doc().unwrap().doc.node(target).unwrap().clone();
|
||||
assert_eq!(n.appearance.effects.iter().filter(|e| e.id == id).count(), 1, "{id} on a {what}");
|
||||
// The object stays what it is, and the effect draws its solid.
|
||||
assert!(!matches!(n.kind, NodeKind::Path { .. }), "{id} on a {what}");
|
||||
let d = &s.doc().unwrap().doc;
|
||||
let art = vectorcraft_render::effects::revolve_art_in(Some(d), &n).or_else(|| vectorcraft_render::effects::solid_art_in(Some(d), &n));
|
||||
assert!(art.is_some_and(|a| a.visual_bounds().is_some()), "{id} on a {what} draws its solid");
|
||||
// Edited through the dialog's command too.
|
||||
s.execute("effect.setParams", &json!({"index": 0, "item": null, "params": {"rotationY": 20.0}}))
|
||||
.unwrap_or_else(|e| panic!("{id} on a {what}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_group_holding_an_image_refuses_every_three_d_type_and_names_image_trace() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 20.0, 20.0, 60.0, 60.0);
|
||||
let image = s
|
||||
.edit("Place", |d, _| {
|
||||
let id = d.alloc_id();
|
||||
let img = vectorcraft_doc::ImageObject {
|
||||
key: "k".into(),
|
||||
width: 4,
|
||||
height: 4,
|
||||
xf: vectorcraft_geom::Affine::translate((100.0, 100.0)),
|
||||
link: None,
|
||||
placement: Default::default(),
|
||||
pixels: None,
|
||||
};
|
||||
let (parent, index, _) = d.position(a).unwrap();
|
||||
d.insert(parent, index + 1, vectorcraft_doc::Node::new(id, NodeKind::Image(Box::new(img))))?;
|
||||
Ok(id)
|
||||
})
|
||||
.unwrap();
|
||||
select(&mut s, &[a, image]);
|
||||
s.execute("object.group", &json!({})).unwrap();
|
||||
for target in [image, s.doc().unwrap().selection.objects[0]] {
|
||||
select(&mut s, &[target]);
|
||||
for id in TYPES {
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let err = s.execute("effect.apply", &json!({"effect": id, "item": null})).unwrap_err().to_string();
|
||||
assert!(err.contains("Object › Image Trace › Make"), "{id}: {err}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "{id}: nothing changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,8 @@
|
||||
//! gradient mesh, or solid strips / concentric ellipses, inside a clip group shaped like the object; a freeform
|
||||
//! gradient becomes a mesh shaped like the object. A pattern fill becomes the tiles it paints inside a clip
|
||||
//! group shaped like the object. Create Gradient Mesh shares the sampling. A dimension whose art
|
||||
//! Expand changes becomes a plain group of that art.
|
||||
//! Expand changes becomes a plain group of that art, except one selected with art it measures,
|
||||
//! which Expand leaves as it is.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
@@ -25,7 +26,7 @@ use super::edit::selected_roots;
|
||||
use super::menucmds::{run_raw, squash};
|
||||
use super::object::as_clipping_path;
|
||||
use super::pathops::{assemble, node_path, outline_strokes, replace_leaves, styled_copy, with_transparency};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, cmd, f64_or, has_selection, str_param};
|
||||
use super::{CommandSpec, EngineError, Result, Session, bad, cmd, has_selection, number_or, switch_or, text_param};
|
||||
|
||||
const C: &str = "object.expand";
|
||||
/// Expand Gradient To → Specify … Objects: the default and the most.
|
||||
@@ -43,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Expand…",
|
||||
["Object"],
|
||||
None,
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes, or one selected with the art it measures when that art is replaced, becomes a plain group of its art → {ids}",
|
||||
"{object?: true (symbol instances without effects (their fills' and strokes' included) and not in perspective → their art, as Break Link to Symbol places it (the instances that art holds stay instances), type → outlines, live shapes → paths, effects baked, blends → groups of their steps, repeats → groups of their instances, Intertwine groups → groups of their objects and of the parts drawn over the others (clip groups), envelopes → their distorted content, placed documents → editable copies of their art, as placing the file without link gives, its symbols, patterns, swatches and images joining the document), fill?: true (gradient fills → `gradient` art, pattern fills → their tiles in a clip group shaped like the object), stroke?: true (strokes → filled outlines, the stroked paths that baking effects leaves included), gradient?: \"objects\" (default: `steps` solid strips, concentric ellipses for radial gradients) | \"mesh\" (a gradient mesh); both in a clip group shaped like the object (freeform gradients always become a mesh shaped like it), steps?: 1..1000 (255)} one undo step; a dimension whose art an option changes becomes a plain group of its art, except one selected with art it measures, directly or in groups without fills, strokes or effects of their own: its lines and label stay as they are, and if the new art no longer fits its link it stays where it was, no longer linked → {ids}",
|
||||
has_selection,
|
||||
expand
|
||||
),
|
||||
@@ -55,7 +56,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{} what each Expand option would change in the selection → {object, fill, stroke, gradient, appearance, symbolArt?: {fill, stroke, gradient}} (true: something to expand; gradient: a gradient fill that Fill expands, which Expand Gradient To applies to; appearance: appearance attributes Expand Appearance expands first, for which Object › Expand… is dimmed in the menus; symbolArt, when the selection holds symbol instances that Object expands: true when only their art holds it, which Fill and Stroke reach only with Object on)",
|
||||
has_selection,
|
||||
|s, _| {
|
||||
let e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
let roots = selected_roots(s)?;
|
||||
let e = Expandable::of(&super::dimension::without_kept(&s.doc()?.doc, &roots), &roots);
|
||||
let mut info =
|
||||
json!({"object": e.object(), "fill": e.fill, "stroke": e.stroke, "gradient": e.gradient, "appearance": has_appearance(s)});
|
||||
if e.symbols {
|
||||
@@ -74,7 +76,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
/// (#3089); `object.expand` itself still bakes them.
|
||||
pub fn has_appearance(s: &Session) -> bool {
|
||||
let (Ok(st), Ok(roots)) = (s.doc(), selected_roots(s)) else { return false };
|
||||
roots.iter().filter_map(|r| st.doc.node(*r)).any(super::effectcmd::expandable)
|
||||
// A dimension selected with the art it measures is left as it is.
|
||||
let kept = super::dimension::with_their_art(&st.doc, &roots);
|
||||
roots.iter().filter_map(|r| st.doc.node(*r)).any(|n| super::effectcmd::expandable_except(n, &kept))
|
||||
}
|
||||
|
||||
/// Every placed document in `roots` of `d` (at any depth) replaced by an editable copy of the art it
|
||||
@@ -101,11 +105,11 @@ enum GradientTo {
|
||||
|
||||
impl GradientTo {
|
||||
fn parse(p: &Value) -> Result<Self> {
|
||||
let steps = f64_or(p, "steps", DEFAULT_STEPS as f64).round();
|
||||
let steps = number_or(p, "steps", DEFAULT_STEPS as f64, C)?.round();
|
||||
if !(1.0..=MAX_STEPS as f64).contains(&steps) {
|
||||
return Err(bad(C, format!("steps must be 1..{MAX_STEPS}")));
|
||||
}
|
||||
match str_param(p, "gradient").unwrap_or("objects") {
|
||||
match text_param(p, "gradient", C)?.unwrap_or("objects") {
|
||||
"objects" => Ok(Self::Objects(steps as u32)),
|
||||
"mesh" => Ok(Self::Mesh),
|
||||
_ => Err(bad(C, "gradient must be \"objects\" or \"mesh\"")),
|
||||
@@ -236,14 +240,23 @@ fn expand(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let object = bool_or(p, "object", true);
|
||||
let fill = bool_or(p, "fill", true);
|
||||
let stroke = bool_or(p, "stroke", true);
|
||||
let object = switch_or(p, "object", true, C)?;
|
||||
let fill = switch_or(p, "fill", true, C)?;
|
||||
let stroke = switch_or(p, "stroke", true, C)?;
|
||||
let to = GradientTo::parse(p)?;
|
||||
let from = s.doc()?.history.undo.len();
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
// Before anything expands: outlining the art a dimension measures would remove the dimension.
|
||||
// Before anything expands: outlining the art a dimension measures would leave the dimension unlinked.
|
||||
let before = s.doc()?.doc.clone();
|
||||
let selected = s.doc()?.selection.objects.clone();
|
||||
// A dimension selected with the art it measures keeps its own lines and label: it is out of the
|
||||
// document while the art expands, so no step below reaches it.
|
||||
let roots = selected_roots(s)?;
|
||||
let mut lifted = vec![];
|
||||
if !super::dimension::with_their_art(&before, &roots).is_empty() {
|
||||
lifted = s.edit("Expand", |d, _| Ok(super::dimension::lift(d, &roots)))?;
|
||||
}
|
||||
let kept: Vec<NodeId> = lifted.iter().map(super::dimension::Lifted::id).collect();
|
||||
let mut e = Expandable::of(&s.doc()?.doc, &selected_roots(s)?);
|
||||
let mut ended = vec![];
|
||||
end_dimension_links(s, &mut ended)?;
|
||||
let rev0 = s.doc()?.revision;
|
||||
@@ -339,14 +352,25 @@ fn expand_steps(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc()?.revision == rev0 {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
if !ended.is_empty() {
|
||||
// A dimension whose source Expand replaced stays plain: its link no longer fits that art.
|
||||
let after = s.doc()?.doc.clone();
|
||||
ended.retain(|e| e.source_kept(&before, &after));
|
||||
s.edit("Expand", |d, _| {
|
||||
super::dimension::restore_unchanged(d, std::mem::take(&mut ended));
|
||||
if !ended.is_empty() || !lifted.is_empty() {
|
||||
s.edit("Expand", |d, sel| {
|
||||
if !lifted.is_empty() {
|
||||
super::dimension::put_back(d, lifted);
|
||||
// The dimensions put back follow what Expand made of the art they measure: a
|
||||
// dimension whose art no longer fits its link stays where it was, no longer linked.
|
||||
super::dimension::refresh(&before, d)?;
|
||||
let mut ids = sel.objects.clone();
|
||||
let again: Vec<NodeId> = kept.iter().copied().filter(|id| selected.contains(id) && !ids.contains(id)).collect();
|
||||
ids.extend(again);
|
||||
sel.set(ids);
|
||||
}
|
||||
// A dimension whose source Expand replaced stays where it was, no longer linked.
|
||||
super::dimension::restore_unchanged(&before, d, ended);
|
||||
Ok(())
|
||||
})?;
|
||||
if !kept.is_empty() && s.doc()?.doc.layers == before.layers {
|
||||
return Err(EngineError::Other("Expand: nothing to expand".into()));
|
||||
}
|
||||
}
|
||||
squash(s, from, "Expand");
|
||||
Ok(json!({ "ids": s.doc()?.selection.objects.iter().map(|i| i.0).collect::<Vec<_>>() }))
|
||||
|
||||
@@ -184,6 +184,8 @@ mod tests_batch;
|
||||
#[cfg(test)]
|
||||
mod tests_screens;
|
||||
#[cfg(test)]
|
||||
mod tests_selexport;
|
||||
#[cfg(test)]
|
||||
mod tests_text;
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user