M2/M8/M9/M4.4: drawing + transform tools, live effects, PDF export/import, file/batch commands; UI restyled to measured Illustrator metrics (Medium Dark, categorized toolbar, hint bar, task bar, Layers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-09-30 06:55:49 -04:00
parent d0007a604d
commit 6d21d36d16
64 changed files with 11320 additions and 867 deletions

324
Cargo.lock generated
View File

@ -705,6 +705,15 @@ dependencies = [
"libc",
]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "crc32fast"
version = "1.5.2"
@ -914,9 +923,17 @@ dependencies = [
name = "drawcraft-pdf"
version = "0.1.0"
dependencies = [
"drawcraft-color",
"drawcraft-doc",
"drawcraft-geom",
"drawcraft-text",
"hayro-interpret",
"hayro-syntax",
"image",
"krilla",
"kurbo 0.13.1",
"log",
"smallvec",
"thiserror 2.0.21",
]
@ -926,10 +943,13 @@ version = "0.1.0"
dependencies = [
"drawcraft-color",
"drawcraft-doc",
"drawcraft-effects",
"drawcraft-geom",
"drawcraft-text",
"image",
"log",
"serde_json",
"vello_common 0.2.0",
"vello_cpu 0.2.0",
]
@ -1224,7 +1244,7 @@ dependencies = [
"ecolor",
"emath",
"epaint_default_fonts",
"font-types",
"font-types 0.12.5",
"harfrust 0.12.0",
"log",
"nohash-hasher",
@ -1344,6 +1364,9 @@ name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
dependencies = [
"num-traits",
]
[[package]]
name = "fnv"
@ -1357,6 +1380,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "font-types"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
dependencies = [
"bytemuck",
]
[[package]]
name = "font-types"
version = "0.12.5"
@ -1715,6 +1747,81 @@ dependencies = [
"foldhash",
]
[[package]]
name = "hayro-ccitt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a"
[[package]]
name = "hayro-cmap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d285dc30731c8485de5fa732fbdf2b3affdf01e4da7c2135022ca6fa664bf6"
dependencies = [
"hayro-postscript",
]
[[package]]
name = "hayro-interpret"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2613d0406898995042d0794c4245b2f2fba1246490c8ac593769bba6551129d"
dependencies = [
"bitflags 2.13.2",
"hayro-cmap",
"hayro-syntax",
"kurbo 0.13.1",
"moxcms",
"phf",
"rustc-hash 2.1.3",
"siphasher",
"skrifa 0.42.1",
"smallvec",
"yoke",
]
[[package]]
name = "hayro-jbig2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d"
dependencies = [
"fearless_simd",
"hayro-ccitt",
]
[[package]]
name = "hayro-jpeg2000"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
dependencies = [
"fearless_simd",
]
[[package]]
name = "hayro-postscript"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "885c5ef0654933139a9b9546fc2c69e18d37f38aa2520f079092b1be18f1fcaa"
[[package]]
name = "hayro-syntax"
version = "0.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
dependencies = [
"flate2",
"hayro-ccitt",
"hayro-jbig2",
"hayro-jpeg2000",
"memchr",
"rustc-hash 2.1.3",
"smallvec",
"zune-jpeg",
]
[[package]]
name = "hermit-abi"
version = "0.5.3"
@ -1762,6 +1869,12 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285"
[[package]]
name = "imagesize"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c"
[[package]]
name = "imagesize"
version = "0.15.0"
@ -1889,6 +2002,34 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
dependencies = [
"base64 0.22.1",
"bumpalo",
"flate2",
"float-cmp",
"gif",
"image-webp",
"imagesize 0.14.0",
"indexmap",
"pdf-writer",
"png 0.18.1",
"rustc-hash 2.1.3",
"rustybuzz",
"siphasher",
"skrifa 0.42.1",
"smallvec",
"subsetter",
"tiny-skia-path 0.12.0",
"xmp-writer",
"yoke",
"zune-jpeg",
]
[[package]]
name = "kurbo"
version = "0.11.3"
@ -2534,6 +2675,18 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pdf-writer"
version = "0.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5e456864a7a304047bff84977dc6fb162bd956475d40ba50b2dcecaada7f753"
dependencies = [
"bitflags 2.13.2",
"itoa",
"memchr",
"ryu",
]
[[package]]
name = "peniko"
version = "0.6.1"
@ -2895,6 +3048,16 @@ dependencies = [
"objc2-quartz-core 0.3.2",
]
[[package]]
name = "read-fonts"
version = "0.39.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81"
dependencies = [
"bytemuck",
"font-types 0.11.3",
]
[[package]]
name = "read-fonts"
version = "0.41.0"
@ -2902,7 +3065,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.5",
"once_cell",
]
@ -2913,7 +3076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "005c8acf251756c478b0bf402885bfd88a1476020c4c7e6060edc1aa68da38ea"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.5",
"once_cell",
]
@ -2924,7 +3087,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5a0cf4bb6cbce4c29756270d189a5e400ac05e91192161d83a0bcb6e68492e8"
dependencies = [
"bytemuck",
"font-types",
"font-types 0.12.5",
"once_cell",
]
@ -3097,6 +3260,30 @@ dependencies = [
"wait-timeout",
]
[[package]]
name = "rustybuzz"
version = "0.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702"
dependencies = [
"bitflags 2.13.2",
"bytemuck",
"core_maths",
"log",
"smallvec",
"ttf-parser",
"unicode-bidi-mirroring",
"unicode-ccc",
"unicode-properties",
"unicode-script",
]
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "same-file"
version = "1.0.6"
@ -3237,6 +3424,16 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "skrifa"
version = "0.42.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564"
dependencies = [
"bytemuck",
"read-fonts 0.39.2",
]
[[package]]
name = "skrifa"
version = "0.44.0"
@ -3362,6 +3559,12 @@ dependencies = [
"bitflags 2.13.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "static_assertions"
version = "1.1.0"
@ -3377,6 +3580,18 @@ dependencies = [
"float-cmp",
]
[[package]]
name = "subsetter"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6"
dependencies = [
"kurbo 0.13.1",
"rustc-hash 2.1.3",
"skrifa 0.42.1",
"write-fonts",
]
[[package]]
name = "svgtypes"
version = "0.15.3"
@ -3419,6 +3634,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -3609,6 +3835,15 @@ dependencies = [
"once_cell",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "type-map"
version = "0.5.1"
@ -3641,6 +3876,18 @@ version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]]
name = "unicode-bidi-mirroring"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe"
[[package]]
name = "unicode-ccc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e"
[[package]]
name = "unicode-general-category"
version = "1.1.0"
@ -3653,6 +3900,12 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-properties"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
[[package]]
name = "unicode-script"
version = "0.5.8"
@ -4578,6 +4831,19 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "write-fonts"
version = "0.48.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8"
dependencies = [
"font-types 0.11.3",
"indexmap",
"kurbo 0.13.1",
"log",
"read-fonts 0.39.2",
]
[[package]]
name = "x11-dl"
version = "2.21.0"
@ -4647,6 +4913,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "xmp-writer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.1.0"
@ -4654,6 +4926,29 @@ dependencies = [
"serde_json",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
]
[[package]]
name = "zbus"
version = "5.19.0"
@ -4780,6 +5075,27 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
"synstructure",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"

View File

@ -19,10 +19,7 @@ pub fn start() {
log::error!("no document");
return;
};
let Some(canvas) = document
.get_element_by_id(CANVAS_ID)
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
else {
let Some(canvas) = document.get_element_by_id(CANVAS_ID).and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok()) else {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
@ -49,9 +46,7 @@ pub fn start() {
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
Err(e) => el.set_inner_html(&format!(
"<p>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>"
)),
Err(e) => el.set_inner_html(&format!("<p>DrawCraft failed to start: {e:?}</p><p>A browser with WebGPU or WebGL2 is required.</p>")),
}
}
});
@ -75,11 +70,7 @@ impl eframe::App for WebShell {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f
.path()
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "dropped".into());
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
@ -129,10 +120,7 @@ fn services(inbox: Inbox, ctx: egui::Context) -> Services {
/// Trigger a browser download of `bytes` named after the last component of `path`.
fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("{e:?}");
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "drawcraft".into());
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "drawcraft".into());
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
@ -140,8 +128,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
opts.set_type(mime_for(&name));
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement =
document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(&name);
a.style().set_property("display", "none").map_err(js)?;
@ -153,9 +140,7 @@ fn download(path: &str, bytes: &[u8]) -> Result<(), String> {
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
web_sys::Url::revoke_object_url(&url).ok();
});
window
.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000)
.map_err(js)?;
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}

View File

@ -30,7 +30,12 @@ impl eframe::App for App {
fn services() -> Services {
Services {
pick_open: Some(Box::new(|| rfd::FileDialog::new().add_filter("All supported", &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"]).pick_file().map(|p| p.to_string_lossy().to_string()))),
pick_open: Some(Box::new(|| {
rfd::FileDialog::new()
.add_filter("All supported", &["drawcraft", "svg", "png", "jpg", "jpeg", "gif", "webp"])
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),

View File

@ -0,0 +1,232 @@
//! Distort & Transform effects.
use drawcraft_geom::{Affine, Anchor, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
use serde_json::Value;
use crate::util::*;
fn diag(b: Rect) -> f64 {
b.width().hypot(b.height()).max(1e-6)
}
/// Free Distort: bilinear map of the bounding box onto four new corners (unit box coordinates).
pub fn free_distort(path: &PathData, b: Rect, p: &Value) -> PathData {
let mut c = [Point::new(0.0, 0.0), Point::new(1.0, 0.0), Point::new(1.0, 1.0), Point::new(0.0, 1.0)];
if let Some(arr) = p.get("corners").and_then(Value::as_array) {
for (i, v) in arr.iter().take(4).enumerate() {
if let Some(a) = v.as_array()
&& let (Some(x), Some(y)) = (a.first().and_then(Value::as_f64), a.get(1).and_then(Value::as_f64))
&& x.is_finite()
&& y.is_finite()
{
c[i] = Point::new(x, y);
}
}
}
let (w, h) = (b.width().max(1e-9), b.height().max(1e-9));
let to_doc = |q: Point| Point::new(b.x0 + q.x * w, b.y0 + q.y * h);
let c = c.map(to_doc);
let f = |q: Point| {
let u = (q.x - b.x0) / w;
let v = (q.y - b.y0) / h;
let top = c[0].lerp(c[1], u);
let bot = c[3].lerp(c[2], u);
top.lerp(bot, v)
};
map_nonlinear(path, diag(b) / 16.0, f)
}
/// Pucker & Bloat: anchors move toward (bloat) or away from (pucker) the centre, handles the
/// opposite way.
pub fn pucker_bloat(path: &PathData, b: Rect, amount: f64) -> PathData {
let a = amount.clamp(-200.0, 200.0) / 100.0;
if a == 0.0 {
return path.clone();
}
let c = b.center();
// Lines have retracted handles: give them handles at 1/3 before distorting so they bulge.
{
let mut res = path.clone();
for (si, sp) in res.subpaths.iter_mut().enumerate() {
let n = sp.anchors.len();
let segs = sp.segment_count();
let orig = &path.subpaths[si];
for i in 0..segs {
let cub = seg_cubic(orig, i);
let j = (i + 1) % n;
sp.anchors[i].h_out = cub.p1;
sp.anchors[j].h_in = cub.p2;
}
for an in &mut sp.anchors {
let p = an.p;
*an = Anchor::with_handles(p.lerp(c, a * 0.5), an.h_in.lerp(c, -a * 0.5), an.h_out.lerp(c, -a * 0.5));
}
}
res
}
}
/// Roughen: resample at `detail` points per inch and jitter each point by up to `size`.
pub fn roughen(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 5.0).clamp(0.0, 1e4);
let amp = if flag(p, "relative", true) { size / 100.0 * mean_size(b) } else { size };
let detail = num(p, "detail", 10.0).clamp(0.0, 100.0);
let spacing = if detail > 0.0 { 72.0 / detail } else { f64::INFINITY };
let smooth = smooth_points(p);
let seed = seed(p);
let mut subs = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let mut pts = vec![];
for i in 0..sp.segment_count() {
let c = sp.segment(i);
let len = drawcraft_geom::kurbo::ParamCurveArclen::arclen(&c, 0.1);
let k = if spacing.is_finite() { ((len / spacing).ceil() as usize).clamp(1, 2000) } else { 1 };
for j in 0..k {
pts.push(c.eval(j as f64 / k as f64));
}
}
if !sp.closed
&& let Some(l) = sp.anchors.last()
{
pts.push(l.p);
}
if pts.is_empty() {
pts.extend(sp.anchors.iter().map(|a| a.p));
}
for (k, q) in pts.iter_mut().enumerate() {
q.x += amp * noise(seed, si as u64, k as u64, 1);
q.y += amp * noise(seed, si as u64, k as u64, 2);
}
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
}
PathData::new(subs)
}
/// Transform effect (scale, move, rotate, reflect about the centre, plus copies).
pub fn transform(path: &PathData, b: Rect, p: &Value) -> PathData {
let sx = num(p, "scaleH", 100.0) / 100.0;
let sy = num(p, "scaleV", 100.0) / 100.0;
let (rx, ry) = (if flag(p, "reflectX", false) { -1.0 } else { 1.0 }, if flag(p, "reflectY", false) { -1.0 } else { 1.0 });
let c = b.center().to_vec2();
// Reflect X mirrors across the horizontal axis (flips x coordinates in Illustrator's dialog).
let m = Affine::translate(Vec2::new(num(p, "moveH", 0.0), num(p, "moveV", 0.0)) + c)
* Affine::rotate(-num(p, "rotate", 0.0).to_radians())
* Affine::scale_non_uniform(sx * rx, sy * ry)
* Affine::translate(-c);
let copies = num(p, "copies", 0.0).clamp(0.0, 1000.0) as usize;
if copies == 0 {
return path.transformed(m);
}
let mut out = path.clone();
let mut cur = path.clone();
for _ in 0..copies {
cur = cur.transformed(m);
out.subpaths.extend(cur.subpaths.iter().cloned());
}
out
}
/// Tweak: random displacement of anchors and/or control points.
pub fn tweak(path: &PathData, b: Rect, p: &Value) -> PathData {
let rel = flag(p, "relative", true);
let h = num(p, "h", 10.0);
let v = num(p, "v", 10.0);
let (ah, av) = if rel { (h / 100.0 * b.width().abs(), v / 100.0 * b.height().abs()) } else { (h, v) };
let (anchors, ins, outs) = (flag(p, "anchors", true), flag(p, "in", true), flag(p, "out", true));
let seed = seed(p);
let mut out = path.clone();
for (si, sp) in out.subpaths.iter_mut().enumerate() {
for (ai, an) in sp.anchors.iter_mut().enumerate() {
let key = ((si as u64) << 32) | ai as u64;
let d = |c: u64| Vec2::new(ah * noise(seed, key, c, 1), av * noise(seed, key, c, 2));
let (mut pp, mut hi, mut ho) = (an.p, an.h_in, an.h_out);
if anchors {
let dp = d(0);
pp += dp;
hi += dp;
ho += dp;
}
if ins {
hi += d(1);
}
if outs {
ho += d(2);
}
*an = Anchor::with_handles(pp, hi, ho);
}
}
out
}
/// Twist: rotation that is strongest at the centre and fades to zero at the bounding circle.
pub fn twist(path: &PathData, b: Rect, angle: f64) -> PathData {
let ang = angle.clamp(-3600.0, 3600.0).to_radians();
if ang == 0.0 {
return path.clone();
}
let c = b.center();
let r = diag(b) / 2.0;
let f = move |q: Point| {
let d = q - c;
let t = (1.0 - d.hypot() / r).max(0.0);
let a = -ang * t;
let (s, co) = a.sin_cos();
c + Vec2::new(d.x * co - d.y * s, d.x * s + d.y * co)
};
let pieces = (r / 8.0).min(r / (1.0 + ang.abs() * 2.0));
map_nonlinear(path, pieces, f)
}
/// Zig Zag: `ridges` extra points per segment, alternately displaced along the normal.
pub fn zig_zag(path: &PathData, b: Rect, p: &Value) -> PathData {
let size = num(p, "size", 10.0).clamp(-1e4, 1e4);
let amp = if flag(p, "relative", false) { size / 100.0 * mean_size(b) } else { size };
let ridges = num(p, "ridges", 4.0).clamp(0.0, 100.0) as usize;
let smooth = smooth_points(p);
let mut subs = vec![];
for sp in &path.subpaths {
let segs = sp.segment_count();
let mut pts = vec![];
let mut k = 0usize;
for i in 0..segs {
let c = sp.segment(i);
for j in 0..=ridges {
let t = j as f64 / (ridges + 1) as f64;
let nrm = if j == 0 {
// Average the normals of the segments meeting at the anchor.
let prev = if i > 0 {
Some(sp.segment(i - 1))
} else if sp.closed {
Some(sp.segment(segs - 1))
} else {
None
};
let n1 = normal_at(&c, 0.0);
match prev {
Some(pc) => {
let s = n1 + normal_at(&pc, 1.0);
if s.hypot() > 1e-9 { s / s.hypot() } else { n1 }
}
None => n1,
}
} else {
normal_at(&c, t)
};
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.eval(t) + nrm * (amp * sign));
k += 1;
}
}
if !sp.closed {
if segs > 0 {
let c = sp.segment(segs - 1);
let sign = if k.is_multiple_of(2) { 1.0 } else { -1.0 };
pts.push(c.p3 + normal_at(&c, 1.0) * (amp * sign));
} else {
pts.extend(sp.anchors.iter().map(|a| a.p));
}
}
subs.push(if smooth { catmull_rom(&pts, sp.closed, 1.0) } else { SubPath::polyline(&pts, sp.closed) });
}
PathData::new(subs)
}

View File

@ -1,2 +1,326 @@
//! DrawCraft live effects evaluated to geometry
//! DrawCraft live effects (Illustrator's Effect menu).
//!
//! Effects live in appearance stacks as [`Effect`] `{id, params, visible}` records. This crate
//! interprets them:
//!
//! - **Geometry effects** (Distort & Transform, Path, Convert to Shape, Round Corners, Scribble,
//! Warp) rewrite a path: [`apply_geometry`] evaluates them in stack order.
//! - **Raster effects** (Drop Shadow, Inner/Outer Glow, Feather, Gaussian Blur) are described by
//! [`raster_effects`] and painted by the renderer; [`outset`] says how far they reach beyond
//! the geometry.
//! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the
//! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults.
//!
//! Everything is deterministic: "random" effects (Roughen, Tweak, Scribble) use a seeded hash
//! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)]
mod distort;
mod raster;
mod stylize;
mod util;
mod warp;
#[cfg(test)]
mod tests;
use drawcraft_doc::Effect;
use drawcraft_geom::{BezPath, PathData, Rect};
use serde_json::{Map, Value, json};
pub use raster::{RasterFx, outset, raster_effects};
pub use warp::{WarpStyle, warp_point};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInfo {
/// Stable id, e.g. `distort.roughen`.
pub id: &'static str,
/// Menu label (with the ellipsis when the effect has a dialog).
pub label: &'static str,
/// Effect-menu path, e.g. `["Effect", "Distort & Transform"]`.
pub menu: &'static [&'static str],
/// Human/agent-readable parameter documentation.
pub params: &'static str,
/// Dialog defaults.
pub defaults: Value,
/// Raster (painted) rather than geometry effect.
pub raster: bool,
}
const WARP_DOC: &str =
"{bend: % (-100..100, 50), horizontal: % distortion (0), vertical: % distortion (0), orientation: \"horizontal\"|\"vertical\"}";
const DT: &[&str] = &["Effect", "Distort & Transform"];
const PATH: &[&str] = &["Effect", "Path"];
const SHAPE: &[&str] = &["Effect", "Convert to Shape"];
const STYLIZE: &[&str] = &["Effect", "Stylize"];
const WARP: &[&str] = &["Effect", "Warp"];
const BLUR: &[&str] = &["Effect", "Blur"];
/// The warp styles in Illustrator's Style menu order: (id suffix, label).
pub const WARP_STYLES: [(&str, &str); 15] = [
("arc", "Arc…"),
("arcLower", "Arc Lower…"),
("arcUpper", "Arc Upper…"),
("arch", "Arch…"),
("bulge", "Bulge…"),
("shellLower", "Shell Lower…"),
("shellUpper", "Shell Upper…"),
("flag", "Flag…"),
("wave", "Wave…"),
("fish", "Fish…"),
("rise", "Rise…"),
("fisheye", "Fisheye…"),
("inflate", "Inflate…"),
("squeeze", "Squeeze…"),
("twist", "Twist…"),
];
/// Every supported effect with its dialog defaults.
pub fn effect_catalog() -> Vec<EffectInfo> {
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false };
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true };
let mut v = vec![
g(
"convertToShape.rectangle",
"Rectangle…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width: pt (absolute, 100), height: pt (absolute, 100)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}),
),
g(
"convertToShape.roundedRectangle",
"Rounded Rectangle…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute), radius: pt (9)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0, "radius": 9.0}),
),
g(
"convertToShape.ellipse",
"Ellipse…",
SHAPE,
"{relative: bool (true), extraW: pt (18), extraH: pt (18), width, height (absolute)}",
json!({"relative": true, "extraW": 18.0, "extraH": 18.0, "width": 100.0, "height": 100.0}),
),
g(
"distort.freeDistort",
"Free Distort…",
DT,
"{corners: [[x,y]×4] new positions of the bounding-box corners TL, TR, BR, BL in unit box coordinates (identity [[0,0],[1,0],[1,1],[0,1]])}",
json!({"corners": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}),
),
g("distort.puckerBloat", "Pucker & Bloat…", DT, "{amount: % (-200 pucker … 200 bloat, default 0)}", json!({"amount": 0.0})),
g(
"distort.roughen",
"Roughen…",
DT,
"{size: 0..100 (5), relative: bool (true: % of size; false: pt), detail: per inch 0..100 (10), points: \"smooth\"|\"corner\", seed: int (0)}",
json!({"size": 5.0, "relative": true, "detail": 10.0, "points": "smooth", "seed": 0}),
),
g(
"distort.transform",
"Transform…",
DT,
"{scaleH: % (100), scaleV: % (100), moveH: pt (0), moveV: pt (0), rotate: deg (0), copies: int (0), reflectX: bool, reflectY: bool}",
json!({"scaleH": 100.0, "scaleV": 100.0, "moveH": 0.0, "moveV": 0.0, "rotate": 0.0, "copies": 0, "reflectX": false, "reflectY": false}),
),
g(
"distort.tweak",
"Tweak…",
DT,
"{h: amount (10), v: amount (10), relative: bool (true: % of size), anchors: bool (true), in: bool (true), out: bool (true), seed: int (0)}",
json!({"h": 10.0, "v": 10.0, "relative": true, "anchors": true, "in": true, "out": true, "seed": 0}),
),
g("distort.twist", "Twist…", DT, "{angle: deg (-3600..3600, 10)}", json!({"angle": 10.0})),
g(
"distort.zigZag",
"Zig Zag…",
DT,
"{size: (10), relative: bool (false: pt; true: % of size), ridges: per segment 0..100 (4), points: \"smooth\"|\"corner\"}",
json!({"size": 10.0, "relative": false, "ridges": 4, "points": "smooth"}),
),
g(
"path.offsetPath",
"Offset Path…",
PATH,
"{offset: pt (10; negative insets), joins: \"miter\"|\"round\"|\"bevel\", miterLimit: (4)}",
json!({"offset": 10.0, "joins": "miter", "miterLimit": 4.0}),
),
g("path.outlineStroke", "Outline Stroke", PATH, "{width?: pt (defaults to the object's stroke weight)}", json!({})),
g("stylize.roundCorners", "Round Corners…", STYLIZE, "{radius: pt (10)}", json!({"radius": 10.0})),
g(
"stylize.scribble",
"Scribble…",
STYLIZE,
"{angle: deg (30), overlap: pt (0), strokeWidth: pt (3), curviness: % (5), spacing: pt (5), variation: pt (0.5), seed: int (0)} (simplified: a single hatching scribble outlined to a filled shape)",
json!({"angle": 30.0, "overlap": 0.0, "strokeWidth": 3.0, "curviness": 5.0, "spacing": 5.0, "variation": 0.5, "seed": 0}),
),
r(
"stylize.dropShadow",
"Drop Shadow…",
STYLIZE,
"{mode: blend mode (\"multiply\"), opacity: % (75), x: pt (7), y: pt (7), blur: pt (5), color: \"#rrggbb\" (\"#000000\")}",
json!({"mode": "multiply", "opacity": 75.0, "x": 7.0, "y": 7.0, "blur": 5.0, "color": "#000000"}),
),
r(
"stylize.innerGlow",
"Inner Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffffff\"), source: \"edge\"|\"center\"}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffffff", "source": "edge"}),
),
r(
"stylize.outerGlow",
"Outer Glow…",
STYLIZE,
"{mode: blend mode (\"screen\"), opacity: % (75), blur: pt (5), color: \"#rrggbb\" (\"#ffff00\")}",
json!({"mode": "screen", "opacity": 75.0, "blur": 5.0, "color": "#ffff00"}),
),
r("stylize.feather", "Feather…", STYLIZE, "{radius: pt (5)}", json!({"radius": 5.0})),
r("blur.gaussian", "Gaussian Blur…", BLUR, "{radius: pt (5)}", json!({"radius": 5.0})),
];
for (suffix, label) in WARP_STYLES {
let id: &'static str = match suffix {
"arc" => "warp.arc",
"arcLower" => "warp.arcLower",
"arcUpper" => "warp.arcUpper",
"arch" => "warp.arch",
"bulge" => "warp.bulge",
"shellLower" => "warp.shellLower",
"shellUpper" => "warp.shellUpper",
"flag" => "warp.flag",
"wave" => "warp.wave",
"fish" => "warp.fish",
"rise" => "warp.rise",
"fisheye" => "warp.fisheye",
"inflate" => "warp.inflate",
"squeeze" => "warp.squeeze",
_ => "warp.twist",
};
v.push(g(id, label, WARP, WARP_DOC, json!({"bend": 50.0, "horizontal": 0.0, "vertical": 0.0, "orientation": "horizontal"})));
}
v
}
/// Catalogue entry for `id`.
pub fn effect_info(id: &str) -> Option<EffectInfo> {
effect_catalog().into_iter().find(|e| e.id == id)
}
/// Dialog defaults for `id` (`None` for unknown effects).
pub fn default_params(id: &str) -> Option<Value> {
effect_info(id).map(|e| e.defaults)
}
/// `params` layered over the defaults of `id` (unknown keys are kept).
pub fn merged_params(id: &str, params: &Value) -> Value {
let mut out = match default_params(id) {
Some(Value::Object(m)) => m,
_ => Map::new(),
};
if let Value::Object(p) = params {
for (k, v) in p {
out.insert(k.clone(), v.clone());
}
}
Value::Object(out)
}
/// A new effect record with default parameters (overridden by `params`).
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
effect_info(id)?;
Some(Effect { id: id.to_string(), params: merged_params(id, params), visible: true })
}
/// Is `id` a raster (painted) effect?
pub fn is_raster(id: &str) -> bool {
matches!(id, "stylize.dropShadow" | "stylize.innerGlow" | "stylize.outerGlow" | "stylize.feather" | "blur.gaussian")
}
/// Does `id` change geometry?
pub fn is_geometry(id: &str) -> bool {
!is_raster(id) && effect_info(id).is_some()
}
/// Any visible geometry effect in the list?
pub fn has_geometry(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible && is_geometry(&e.id))
}
/// Context the geometry effects may need from the object's appearance.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GeomContext {
/// Stroke weight used by `path.outlineStroke` when the effect has no `width`.
pub stroke_width: f64,
}
impl Default for GeomContext {
fn default() -> Self {
Self { stroke_width: 1.0 }
}
}
/// Evaluate the visible geometry effects of `effects` on `path`, in order. `bounds` is the
/// reference box of the first effect (usually the object's geometric bounds); each later effect
/// uses the bounds of its input. Raster and unknown effects are skipped.
pub fn apply_geometry(effects: &[Effect], path: &PathData, bounds: Rect) -> PathData {
apply_geometry_with(effects, path, bounds, &GeomContext::default())
}
/// [`apply_geometry`] with an explicit [`GeomContext`].
pub fn apply_geometry_with(effects: &[Effect], path: &PathData, bounds: Rect, ctx: &GeomContext) -> PathData {
let mut cur = path.clone();
let mut b = bounds;
let mut first = true;
for e in effects.iter().filter(|e| e.visible && is_geometry(&e.id)) {
if !first {
b = cur.bounds().unwrap_or(b);
}
first = false;
if cur.is_empty() {
break;
}
let p = merged_params(&e.id, &e.params);
cur = apply_one(&e.id, &p, &cur, b, ctx);
}
cur
}
/// Convenience wrapper on kurbo paths.
pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: &GeomContext) -> BezPath {
apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath()
}
fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData {
use util::*;
match id {
"distort.freeDistort" => distort::free_distort(path, b, p),
"distort.puckerBloat" => distort::pucker_bloat(path, b, num(p, "amount", 0.0)),
"distort.roughen" => distort::roughen(path, b, p),
"distort.transform" => distort::transform(path, b, p),
"distort.tweak" => distort::tweak(path, b, p),
"distort.twist" => distort::twist(path, b, num(p, "angle", 10.0)),
"distort.zigZag" => distort::zig_zag(path, b, p),
"path.offsetPath" => {
let off = num(p, "offset", 10.0).clamp(-1e5, 1e5);
if off.abs() < 1e-9 {
return path.clone();
}
drawcraft_pathops::offset_path(path, off, join(p, "joins"), num(p, "miterLimit", 4.0).clamp(1.0, 500.0))
}
"path.outlineStroke" => {
let w = p.get("width").and_then(Value::as_f64).unwrap_or(ctx.stroke_width);
if w <= 0.0 {
return path.clone();
}
drawcraft_pathops::outline_stroke(path, w, drawcraft_pathops::Cap::Butt, join(p, "joins"), num(p, "miterLimit", 10.0).max(1.0))
}
"convertToShape.rectangle" | "convertToShape.roundedRectangle" | "convertToShape.ellipse" => stylize::convert_to_shape(id, b, p),
"stylize.roundCorners" => stylize::round_corners(path, num(p, "radius", 10.0)),
"stylize.scribble" => stylize::scribble(path, b, p),
_ => match id.strip_prefix("warp.").and_then(WarpStyle::from_id) {
Some(style) => warp::warp(path, b, style, p),
None => path.clone(),
},
}
}

View File

@ -0,0 +1,125 @@
//! Raster (painted) effects: descriptions for the renderer.
use drawcraft_doc::Effect;
use drawcraft_doc::color::{BlendMode, Color};
use serde_json::Value;
use crate::merged_params;
use crate::util::*;
/// A painted effect, in document units. `blur` is Illustrator's blur distance; the renderer uses
/// a Gaussian with σ = blur / 2 (so the visible spread is about 1.5 × blur).
#[derive(Clone, Debug, PartialEq)]
pub enum RasterFx {
DropShadow {
mode: BlendMode,
opacity: f32,
dx: f64,
dy: f64,
blur: f64,
color: Color,
},
OuterGlow {
mode: BlendMode,
opacity: f32,
blur: f64,
color: Color,
},
InnerGlow {
mode: BlendMode,
opacity: f32,
blur: f64,
color: Color,
center: bool,
},
/// Soften the object's edges inward over `radius`.
Feather {
radius: f64,
},
/// Blur the whole object (σ = radius / 2).
GaussianBlur {
radius: f64,
},
}
impl RasterFx {
/// Painted below the object (shadows, outer glows)?
pub fn is_below(&self) -> bool {
matches!(self, RasterFx::DropShadow { .. } | RasterFx::OuterGlow { .. })
}
/// How far this effect reaches beyond the geometry.
pub fn outset(&self) -> f64 {
match self {
RasterFx::DropShadow { dx, dy, blur, .. } => dx.abs().max(dy.abs()) + 1.5 * blur,
RasterFx::OuterGlow { blur, .. } => 1.5 * blur,
RasterFx::InnerGlow { .. } | RasterFx::Feather { .. } => 0.0,
RasterFx::GaussianBlur { radius } => 1.5 * radius,
}
}
}
/// Parse a blend mode name (`"multiply"`, `"Screen"`, `"color-burn"`…).
pub fn blend_mode(s: &str) -> BlendMode {
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
BlendMode::ALL.iter().copied().find(|m| format!("{m:?}").to_ascii_lowercase() == key).unwrap_or(BlendMode::Normal)
}
fn color(p: &Value, default: Color) -> Color {
match p.get("color") {
Some(Value::String(s)) => Color::from_hex(s).unwrap_or(default),
Some(Value::Array(a)) if a.len() >= 3 => {
let f = |i: usize| a[i].as_f64().unwrap_or(0.0) as f32;
Color::rgb(f(0), f(1), f(2))
}
Some(v @ Value::Object(_)) => serde_json::from_value(v.clone()).unwrap_or(default),
_ => default,
}
}
fn opacity(p: &Value) -> f32 {
(num(p, "opacity", 75.0) / 100.0).clamp(0.0, 1.0) as f32
}
/// The visible raster effects of `effects`, in stack order.
pub fn raster_effects(effects: &[Effect]) -> Vec<RasterFx> {
effects
.iter()
.filter(|e| e.visible)
.filter_map(|e| {
let p = merged_params(&e.id, &e.params);
let blur = num(&p, "blur", 5.0).clamp(0.0, 1000.0);
Some(match e.id.as_str() {
"stylize.dropShadow" => RasterFx::DropShadow {
mode: blend_mode(text(&p, "mode", "multiply")),
opacity: opacity(&p),
dx: num(&p, "x", 7.0).clamp(-1e4, 1e4),
dy: num(&p, "y", 7.0).clamp(-1e4, 1e4),
blur,
color: color(&p, Color::BLACK),
},
"stylize.outerGlow" => RasterFx::OuterGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::rgb(1.0, 1.0, 0.0)),
},
"stylize.innerGlow" => RasterFx::InnerGlow {
mode: blend_mode(text(&p, "mode", "screen")),
opacity: opacity(&p),
blur,
color: color(&p, Color::WHITE),
center: text(&p, "source", "edge").eq_ignore_ascii_case("center"),
},
"stylize.feather" => RasterFx::Feather { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
"blur.gaussian" => RasterFx::GaussianBlur { radius: num(&p, "radius", 5.0).clamp(0.0, 1000.0) },
_ => return None,
})
})
.collect()
}
/// How far the visible raster effects of `effects` paint beyond the (effected) geometry, in
/// document units. Geometry effects are accounted for by evaluating them.
pub fn outset(effects: &[Effect]) -> f64 {
raster_effects(effects).iter().map(RasterFx::outset).fold(0.0, f64::max)
}

View File

@ -0,0 +1,161 @@
//! Convert to Shape, Round Corners and Scribble.
use drawcraft_geom::kurbo;
use drawcraft_geom::shapes::{self, KAPPA};
use drawcraft_geom::{Affine, Anchor, PathData, Point, Rect, SubPath};
use serde_json::Value;
use crate::util::*;
/// Convert to Shape (rectangle / rounded rectangle / ellipse) around the bounds.
pub fn convert_to_shape(id: &str, b: Rect, p: &Value) -> PathData {
let r = if flag(p, "relative", true) {
let (ew, eh) = (num(p, "extraW", 18.0), num(p, "extraH", 18.0));
let r = b.inflate(ew / 2.0, eh / 2.0);
Rect::new(r.x0.min(b.center().x), r.y0.min(b.center().y), r.x1.max(b.center().x), r.y1.max(b.center().y))
} else {
let (w, h) = (num(p, "width", 100.0).abs(), num(p, "height", 100.0).abs());
Rect::from_center_size(b.center(), (w, h))
};
match id {
"convertToShape.ellipse" => shapes::ellipse(r),
"convertToShape.roundedRectangle" => shapes::rounded_rectangle(r, num(p, "radius", 9.0)),
_ => shapes::rectangle(r),
}
}
/// Round Corners: every sharp, handle-less corner becomes a circular-ish arc of `radius`.
pub fn round_corners(path: &PathData, radius: f64) -> PathData {
if radius <= 0.0 {
return path.clone();
}
let mut subs = vec![];
for sp in &path.subpaths {
let n = sp.anchors.len();
if n < 3 && (n != 2 || sp.closed) {
subs.push(sp.clone());
continue;
}
let mut out: Vec<Anchor> = vec![];
for i in 0..n {
let a = sp.anchors[i];
let interior = sp.closed || (i > 0 && i + 1 < n);
if !interior || a.has_in() || a.has_out() {
out.push(a);
continue;
}
let prev = &sp.anchors[(i + n - 1) % n];
let next = &sp.anchors[(i + 1) % n];
// Tangent directions toward the neighbours (their handles if the segments curve).
let tp = if prev.has_out() { prev.h_out } else { prev.p };
let tn = if next.has_in() { next.h_in } else { next.p };
let (vp, vn) = (tp - a.p, tn - a.p);
let (lp, ln) = (vp.hypot(), vn.hypot());
if lp < 1e-9 || ln < 1e-9 {
out.push(a);
continue;
}
let (up, un) = (vp / lp, vn / ln);
// Nearly straight: nothing to round.
if up.dot(un) < -0.9999 {
out.push(a);
continue;
}
let d = radius.min(prev.p.distance(a.p) / 2.0).min(next.p.distance(a.p) / 2.0);
let pa = a.p + up * d;
let pb = a.p + un * d;
let k = d * KAPPA;
out.push(Anchor::with_handles(pa, pa, pa - up * k));
out.push(Anchor::with_handles(pb, pb - un * k, pb));
}
subs.push(SubPath::new(out, sp.closed));
}
PathData::new(subs)
}
fn flatten(path: &PathData) -> Vec<Vec<Point>> {
let mut polys: Vec<Vec<Point>> = vec![];
kurbo::flatten(path.to_bezpath(), 0.25, |el| match el {
kurbo::PathEl::MoveTo(p) => polys.push(vec![p]),
kurbo::PathEl::LineTo(p) => {
if let Some(l) = polys.last_mut() {
l.push(p)
}
}
kurbo::PathEl::ClosePath => {
if let Some(l) = polys.last_mut()
&& let Some(&f) = l.first()
{
l.push(f)
}
}
_ => {}
});
polys
}
/// Scribble (simplified): a single zig-zag hatching across the filled area at `angle`, outlined
/// to `strokeWidth` so the fill paints it.
pub fn scribble(path: &PathData, b: Rect, p: &Value) -> PathData {
let angle = num(p, "angle", 30.0).to_radians();
let overlap = num(p, "overlap", 0.0);
let width = num(p, "strokeWidth", 3.0).clamp(0.01, 1000.0);
let curvy = num(p, "curviness", 5.0).clamp(0.0, 100.0) / 100.0;
let spacing = num(p, "spacing", 5.0).max(0.1);
let variation = num(p, "variation", 0.5).clamp(0.0, 100.0);
let seed = seed(p);
let c = b.center();
let to_local = Affine::translate(c.to_vec2()) * Affine::rotate(angle) * Affine::translate(-c.to_vec2());
let from_local = to_local.inverse();
let polys: Vec<Vec<Point>> = flatten(path).into_iter().map(|pl| pl.into_iter().map(|q| from_local * q).collect()).collect();
let (mut y0, mut y1) = (f64::INFINITY, f64::NEG_INFINITY);
for q in polys.iter().flatten() {
y0 = y0.min(q.y);
y1 = y1.max(q.y);
}
if !y0.is_finite() || y1 - y0 < 1e-9 {
return PathData::default();
}
let step = spacing.max((y1 - y0) / 2000.0);
let mut pts: Vec<Point> = vec![];
let mut y = y0 + step / 2.0;
let mut row = 0u64;
while y < y1 {
let mut xs: Vec<f64> = vec![];
for pl in &polys {
for w in pl.windows(2) {
let (a, bq) = (w[0], w[1]);
if (a.y <= y && bq.y > y) || (bq.y <= y && a.y > y) {
xs.push(a.x + (y - a.y) / (bq.y - a.y) * (bq.x - a.x));
}
}
}
xs.sort_by(f64::total_cmp);
let mut spans: Vec<(f64, f64)> = xs.chunks_exact(2).map(|s| (s[0] - overlap, s[1] + overlap)).collect();
if row % 2 == 1 {
spans.reverse();
}
for (a, bx) in spans {
let jitter = |k: u64| variation * noise(seed, row, k, 3);
let (l, r) = (Point::new(a + jitter(0), y + jitter(1)), Point::new(bx + jitter(2), y + jitter(3)));
if row.is_multiple_of(2) {
pts.push(l);
pts.push(r);
} else {
pts.push(r);
pts.push(l);
}
}
row += 1;
y += step;
}
if pts.len() < 2 {
return PathData::default();
}
let line = if curvy > 0.0 { catmull_rom(&pts, false, curvy * 2.0) } else { SubPath::polyline(&pts, false) };
let mut center = PathData::single(line);
center.transform(to_local);
let stroke = kurbo::Stroke::new(width).with_join(kurbo::Join::Round).with_caps(kurbo::Cap::Round);
let outline = kurbo::stroke(center.to_bezpath(), &stroke, &kurbo::StrokeOpts::default(), 0.05);
PathData::from_bezpath(&outline)
}

325
crates/effects/src/tests.rs Normal file
View File

@ -0,0 +1,325 @@
use drawcraft_doc::Effect;
use drawcraft_geom::{PathData, Point, Rect, Shape, SubPath, shapes};
use serde_json::{Value, json};
use super::*;
fn fx(id: &str, params: Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn square() -> PathData {
shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0))
}
fn run(id: &str, params: Value, p: &PathData) -> PathData {
apply_geometry(&[fx(id, params)], p, p.bounds().unwrap())
}
fn close(a: f64, b: f64, tol: f64) -> bool {
(a - b).abs() <= tol
}
fn all_finite(p: &PathData) -> bool {
p.subpaths.iter().flat_map(|s| &s.anchors).all(|a| a.p.x.is_finite() && a.p.y.is_finite() && a.h_in.x.is_finite() && a.h_out.y.is_finite())
}
/// Every geometry effect with non-trivial parameters.
fn geometry_cases() -> Vec<(&'static str, Value)> {
let mut v = vec![
("distort.freeDistort", json!({"corners": [[0.1, 0.0], [0.9, 0.1], [1.0, 1.0], [0.0, 0.8]]})),
("distort.puckerBloat", json!({"amount": 40})),
("distort.puckerBloat", json!({"amount": -40})),
("distort.roughen", json!({})),
("distort.roughen", json!({"points": "corner", "size": 3, "relative": false})),
("distort.transform", json!({"scaleH": 50, "rotate": 30, "moveH": 10})),
("distort.tweak", json!({})),
("distort.twist", json!({"angle": 90})),
("distort.zigZag", json!({})),
("distort.zigZag", json!({"points": "corner", "ridges": 3})),
("path.offsetPath", json!({"offset": 5})),
("path.outlineStroke", json!({"width": 4})),
("convertToShape.rectangle", json!({})),
("convertToShape.roundedRectangle", json!({})),
("convertToShape.ellipse", json!({})),
("stylize.roundCorners", json!({"radius": 10})),
];
for (s, _) in WARP_STYLES {
let id: &'static str = Box::leak(format!("warp.{s}").into_boxed_str());
v.push((id, json!({"bend": 50})));
}
v
}
#[test]
fn catalog_complete_and_unique() {
let cat = effect_catalog();
let mut ids: Vec<_> = cat.iter().map(|e| e.id).collect();
ids.sort();
ids.dedup();
assert_eq!(ids.len(), cat.len());
assert!(cat.len() >= 34);
for e in &cat {
assert!(e.defaults.is_object(), "{}", e.id);
assert!(!e.params.is_empty());
assert_eq!(e.menu[0], "Effect");
assert_eq!(is_raster(e.id), e.raster);
}
}
#[test]
fn defaults_match_dialogs() {
let d = default_params("stylize.dropShadow").unwrap();
assert_eq!(d["opacity"], json!(75.0));
assert_eq!(d["x"], json!(7.0));
assert_eq!(d["mode"], json!("multiply"));
let r = default_params("distort.roughen").unwrap();
assert_eq!(r["size"], json!(5.0));
assert_eq!(r["detail"], json!(10.0));
assert!(default_params("nope").is_none());
}
#[test]
fn merged_params_override_defaults() {
let e = new_effect("distort.twist", &json!({"angle": 45})).unwrap();
assert_eq!(e.params["angle"], json!(45));
let e = new_effect("path.offsetPath", &json!({})).unwrap();
assert_eq!(e.params["joins"], json!("miter"));
assert!(new_effect("bogus.effect", &json!({})).is_none());
}
#[test]
fn every_geometry_effect_changes_geometry_and_keeps_closedness() {
let sq = square();
for (id, params) in geometry_cases() {
let out = run(id, params.clone(), &sq);
assert!(!out.is_empty(), "{id} produced nothing");
assert!(out.is_closed(), "{id} opened the path");
assert!(all_finite(&out), "{id} produced NaN");
assert_ne!(out.to_bezpath(), sq.to_bezpath(), "{id} did nothing");
}
}
#[test]
fn geometry_effects_are_deterministic() {
let sq = square();
for (id, params) in geometry_cases() {
let a = run(id, params.clone(), &sq);
let b = run(id, params, &sq);
assert_eq!(a, b, "{id} is not deterministic");
}
}
#[test]
fn open_paths_stay_open() {
let line = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(50.0, 20.0), Point::new(100.0, 0.0)], false));
for id in ["distort.roughen", "distort.zigZag", "distort.twist", "distort.tweak", "warp.arc", "warp.flag", "stylize.roundCorners"] {
let out = run(id, json!({}), &line);
assert!(!out.is_closed(), "{id}");
assert_eq!(out.subpaths.len(), 1);
}
}
#[test]
fn warp_keeps_bounds_sane() {
let sq = square();
let d = 100.0f64.hypot(100.0);
for (s, _) in WARP_STYLES {
for bend in [-100.0, -50.0, 50.0, 100.0] {
for orient in ["horizontal", "vertical"] {
let out = run(&format!("warp.{s}"), json!({"bend": bend, "horizontal": 30, "vertical": -30, "orientation": orient}), &sq);
let b = out.bounds().unwrap();
assert!(all_finite(&out), "warp.{s}");
assert!(b.width() > 1.0 && b.height() > 1.0, "warp.{s} collapsed: {b:?}");
assert!(b.width() < 3.0 * d && b.height() < 3.0 * d, "warp.{s} exploded: {b:?}");
assert!(b.center().distance(Point::new(50.0, 50.0)) < d, "warp.{s} drifted");
}
}
}
}
#[test]
fn warp_zero_bend_is_identity_ish() {
let sq = square();
for (s, _) in WARP_STYLES {
let out = run(&format!("warp.{s}"), json!({"bend": 0}), &sq);
let b = out.bounds().unwrap();
assert!(close(b.x0, 0.0, 1e-6) && close(b.x1, 100.0, 1e-6) && close(b.y1, 100.0, 1e-6), "warp.{s}: {b:?}");
}
}
#[test]
fn warp_arc_bends_the_top_edge() {
let out = run("warp.arc", json!({"bend": 50}), &square());
// The ends droop below the original bottom while the middle keeps the top.
let b = out.bounds().unwrap();
assert!(b.y1 > 101.0 && close(b.y0, 0.0, 0.5), "{b:?}");
let out = run("warp.arc", json!({"bend": 50, "orientation": "vertical"}), &square());
let b = out.bounds().unwrap();
assert!(b.x0 < -1.0 || b.x1 > 101.0, "{b:?}");
}
#[test]
fn transform_copies_and_move() {
let out = run("distort.transform", json!({"moveH": 20, "copies": 3}), &square());
assert_eq!(out.subpaths.len(), 4);
let b = out.bounds().unwrap();
assert!(close(b.x1, 160.0, 1e-9));
let r = run("distort.transform", json!({"scaleH": 50, "scaleV": 50}), &square()).bounds().unwrap();
assert!(close(r.width(), 50.0, 1e-9) && close(r.center().x, 50.0, 1e-9));
let f = run("distort.transform", json!({"reflectX": true, "moveV": 5}), &square()).bounds().unwrap();
assert!(close(f.y0, 5.0, 1e-9));
}
#[test]
fn pucker_bloat_moves_anchors_in_opposite_directions() {
let bloat = run("distort.puckerBloat", json!({"amount": 50}), &square());
let a = bloat.subpaths[0].anchors[0].p;
assert!(a.x > 0.0 && a.y > 0.0, "bloat pulls corners in: {a:?}");
let pucker = run("distort.puckerBloat", json!({"amount": -50}), &square());
let a = pucker.subpaths[0].anchors[0].p;
assert!(a.x < 0.0 && a.y < 0.0, "pucker pushes corners out: {a:?}");
assert_eq!(run("distort.puckerBloat", json!({"amount": 0}), &square()), square());
}
#[test]
fn roughen_seed_changes_result_and_detail_adds_points() {
let a = run("distort.roughen", json!({"seed": 1}), &square());
let b = run("distort.roughen", json!({"seed": 2}), &square());
assert_ne!(a, b);
let lo = run("distort.roughen", json!({"detail": 1}), &square());
let hi = run("distort.roughen", json!({"detail": 50}), &square());
assert!(hi.anchor_count() > lo.anchor_count());
// Displacement is bounded by size (5% of 100 = 5 pt).
let bb = a.bounds().unwrap();
assert!(bb.x0 > -8.0 && bb.x1 < 108.0);
}
#[test]
fn zigzag_ridge_count_and_amplitude() {
let out = run("distort.zigZag", json!({"ridges": 4, "points": "corner", "size": 10}), &square());
assert_eq!(out.anchor_count(), 4 * 5);
let b = out.bounds().unwrap();
assert!(b.width() > 100.0 && b.width() <= 120.0 + 1e-6, "{b:?}");
}
#[test]
fn offset_path_grows_and_shrinks() {
let grow = run("path.offsetPath", json!({"offset": 10, "joins": "miter"}), &square()).bounds().unwrap();
assert!(close(grow.width(), 120.0, 0.1), "{grow:?}");
let shrink = run("path.offsetPath", json!({"offset": -10}), &square()).bounds().unwrap();
assert!(close(shrink.width(), 80.0, 0.1), "{shrink:?}");
}
#[test]
fn outline_stroke_uses_context_width() {
let ctx = GeomContext { stroke_width: 10.0 };
let out = apply_geometry_with(&[fx("path.outlineStroke", json!({}))], &square(), Rect::new(0.0, 0.0, 100.0, 100.0), &ctx);
let b = out.bounds().unwrap();
assert!(close(b.width(), 110.0, 0.1), "{b:?}");
}
#[test]
fn convert_to_shape_sizes() {
let r = run("convertToShape.rectangle", json!({"extraW": 20, "extraH": 10}), &square()).bounds().unwrap();
assert!(close(r.width(), 120.0, 1e-9) && close(r.height(), 110.0, 1e-9));
let e = run("convertToShape.ellipse", json!({"relative": false, "width": 40, "height": 20}), &square());
let b = e.bounds().unwrap();
assert!(close(b.width(), 40.0, 1e-6) && close(b.center().x, 50.0, 1e-9));
assert_eq!(e.anchor_count(), 4);
}
#[test]
fn round_corners_replaces_corners() {
let out = run("stylize.roundCorners", json!({"radius": 10}), &square());
assert_eq!(out.anchor_count(), 8);
// Area shrinks by 4 × (r² − πr²/4) ≈ 85.8 for r = 10.
let area = out.to_bezpath().area().abs();
assert!(close(area, 10000.0 - 85.84, 2.0), "{area}");
// An ellipse has no sharp corners → unchanged.
let el = shapes::ellipse(Rect::new(0.0, 0.0, 50.0, 30.0));
assert_eq!(run("stylize.roundCorners", json!({}), &el), el);
}
#[test]
fn twist_fixes_the_outer_circle_and_rotates_inside() {
let out = run("distort.twist", json!({"angle": 0}), &square());
assert_eq!(out, square());
let small = PathData::single(SubPath::polyline(&[Point::new(45.0, 50.0), Point::new(55.0, 50.0)], false));
let out = apply_geometry(&[fx("distort.twist", json!({"angle": 90}))], &small, Rect::new(0.0, 0.0, 100.0, 100.0));
let a = out.subpaths[0].anchors[0].p;
assert!((a.y - 50.0).abs() > 1.0, "near-centre points rotate: {a:?}");
}
#[test]
fn scribble_produces_filled_outline_inside_bounds() {
let out = run("stylize.scribble", json!({}), &square());
assert!(!out.is_empty());
let b = out.bounds().unwrap();
assert!(b.x0 > -10.0 && b.x1 < 110.0 && b.y0 > -10.0 && b.y1 < 110.0, "{b:?}");
assert_eq!(out, run("stylize.scribble", json!({}), &square()));
}
#[test]
fn effects_chain_in_order_and_skip_hidden_and_raster() {
let chain = [fx("distort.transform", json!({"moveH": 10})), fx("distort.transform", json!({"scaleH": 200}))];
let b = apply_geometry(&chain, &square(), Rect::new(0.0, 0.0, 100.0, 100.0)).bounds().unwrap();
// Move first (10..110), then scale around the new centre (60): -40..160.
assert!(close(b.x0, -40.0, 1e-9) && close(b.x1, 160.0, 1e-9), "{b:?}");
let mut hidden = fx("distort.twist", json!({"angle": 90}));
hidden.visible = false;
let raster = fx("stylize.dropShadow", json!({}));
assert_eq!(apply_geometry(&[hidden, raster.clone()], &square(), Rect::new(0.0, 0.0, 100.0, 100.0)), square());
assert!(!has_geometry(&[raster]));
}
#[test]
fn raster_effects_parse_and_outset() {
let list = [
fx("stylize.dropShadow", json!({"x": 10, "y": -4, "blur": 6, "color": "#ff0000", "opacity": 50})),
fx("stylize.outerGlow", json!({})),
fx("stylize.innerGlow", json!({"source": "center"})),
fx("stylize.feather", json!({})),
fx("blur.gaussian", json!({"radius": 2})),
fx("distort.twist", json!({})),
];
let r = raster_effects(&list);
assert_eq!(r.len(), 5);
match &r[0] {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
assert_eq!(*mode, drawcraft_doc::color::BlendMode::Multiply);
assert!(close(*opacity as f64, 0.5, 1e-6));
assert_eq!((*dx, *dy, *blur), (10.0, -4.0, 6.0));
assert_eq!(color.to_rgb(), [1.0, 0.0, 0.0]);
}
other => panic!("{other:?}"),
}
assert!(matches!(r[2], RasterFx::InnerGlow { center: true, .. }));
assert!(close(outset(&list), 10.0 + 9.0, 1e-9));
assert_eq!(outset(&[]), 0.0);
}
#[test]
fn blend_mode_names() {
use drawcraft_doc::color::BlendMode;
assert_eq!(raster::blend_mode("Color-Burn"), BlendMode::ColorBurn);
assert_eq!(raster::blend_mode("screen"), BlendMode::Screen);
assert_eq!(raster::blend_mode("???"), BlendMode::Normal);
}
#[test]
fn free_distort_identity_and_corner_move() {
let id = run("distort.freeDistort", json!({}), &square()).bounds().unwrap();
assert!(close(id.x0, 0.0, 1e-9) && close(id.x1, 100.0, 1e-9));
let out = run("distort.freeDistort", json!({"corners": [[0, 0], [1.5, 0], [1, 1], [0, 1]]}), &square());
let b = out.bounds().unwrap();
assert!(close(b.x1, 150.0, 1e-6), "{b:?}");
}
#[test]
fn bad_params_fall_back_to_defaults() {
let out = run("distort.roughen", json!({"size": "abc", "detail": null, "points": 7}), &square());
assert_eq!(out, run("distort.roughen", json!({}), &square()));
let out = run("path.offsetPath", json!({"offset": "12 pt"}), &square());
assert!(close(out.bounds().unwrap().width(), 124.0, 0.1));
}

147
crates/effects/src/util.rs Normal file
View File

@ -0,0 +1,147 @@
//! Parameter parsing, deterministic noise and path-mapping helpers.
use drawcraft_geom::{Anchor, CubicBez, ParamCurve, PathData, Point, Rect, SubPath, Vec2};
use serde_json::Value;
/// Number param (also accepts numeric strings such as `"10 pt"`); non-finite → default.
pub fn num(p: &Value, key: &str, default: f64) -> f64 {
match p.get(key) {
Some(Value::Number(n)) => n.as_f64(),
Some(Value::String(s)) => {
let t: String = s.trim().chars().take_while(|c| c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | 'e' | 'E')).collect();
t.parse().ok()
}
Some(Value::Bool(b)) => Some(if *b { 1.0 } else { 0.0 }),
_ => None,
}
.filter(|v| v.is_finite())
.unwrap_or(default)
}
pub fn flag(p: &Value, key: &str, default: bool) -> bool {
match p.get(key) {
Some(Value::Bool(b)) => *b,
Some(Value::Number(n)) => n.as_f64().is_some_and(|v| v != 0.0),
_ => default,
}
}
pub fn text<'a>(p: &'a Value, key: &str, default: &'a str) -> &'a str {
p.get(key).and_then(Value::as_str).unwrap_or(default)
}
pub fn join(p: &Value, key: &str) -> drawcraft_pathops::Join {
match text(p, key, "miter").to_ascii_lowercase().as_str() {
"round" => drawcraft_pathops::Join::Round,
"bevel" => drawcraft_pathops::Join::Bevel,
_ => drawcraft_pathops::Join::Miter,
}
}
/// "smooth" (default) vs "corner" points.
pub fn smooth_points(p: &Value) -> bool {
!text(p, "points", "smooth").eq_ignore_ascii_case("corner")
}
/// Deterministic hash noise in [-1, 1] for (seed, a, b, c).
pub fn noise(seed: u64, a: u64, b: u64, c: u64) -> f64 {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ a.wrapping_mul(0xBF58_476D_1CE4_E5B9) ^ b.wrapping_mul(0x94D0_49BB_1331_11EB) ^ c;
// splitmix64 finaliser
x = x.wrapping_add(0x9E37_79B9_7F4A_7C15);
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
(x >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
pub fn seed(p: &Value) -> u64 {
num(p, "seed", 0.0).abs() as u64
}
/// Mean side of a box (the reference for "relative" sizes).
pub fn mean_size(b: Rect) -> f64 {
(b.width().abs() + b.height().abs()) / 2.0
}
/// Segment as a cubic; straight lines get handles at 1/3 and 2/3 so a non-linear map bends them.
pub fn seg_cubic(sp: &SubPath, i: usize) -> CubicBez {
let c = sp.segment(i);
if sp.segment_is_line(i) { CubicBez::new(c.p0, c.p0.lerp(c.p3, 1.0 / 3.0), c.p0.lerp(c.p3, 2.0 / 3.0), c.p3) } else { c }
}
fn poly_len(c: &CubicBez) -> f64 {
c.p0.distance(c.p1) + c.p1.distance(c.p2) + c.p2.distance(c.p3)
}
/// Map every point of `path` through the non-linear function `f`. Segments are split into pieces
/// no longer than about `max_piece` (up to 64 per segment) and their control points mapped, which
/// approximates the image curve to O(h²). Closedness and subpath structure are preserved.
pub fn map_nonlinear(path: &PathData, max_piece: f64, f: impl Fn(Point) -> Point) -> PathData {
let max_piece = max_piece.max(1e-3);
let mut subs = Vec::with_capacity(path.subpaths.len());
for sp in &path.subpaths {
let n = sp.anchors.len();
if n == 0 {
continue;
}
let segs = sp.segment_count();
let mut res: Vec<Anchor> = Vec::new();
let mut pending_in: Option<Point> = None;
for i in 0..n {
let a = &sp.anchors[i];
let h_in = pending_in.take().unwrap_or_else(|| f(a.h_in));
res.push(Anchor { p: f(a.p), h_in, h_out: f(a.h_out), kind: a.kind });
if i < segs {
let c = seg_cubic(sp, i);
let k = ((poly_len(&c) / max_piece).ceil() as usize).clamp(1, 64);
for j in 0..k {
let sub = c.subsegment((j as f64 / k as f64)..((j + 1) as f64 / k as f64));
res.last_mut().unwrap().h_out = f(sub.p1);
if j + 1 < k {
res.push(Anchor { p: f(sub.p3), h_in: f(sub.p2), h_out: f(sub.p3), kind: Default::default() });
} else {
pending_in = Some(f(sub.p2));
}
}
}
}
if let Some(h) = pending_in
&& sp.closed
{
res[0].h_in = h;
}
let anchors = res.into_iter().map(|a| Anchor::with_handles(a.p, a.h_in, a.h_out)).collect();
subs.push(SubPath::new(anchors, sp.closed));
}
PathData::new(subs)
}
/// Smooth subpath through `pts` (Catmull-Rom tangents).
pub fn catmull_rom(pts: &[Point], closed: bool, tension: f64) -> SubPath {
let n = pts.len();
if n < 3 {
return SubPath::polyline(pts, closed);
}
let anchors = (0..n)
.map(|i| {
let prev = if i == 0 { if closed { pts[n - 1] } else { pts[0] } } else { pts[i - 1] };
let next = if i == n - 1 { if closed { pts[0] } else { pts[n - 1] } } else { pts[i + 1] };
let t: Vec2 = (next - prev) * (tension / 6.0);
Anchor::with_handles(pts[i], pts[i] - t, pts[i] + t)
})
.collect();
SubPath::new(anchors, closed)
}
/// Unit normal (left of travel direction in y-down coordinates) of cubic `c` at `t`.
pub fn normal_at(c: &CubicBez, t: f64) -> Vec2 {
let eps = 1e-4;
let a = c.eval((t - eps).max(0.0));
let b = c.eval((t + eps).min(1.0));
let mut d = b - a;
if d.hypot() < 1e-12 {
d = c.p3 - c.p0;
}
let len = d.hypot();
if len < 1e-12 { Vec2::ZERO } else { Vec2::new(d.y / len, -d.x / len) }
}

133
crates/effects/src/warp.rs Normal file
View File

@ -0,0 +1,133 @@
//! Warp effects: 15 envelope styles plus horizontal/vertical perspective distortion.
//!
//! Each style is a map on normalised box coordinates (x, y) ∈ [-1, 1]² (y down). The path is
//! split into short pieces whose control points are mapped (see `map_nonlinear`).
use std::f64::consts::PI;
use drawcraft_geom::{PathData, Point, Rect};
use serde_json::Value;
use crate::util::*;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WarpStyle {
Arc,
ArcLower,
ArcUpper,
Arch,
Bulge,
ShellLower,
ShellUpper,
Flag,
Wave,
Fish,
Rise,
Fisheye,
Inflate,
Squeeze,
Twist,
}
impl WarpStyle {
/// From the id suffix (`arc`, `arcLower`, …).
pub fn from_id(s: &str) -> Option<Self> {
use WarpStyle::*;
Some(match s {
"arc" => Arc,
"arcLower" => ArcLower,
"arcUpper" => ArcUpper,
"arch" => Arch,
"bulge" => Bulge,
"shellLower" => ShellLower,
"shellUpper" => ShellUpper,
"flag" => Flag,
"wave" => Wave,
"fish" => Fish,
"rise" => Rise,
"fisheye" => Fisheye,
"inflate" => Inflate,
"squeeze" => Squeeze,
"twist" => Twist,
_ => return None,
})
}
}
/// The warp map on normalised coordinates: `b` = bend (-1..1), `dh`/`dv` = distortion (-1..1).
pub fn warp_point(style: WarpStyle, b: f64, dh: f64, dv: f64, x: f64, y: f64) -> (f64, f64) {
use WarpStyle::*;
let t = (y + 1.0) / 2.0; // 0 at top, 1 at bottom
let par = 1.0 - x * x; // parabola: 1 at centre, 0 at sides
let (mut x2, mut y2) = match style {
Arc => {
if b.abs() < 1e-6 {
(x, y)
} else {
// Bend the centre line into a circular arc of equal length (sweep = |b|·180°).
let sweep = b.abs() * PI;
let r0 = 2.0 / sweep;
let yy = if b > 0.0 { y } else { -y };
let a = x / r0;
let r = r0 - yy;
let (s, c) = a.sin_cos();
let (nx, ny) = (r * s, r0 - r * c);
(nx, if b > 0.0 { ny } else { -ny })
}
}
ArcLower => (x, y + b * par * t),
ArcUpper => (x, y - b * par * (1.0 - t)),
Arch => (x, y - b * par),
Bulge => (x, y + b * par * y),
ShellLower => (x * (1.0 - 0.5 * b * (1.0 - t)), y + b * par * t),
ShellUpper => (x * (1.0 - 0.5 * b * t), y - b * par * (1.0 - t)),
Flag => (x, y + 0.5 * b * (PI * x).sin()),
Wave => (x + 0.1 * b * (PI * y).sin(), y + 0.3 * b * (2.0 * PI * x).sin() * (0.5 + 0.5 * t)),
Fish => (x, y * (1.0 + 0.5 * b * (0.75 * PI * (x + 1.0)).sin())),
Rise => (x, y - b * ((x + 1.0) / 2.0).powi(2) * 2.0 + b),
Fisheye => {
let r2 = x * x + y * y;
let s = if r2 < 1.0 { 1.0 + 0.5 * b * (1.0 - r2) } else { 1.0 };
(x * s, y * s)
}
Inflate => (x * (1.0 + 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.5 * b * par)),
Squeeze => (x * (1.0 - 0.5 * b * (1.0 - y * y)), y * (1.0 + 0.3 * b * par)),
Twist => {
let r = (x * x + y * y).sqrt();
let a = -b * PI * 0.5 * (1.0 - r / std::f64::consts::SQRT_2).max(0.0);
let (s, c) = a.sin_cos();
(x * c - y * s, x * s + y * c)
}
};
// Perspective-like distortion: horizontal narrows one side, vertical one end.
if dh != 0.0 {
y2 *= (1.0 + dh * x2 * 0.5).max(0.0);
}
if dv != 0.0 {
x2 *= (1.0 + dv * y2 * 0.5).max(0.0);
}
(x2, y2)
}
/// Apply a warp to `path` using box `b`.
pub fn warp(path: &PathData, b: Rect, style: WarpStyle, p: &Value) -> PathData {
let bend = num(p, "bend", 50.0).clamp(-100.0, 100.0) / 100.0;
let dh = num(p, "horizontal", 0.0).clamp(-100.0, 100.0) / 100.0;
let dv = num(p, "vertical", 0.0).clamp(-100.0, 100.0) / 100.0;
let vertical = text(p, "orientation", "horizontal").eq_ignore_ascii_case("vertical");
let c = b.center();
let hw = (b.width() / 2.0).max(1e-9);
let hh = (b.height() / 2.0).max(1e-9);
let f = |q: Point| {
let (x, y) = ((q.x - c.x) / hw, (q.y - c.y) / hh);
let (x2, y2) = if vertical {
let (a, b2) = warp_point(style, bend, dh, dv, y, x);
(b2, a)
} else {
warp_point(style, bend, dh, dv, x, y)
};
Point::new(c.x + x2 * hw, c.y + y2 * hh)
};
let piece = b.width().hypot(b.height()) / 24.0;
map_nonlinear(path, piece, f)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,318 @@
//! Effect menu commands (live effects).
//! Effect menu commands (live effects) and Object → Expand Appearance.
//!
//! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render
//! time by `drawcraft-effects` (re-exported by the renderer).
use super::CommandSpec;
use std::sync::Arc;
use drawcraft_doc::{Node, NodeKind};
use drawcraft_geom::{FillRule, PathData};
use drawcraft_render::effects;
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![]
vec![
cmd!(
"effect.apply",
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}",
has_selection,
apply
),
cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list),
cmd!(
"effect.remove",
"Remove Effect",
[],
None,
"{index: int (position in the object's effect list), ids?: [..]} → {ids}",
has_selection,
remove
),
cmd!(
"effect.setParams",
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}",
has_selection,
set_params
),
cmd!(
"effect.expandAppearance",
"Expand Appearance",
["Object"],
None,
"{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}",
has_selection,
expand_appearance
),
]
}
fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() || p.get("id").is_some() {
return targets(s, p);
}
selected_roots(s)
}
fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let roots = target_roots(s, p)?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
let (ids, index) = s.edit(&label, |d, _| {
let mut done = vec![];
let mut index = 0;
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if n.is_layer() {
continue;
}
n.appearance.effects.push(effect.clone());
index = n.appearance.effects.len() - 1;
done.push(*id);
}
if done.is_empty() {
return Err(EngineError::Other("Apply Effect: select objects".into()));
}
Ok((done, index))
})?;
Ok(json!({ "ids": ids_json(&ids), "index": index }))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
.map(|e| json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster}))
.collect();
let mut applied = vec![];
if s.active().is_some() {
for id in target_roots(s, p)? {
if let Some(n) = s.doc()?.doc.node(id) {
applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)}));
}
}
}
Ok(json!({ "catalog": catalog, "applied": applied }))
}
fn index_param(p: &Value, cmd: &str) -> Result<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`"))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.remove";
let index = index_param(p, C)?;
let roots = target_roots(s, p)?;
let ids = s.edit("Remove Effect", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if index < n.appearance.effects.len() {
n.appearance.effects.remove(index);
done.push(*id);
}
}
if done.is_empty() {
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, C)?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let visible = p.get("visible").and_then(Value::as_bool);
let roots = target_roots(s, p)?;
let ids = s.edit("Effect Options", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
let Some(e) = n.appearance.effects.get_mut(index) else { continue };
if let (Value::Object(new), cur) = (&params, &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
if let Value::Object(m) = cur {
for (k, v) in new {
m.insert(k.clone(), v.clone());
}
}
}
if let Some(v) = visible {
e.visible = v;
}
done.push(*id);
}
if done.is_empty() {
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Geometry of a path or compound path node.
fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
/// Bake the object-level geometry effects of `id` (and, recursively, of group members).
fn expand_node(d: &mut drawcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
if let Some(children) = n.children()
&& matches!(n.kind, NodeKind::Group { .. })
{
for c in children.iter().map(|c| c.id).collect::<Vec<_>>() {
expand_node(d, c, out);
}
return;
}
if !effects::has_geometry(&n.appearance.effects) {
return;
}
let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return };
let w = n.appearance.stroke_width();
let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx);
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
let children = baked
.subpaths
.into_iter()
.map(|sp| {
let cid = d.alloc_id();
Arc::new(Node::path(cid, PathData::single(sp), Default::default()))
})
.collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path: baked, rule, live: None, clipping: false, guide: false }
};
let Some(m) = d.node_mut(id) else { return };
m.kind = kind;
m.appearance.effects.retain(|e| !effects::is_geometry(&e.id));
out.push(id);
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = target_roots(s, p)?;
let ids = s.edit("Expand Appearance", |d, _| {
let mut out = vec![];
for id in &roots {
expand_node(d, *id, &mut out);
}
if out.is_empty() {
return Err(EngineError::Other("Expand Appearance: no geometry effects to expand".into()));
}
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
#[cfg(test)]
mod tests {
use drawcraft_doc::NodeKind;
use serde_json::json;
use crate::{NodeId, Session};
fn session_with_rect() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
#[test]
fn apply_uses_catalog_defaults_and_undoes() {
let (mut s, id) = session_with_rect();
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap();
assert_eq!(r["index"], json!(0));
let n = node(&s, id);
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].params["x"], json!(3));
assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0));
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.effects.is_empty());
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
}
#[test]
fn list_remove_and_set_params() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap();
let l = s.execute("effect.list", &json!({})).unwrap();
assert!(l["catalog"].as_array().unwrap().len() >= 34);
assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2);
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap();
let e = &node(&s, id).appearance.effects[0];
assert_eq!(e.params["angle"], json!(45));
assert!(!e.visible);
s.execute("effect.remove", &json!({"index": 0})).unwrap();
let n = node(&s, id);
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow");
assert!(s.execute("effect.remove", &json!({"index": 5})).is_err());
}
#[test]
fn expand_appearance_bakes_geometry_and_keeps_raster() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.1, "{b:?}");
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].id, "stylize.dropShadow");
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }));
// Nothing left to expand.
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
}
#[test]
fn expand_transform_copies_makes_compound() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Compound { .. }));
let b = n.geometric_bounds().unwrap();
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
}

View File

@ -0,0 +1,130 @@
//! Document I/O and transactions: open/save/export by path or bytes (base64), and `command.batch`.
//!
//! These live in the engine so every frontend (UI, CLI, MCP headless) shares them.
use drawcraft_doc::Document;
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"document.open",
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index}; .drawcraft, .svg (PDF/.ai via drawcraft-pdf when available)",
always,
open
),
cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save),
cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg} → {dataBase64 | text}", has_doc, serialize),
cmd!("document.export", "Export Document", [], None, "{format: svg|png|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch),
]
}
fn load(name: &str, bytes: &[u8]) -> Result<Document> {
let lower = name.to_ascii_lowercase();
if lower.ends_with(".drawcraft") || drawcraft_format::sniff(bytes) {
return drawcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
}
if lower.ends_with(".svg") || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
let mut d = drawcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
return Ok(d);
}
Err(EngineError::Other(format!("unsupported file `{name}`")))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) {
(Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())),
(None, Some(b64)) => (
str_param(p, "name").unwrap_or("untitled.drawcraft").to_string(),
drawcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?,
None,
),
_ => return Err(bad("document.open", "give path or dataBase64")),
};
let doc = load(&name, &bytes)?;
let native = name.to_ascii_lowercase().ends_with(".drawcraft");
let i = s.add_document(doc, if native { path } else { None });
Ok(json!({ "index": i }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path")
.map(str::to_string)
.or_else(|| s.active().and_then(|d| d.path.clone()))
.ok_or_else(|| bad("document.save", "no path"))?;
let bytes = drawcraft_format::save(&s.doc()?.doc, true);
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.saved_revision = st.revision;
Ok(json!({ "path": path }))
}
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
let doc = &s.doc()?.doc;
Ok(match format {
"drawcraft" => drawcraft_format::save(doc, true),
"svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(),
"png" => {
let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), false).to_png()
}
other => return Err(EngineError::Other(format!("unknown format `{other}`"))),
})
}
fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "format").unwrap_or("drawcraft");
let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
if f == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
}
Ok(json!({ "dataBase64": drawcraft_format::base64_encode(&bytes) }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?;
let f = str_param(p, "format")
.map(str::to_string)
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into()));
let bytes = encode(s, &f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len() }))
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id == "command.batch" {
s.cancel_interaction()?;
return Err(bad("command.batch", "batches cannot nest"));
}
let params = c.get("params").cloned().unwrap_or(json!({}));
match s.execute(id, &params) {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
}
}
// Commit as one undo step (the interaction's "preview" is the batch itself).
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
it.preview = Some(("command.batch".into(), p.clone()));
}
s.commit_interaction()?;
Ok(json!({ "results": results }))
}

View File

@ -2,9 +2,9 @@
mod create;
mod draw2;
mod effectcmd;
mod xform;
mod edit;
mod effectcmd;
mod file;
mod layer;
mod object;
mod paint;
@ -12,6 +12,7 @@ mod path;
mod pathops;
mod select;
mod typecmd;
mod xform;
use drawcraft_color::Color;
use drawcraft_doc::NodeId;
@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(file::specs());
v.extend(create::specs());
v.extend(object::specs());
v.extend(path::specs());

View File

@ -1,7 +1,355 @@
//! Commands backing transform/utility tools (eyedropper, gradient, artboard…).
//! Commands backing transform/utility tools: free distort, eyedropper, gradient vector, artboard move.
use super::CommandSpec;
use std::sync::Arc;
use drawcraft_color::{Gradient, GradientGeom, GradientPaint, Paint};
use drawcraft_doc::{Appearance, Node, NodeId, NodeKind};
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![]
vec![
cmd!(
"object.distort",
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles",
has_doc,
distort
),
cmd!(
"appearance.copyFrom",
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
has_doc,
copy_from
),
cmd!(
"paint.sampleColor",
"Sample Color",
[],
None,
"{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)",
has_doc,
sample_color
),
cmd!(
"paint.setGradientGeom",
"Gradient Vector",
[],
None,
"{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)",
has_doc,
set_gradient_geom
),
cmd!(
"artboard.move",
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)",
has_doc,
artboard_move
),
]
}
// ---------- projective distort ----------
/// A 2D projective transform (homography) mapping the unit square to a quad.
#[derive(Clone, Copy, Debug)]
pub struct Projective {
m: [f64; 8],
src: Rect,
}
impl Projective {
/// Map `src`'s corners (TL, TR, BR, BL) onto `q`.
pub fn from_rect(src: Rect, q: [Point; 4]) -> Self {
let [p0, p1, p2, p3] = q;
let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x);
let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y);
let (g, h) = if dx3.abs() < 1e-12 && dy3.abs() < 1e-12 {
(0.0, 0.0)
} else {
let den = dx1 * dy2 - dx2 * dy1;
if den.abs() < 1e-12 { (0.0, 0.0) } else { ((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den) }
};
let a = p1.x - p0.x + g * p1.x;
let b = p3.x - p0.x + h * p3.x;
let d = p1.y - p0.y + g * p1.y;
let e = p3.y - p0.y + h * p3.y;
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src }
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f, g, h] = self.m;
let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 };
let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 };
let w = g * u + h * v + 1.0;
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
Point::new((a * u + b * v + c) / w, (d * u + e * v + f) / w)
}
/// Affine approximation at a point (for objects that can't be warped: text, images, symbols).
fn affine_near(&self, p: Point) -> Affine {
let eps = 1.0;
let o = self.apply(p);
let ex = self.apply(p + Vec2::new(eps, 0.0)) - o;
let ey = self.apply(p + Vec2::new(0.0, eps)) - o;
let lin = Affine::new([ex.x / eps, ex.y / eps, ey.x / eps, ey.y / eps, 0.0, 0.0]);
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
}
}
fn warp_paints(ap: &mut Appearance, pr: &Projective) {
for it in &mut ap.items {
let p = match it {
drawcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
drawcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p
&& let Some(geom) = &mut g.geom
{
geom.start = pr.apply(geom.start);
geom.end = pr.apply(geom.end);
}
}
}
fn warp_node(n: &mut Node, pr: &Projective) {
warp_paints(&mut n.appearance, pr);
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
a.p = pr.apply(a.p);
a.h_in = pr.apply(a.h_in);
a.h_out = pr.apply(a.h_out);
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
warp_node(Arc::make_mut(c), pr);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
n.transform(pr.affine_near(b.center()), false);
}
}
}
}
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
let corners: Vec<Point> = p
.get("corners")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
if corners.len() != 4 {
return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)"));
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let src = match p.get("from").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
let f: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if f.len() != 4 {
return Err(bad("object.distort", "from must be [x0,y0,x1,y1]"));
}
Rect::new(f[0], f[1], f[2], f[3])
}
_ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?,
};
if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
return Err(EngineError::Other("cannot distort a zero-size bounding box".into()));
}
let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]);
s.edit("Free Distort", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
warp_node(n, &pr);
}
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- eyedropper ----------
/// Leaves whose appearance a paint command changes (groups expand to their contents; compound
/// paths own their children's appearance).
fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
let d = &s.doc()?.doc;
let mut out = vec![];
for id in ids {
let Some(n) = d.node(*id) else { continue };
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
out.push(c.id)
}
}),
_ => out.push(*id),
}
}
let comp: Vec<NodeId> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
Ok(out)
}
fn copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind {
NodeKind::Text(t) => match t.runs.first() {
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
None => src.appearance.clone(),
},
_ => src.appearance.clone(),
};
s.paint.fill = appearance.fill_paint();
s.paint.stroke = appearance.stroke_paint();
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.fill = appearance.fill_paint();
r.style.stroke = appearance.stroke_paint();
r.style.stroke_width = appearance.stroke_width();
}
continue;
}
n.appearance = appearance.clone();
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?;
let mut q = json!({ "color": c });
if let Some(ids) = p.get("ids") {
q["ids"] = ids.clone();
}
let id = if stroke { "paint.setStroke" } else { "paint.setFill" };
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.run)(s, &q)
}
// ---------- gradient tool ----------
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?;
let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?;
let stroke = bool_or(p, "stroke", false);
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let targets = leaf_targets(s, &ids)?;
if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
s.edit("Gradient", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
if matches!(n.kind, NodeKind::Text(_)) {
continue;
}
let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
let mut gp = match cur {
Paint::Gradient(g) => *g,
_ => GradientPaint::new(Gradient::default()),
};
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
gp.geom = Some(GradientGeom { start, end, aspect });
gp.angle = GradientGeom { start, end, aspect }.angle_deg();
let paint = Paint::Gradient(Box::new(gp));
if stroke {
n.appearance.set_stroke(paint);
} else {
n.appearance.set_fill(paint);
}
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let move_art = bool_or(p, "moveArt", false);
let st = s.doc()?;
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// Top-level objects (children of layers) lying entirely inside the artboard.
let mut art = vec![];
if move_art {
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.locked {
continue;
}
if c.is_layer() {
collect(c, rect, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
{
out.push(c.id);
}
}
}
for l in &st.doc.layers {
if !l.locked {
collect(l, rect, &mut art);
}
}
}
let scale_strokes = s.prefs.scale_strokes;
s.edit("Move Artboard", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
Ok(())
})?;
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
}

View File

@ -354,4 +354,10 @@ impl Session {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_draw2;
#[cfg(test)]
mod tests_file;
#[cfg(test)]
mod tests_pathops;
#[cfg(test)]
mod tests_xform;

View File

@ -0,0 +1,391 @@
//! Drawing / path-editing tools driven through the session (tools → actions → `path.*` commands).
use drawcraft_doc::{NodeId, NodeKind};
use drawcraft_geom::{FillRule, PathData, Point};
use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use serde_json::json;
use super::*;
use crate::tooling::{UiRequest, ViewInfo};
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn view() -> ViewInfo {
ViewInfo { smart_guides: false, ..Default::default() }
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn line(s: &mut Session, x1: f64, y1: f64, x2: f64, y2: f64) -> NodeId {
let r = s.execute("shape.line", &json!({"x1": x1, "y1": y1, "x2": x2, "y2": y2})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn paths(s: &Session) -> Vec<(NodeId, PathData)> {
let mut v = vec![];
s.doc().unwrap().doc.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
v.push((n.id, path.clone()));
}
});
v
}
fn path(s: &Session, id: NodeId) -> PathData {
s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().clone()
}
fn drag(s: &mut Session, tool: &str, pts: &[(f64, f64)], up_mods: Mods) -> Vec<UiRequest> {
let v = view();
s.select_tool(tool, v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1), v).unwrap();
for &(x, y) in &pts[1..] {
s.pointer(&PointerEvent::new(PointerKind::Drag, x, y), v).unwrap();
}
let l = pts[pts.len() - 1];
s.pointer(&PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(up_mods), v).unwrap()
}
fn click(s: &mut Session, tool: &str, x: f64, y: f64) -> Vec<UiRequest> {
drag(s, tool, &[(x, y)], Mods::default())
}
/// A wavy stroke sampled every 2 pt.
fn wave(x0: f64, x1: f64, y: f64) -> Vec<(f64, f64)> {
let n = ((x1 - x0) / 2.0) as usize;
(0..=n).map(|i| x0 + i as f64 * 2.0).map(|x| (x, y + 20.0 * ((x - x0) / 40.0).sin())).collect()
}
fn near(a: Point, b: Point) -> bool {
a.distance(b) < 1e-6
}
fn area(pd: &PathData) -> f64 {
drawcraft_pathops::area(pd, FillRule::NonZero)
}
#[test]
fn pencil_fits_freehand_stroke_as_one_undo_step() {
let mut s = session();
let pts = wave(100.0, 400.0, 200.0);
let undo_before = s.doc().unwrap().history.undo.len();
drag(&mut s, "pencil", &pts, Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 1);
let p = &ps[0].1;
assert!(!p.is_closed());
assert!(p.anchor_count() >= 2 && p.anchor_count() < pts.len() / 4, "{} anchors", p.anchor_count());
let first = p.subpaths[0].anchors[0].p;
assert!(first.distance(Point::new(100.0, 200.0)) < 1e-6);
assert_eq!(s.doc().unwrap().history.undo.len(), undo_before + 1);
// Unfilled, stroked.
let n = s.doc().unwrap().doc.node(ps[0].0).unwrap().clone();
assert!(n.appearance.fill_paint().is_none());
assert!(!n.appearance.stroke_paint().is_none());
}
#[test]
fn pencil_alt_closes_path() {
let mut s = session();
let mut pts: Vec<(f64, f64)> =
(0..=36).map(|i| (i as f64 * 10.0f64).to_radians()).map(|a| (300.0 + 80.0 * a.cos(), 300.0 + 80.0 * a.sin())).collect();
pts.pop();
drag(&mut s, "pencil", &pts, Mods { alt: true, ..Default::default() });
let ps = paths(&s);
assert!(ps[0].1.is_closed());
}
#[test]
fn pencil_continues_selected_open_path() {
let mut s = session();
let id = line(&mut s, 100.0, 100.0, 200.0, 100.0);
drag(&mut s, "pencil", &[(202.0, 101.0), (230.0, 120.0), (260.0, 150.0), (300.0, 160.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 1, "no new object");
let p = path(&s, id);
assert!(p.anchor_count() >= 3);
let last = p.subpaths[0].anchors.last().unwrap().p;
assert!(last.distance(Point::new(300.0, 160.0)) < 1e-6);
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 100.0));
}
#[test]
fn paintbrush_uses_stroke_blob_brush_fills() {
let mut s = session();
drag(&mut s, "paintbrush", &wave(50.0, 200.0, 100.0), Mods::default());
let n = s.doc().unwrap().doc.node(paths(&s)[0].0).unwrap().clone();
assert!(n.appearance.fill_paint().is_none());
s.execute("select.none", &json!({})).unwrap();
drag(&mut s, "blobBrush", &[(100.0, 400.0), (200.0, 400.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 2);
let blob = &ps[1].1;
assert!(blob.is_closed());
// 100 × 10 capsule ≈ 1000 + π·25.
assert!((area(blob) - (1000.0 + std::f64::consts::PI * 25.0)).abs() < 10.0, "{}", area(blob));
let bn = s.doc().unwrap().doc.node(ps[1].0).unwrap().clone();
assert!(bn.appearance.stroke_paint().is_none() && !bn.appearance.fill_paint().is_none());
// A second overlapping stroke of the same colour merges.
drag(&mut s, "blobBrush", &[(150.0, 380.0), (150.0, 450.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 2, "merged into the first blob");
assert!(area(&ps[1].1) > 1500.0);
}
#[test]
fn curvature_clicks_build_smooth_path() {
let mut s = session();
let v = view();
s.select_tool("curvature", v).unwrap();
for (x, y) in [(100.0, 300.0), (200.0, 200.0), (300.0, 300.0)] {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
let ps = paths(&s);
assert_eq!(ps.len(), 1);
let sp = &ps[0].1.subpaths[0];
assert_eq!(sp.anchors.len(), 3);
assert_eq!(sp.anchors[1].kind, drawcraft_geom::AnchorKind::Smooth);
// Alt-click the middle point → corner.
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0).with_mods(Mods { alt: true, ..Default::default() }), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 200.0), v).unwrap();
assert!(!path(&s, ps[0].0).subpaths[0].anchors[1].has_out());
// Drag the middle point.
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 200.0, 150.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 200.0, 150.0), v).unwrap();
assert_eq!(path(&s, ps[0].0).subpaths[0].anchors[1].p, Point::new(200.0, 150.0));
// Esc ends; the next click starts a new path.
s.tool_key(ToolKey::Escape, Mods::default(), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 500.0, 500.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 500.0, 500.0), v).unwrap();
assert_eq!(paths(&s).len(), 2);
}
#[test]
fn curvature_click_first_point_closes() {
let mut s = session();
let v = view();
s.select_tool("curvature", v).unwrap();
for (x, y) in [(100.0, 100.0), (200.0, 100.0), (150.0, 200.0), (100.0, 100.0)] {
s.pointer(&PointerEvent::new(PointerKind::Down, x, y), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, x, y), v).unwrap();
}
let p = &paths(&s)[0].1;
assert!(p.is_closed());
assert_eq!(p.anchor_count(), 3);
}
#[test]
fn add_and_delete_anchor_tools() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
click(&mut s, "addAnchor", 150.0, 100.0);
let p = path(&s, id);
assert_eq!(p.anchor_count(), 5);
assert!(near(p.subpaths[0].anchors[1].p, Point::new(150.0, 100.0)));
click(&mut s, "deleteAnchor", 150.0, 100.0);
click(&mut s, "deleteAnchor", 200.0, 200.0);
let p = path(&s, id);
assert_eq!(p.anchor_count(), 3);
assert!(p.is_closed());
}
#[test]
fn delete_anchor_on_circle_keeps_curve() {
let mut s = session();
let r = s.execute("shape.ellipse", &json!({"x": 100, "y": 100, "width": 200, "height": 200})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
let before = path(&s, id);
let a = before.subpaths[0].anchors[1].p;
click(&mut s, "deleteAnchor", a.x, a.y);
let p = path(&s, id);
assert_eq!(p.anchor_count(), before.anchor_count() - 1);
// The remaining curve still bulges out towards the removed anchor.
let b = p.bounds().unwrap();
assert!(b.width() > 150.0 && b.height() > 150.0, "{b:?}");
}
#[test]
fn anchor_point_tool_converts() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
drag(&mut s, "anchorPoint", &[(100.0, 100.0), (130.0, 80.0)], Mods::default());
let a = path(&s, id).subpaths[0].anchors[0];
assert_eq!(a.kind, drawcraft_geom::AnchorKind::Smooth);
assert_eq!(a.h_out, Point::new(130.0, 80.0));
assert_eq!(a.h_in, Point::new(70.0, 120.0));
// Click the smooth anchor → corner again.
click(&mut s, "anchorPoint", 100.0, 100.0);
let a = path(&s, id).subpaths[0].anchors[0];
assert!(!a.has_in() && !a.has_out());
// Drag a segment → it bends so the grabbed point follows.
drag(&mut s, "anchorPoint", &[(150.0, 200.0), (150.0, 240.0)], Mods::default());
let p = path(&s, id);
let (_, _, _, q, d) = p.nearest(Point::new(150.0, 240.0)).unwrap();
assert!(d < 0.5, "{q:?}");
}
#[test]
fn scissors_opens_closed_path() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
click(&mut s, "scissors", 200.0, 150.0);
let p = path(&s, id);
assert!(!p.is_closed());
assert_eq!(p.anchor_count(), 6);
assert!(near(p.subpaths[0].anchors[0].p, Point::new(200.0, 150.0)));
assert!(near(p.subpaths[0].anchors[5].p, Point::new(200.0, 150.0)));
}
#[test]
fn scissors_splits_open_path_in_two() {
let mut s = session();
let id = line(&mut s, 100.0, 100.0, 300.0, 100.0);
click(&mut s, "scissors", 150.0, 100.0);
let ps = paths(&s);
assert_eq!(ps.len(), 2);
assert!(near(path(&s, id).subpaths[0].anchors[1].p, Point::new(150.0, 100.0)));
assert!(near(ps[1].1.subpaths[0].anchors[0].p, Point::new(150.0, 100.0)));
assert_eq!(ps[1].1.subpaths[0].anchors[1].p, Point::new(300.0, 100.0));
assert_eq!(s.doc().unwrap().selection.objects.len(), 2);
// Clicking an end point is refused without panicking.
assert!(s.execute("path.split", &json!({"id": id.0, "subpath": 0, "anchor": 0})).is_err());
}
#[test]
fn knife_cuts_rect_into_two_pieces() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
drag(&mut s, "knife", &[(200.0, 50.0), (210.0, 150.0), (200.0, 250.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 2);
let total: f64 = ps.iter().map(|(_, p)| area(p)).sum();
assert!((total - 20000.0).abs() < 50.0, "{total}");
assert!(ps.iter().all(|(_, p)| p.is_closed() && area(p) > 9000.0));
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(paths(&s).len(), 1);
}
#[test]
fn knife_partial_cut_does_nothing() {
let mut s = session();
let id = rect(&mut s, 100.0, 100.0, 200.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
let r = s.execute("path.knife", &json!({"points": [[200, 50], [200, 150]]})).unwrap();
assert_eq!(r["ids"], json!([]));
assert_eq!(path(&s, id).anchor_count(), 4);
}
#[test]
fn eraser_splits_shapes_and_lines() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 200.0, 100.0);
line(&mut s, 100.0, 300.0, 300.0, 300.0);
s.execute("select.none", &json!({})).unwrap();
drag(&mut s, "eraser", &[(200.0, 50.0), (200.0, 350.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 4, "two rect halves + two line pieces");
let closed: Vec<&PathData> = ps.iter().map(|p| &p.1).filter(|p| p.is_closed()).collect();
assert_eq!(closed.len(), 2);
let total: f64 = closed.iter().map(|p| area(p)).sum();
assert!((total - (20000.0 - 1000.0)).abs() < 50.0, "{total}");
let open: Vec<&PathData> = ps.iter().map(|p| &p.1).filter(|p| !p.is_closed()).collect();
let len: f64 = open.iter().map(|p| p.length()).sum();
assert!((len - 190.0).abs() < 0.1, "{len}");
}
#[test]
fn eraser_removes_small_shape_entirely() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 4.0, 4.0);
s.execute("select.none", &json!({})).unwrap();
s.execute("path.eraseRegion", &json!({"points": [[102, 102]], "size": 20})).unwrap();
assert!(paths(&s).is_empty());
}
#[test]
fn path_eraser_splits_selected_path() {
let mut s = session();
let id = line(&mut s, 100.0, 100.0, 300.0, 100.0);
drag(&mut s, "pathEraser", &[(180.0, 100.0), (220.0, 100.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 2);
assert!(path(&s, id).subpaths[0].anchors[1].p.x < 180.0);
}
#[test]
fn smooth_tool_reduces_jagged_path() {
let mut s = session();
let anchors: Vec<_> = (0..=40).map(|i| json!({"x": 100.0 + i as f64 * 5.0, "y": 200.0 + if i % 2 == 0 { 0.0 } else { 1.5 }})).collect();
let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap();
let id = NodeId(r["id"].as_u64().unwrap());
drag(&mut s, "smooth", &[(90.0, 200.0), (200.0, 200.0), (310.0, 200.0)], Mods::default());
let p = path(&s, id);
assert!(p.anchor_count() < 20, "{}", p.anchor_count());
assert_eq!(p.subpaths[0].anchors[0].p, Point::new(100.0, 200.0));
}
#[test]
fn join_tool_joins_two_lines() {
let mut s = session();
let a = line(&mut s, 100.0, 100.0, 200.0, 100.0);
line(&mut s, 205.0, 100.0, 300.0, 150.0);
s.execute("select.none", &json!({})).unwrap();
drag(&mut s, "join", &[(202.0, 90.0), (202.0, 110.0)], Mods::default());
let ps = paths(&s);
assert_eq!(ps.len(), 1);
let p = path(&s, a);
assert_eq!(p.anchor_count(), 3);
assert_eq!(p.subpaths[0].anchors[1].p, Point::new(202.5, 100.0));
assert_eq!(p.subpaths[0].anchors[2].p, Point::new(300.0, 150.0));
}
#[test]
fn line_family_tools_draw_and_click_asks_dialog() {
let mut s = session();
drag(&mut s, "arc", &[(100.0, 100.0), (200.0, 150.0)], Mods::default());
drag(&mut s, "spiral", &[(400.0, 300.0), (450.0, 300.0)], Mods::default());
drag(&mut s, "rectangularGrid", &[(100.0, 300.0), (200.0, 400.0)], Mods::default());
drag(&mut s, "polarGrid", &[(500.0, 100.0), (600.0, 200.0)], Mods::default());
let top = s.doc().unwrap().doc.layers[0].children().unwrap().len();
assert_eq!(top, 4);
let b = paths(&s)[0].1.bounds().unwrap();
assert!((b.width() - 100.0).abs() < 1e-6 && (b.height() - 50.0).abs() < 1e-6, "{b:?}");
let ui = click(&mut s, "polarGrid", 10.0, 10.0);
assert_eq!(ui, vec![UiRequest::Dialog("polarGrid".into(), json!({"x": 10.0, "y": 10.0}))]);
}
#[test]
fn draw2_commands_reject_bad_params() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0);
for (cmd, p) in [
("path.freehand", json!({})),
("path.freehand", json!({"points": [[1, 2]]})),
("path.curvature", json!({"points": []})),
("path.removeAnchor", json!({"id": id.0, "anchor": 99})),
("path.convertAnchor", json!({"id": 9999, "anchor": 0, "to": "corner"})),
("path.reshapeSegment", json!({"id": id.0, "segment": 42})),
("path.split", json!({"id": id.0})),
("path.split", json!({"id": id.0, "segment": 0, "t": "x"})),
("path.knife", json!({"points": [[0, 0]]})),
("path.eraseRegion", json!({"points": [[0, 0]], "size": -1})),
("path.blob", json!({"points": "nope"})),
("path.joinScrub", json!({"points": []})),
] {
let before = s.doc().unwrap().history.undo.len();
assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}");
assert_eq!(s.doc().unwrap().history.undo.len(), before, "{cmd} left an undo step");
}
}

View File

@ -0,0 +1,80 @@
use serde_json::json;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 300})).unwrap();
s
}
#[test]
fn batch_is_one_undo_step() {
let mut s = session();
let r = s
.execute(
"command.batch",
&json!({"label": "Logo", "commands": [
{"command": "paint.setFill", "params": {"color": "#ff0000"}},
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 50, "height": 50}},
{"command": "shape.ellipse", "params": {"x": 60, "y": 0, "width": 50, "height": 50}},
]}),
)
.unwrap();
assert_eq!(r["results"].as_array().unwrap().len(), 3);
let st = s.doc().unwrap();
assert_eq!(st.history.undo.len(), 1);
assert_eq!(st.history.undo[0].label, "Logo");
assert_eq!(st.doc.layers[0].children().unwrap().len(), 2);
assert_eq!(s.journal.iter().filter(|(c, _)| c == "command.batch").count(), 1);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 0);
}
#[test]
fn batch_rolls_back_on_error() {
let mut s = session();
let r = s.execute(
"command.batch",
&json!({"commands": [
{"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 50, "height": 50}},
{"command": "no.such.command", "params": {}},
]}),
);
assert!(r.is_err());
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 0);
assert!(s.doc().unwrap().history.undo.is_empty());
assert!(!s.in_interaction());
}
#[test]
fn serialize_and_reopen() {
let mut s = session();
s.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
let b64 = s.execute("document.serialize", &json!({"format": "drawcraft"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap()["text"].as_str().unwrap().to_string();
assert!(svg.contains("<svg"));
s.execute("document.open", &json!({"name": "copy.drawcraft", "dataBase64": b64})).unwrap();
assert_eq!(s.documents().len(), 2);
assert_eq!(s.doc().unwrap().doc.node_count(), 2);
let png = s.execute("document.serialize", &json!({"format": "png", "scale": 0.5})).unwrap()["dataBase64"].as_str().unwrap().to_string();
assert!(png.len() > 100);
}
#[test]
fn save_and_open_path() {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
let dir = std::env::temp_dir().join(format!("dc-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("t.drawcraft");
s.execute("document.save", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
s.execute("document.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.doc().unwrap().path.as_deref(), Some(path.to_string_lossy().as_ref()));
let svg = dir.join("t.svg");
s.execute("document.export", &json!({"path": svg.to_string_lossy()})).unwrap_or_default();
s.execute("document.export", &json!({"format": "svg", "path": svg.to_string_lossy()})).unwrap();
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
let _ = std::fs::remove_dir_all(dir);
}

View File

@ -0,0 +1,290 @@
//! Transform & utility tools driven through `Session::pointer`, and their commands.
use drawcraft_color::{Color, Paint};
use drawcraft_geom::{Point, Rect};
use drawcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use serde_json::json;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId {
let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap();
NodeId(r["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> Rect {
s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap()
}
fn gesture(s: &mut Session, tool: &str, pts: &[(f64, f64)], mods: Mods) -> Vec<UiRequest> {
let v = ViewInfo::default();
s.select_tool(tool, v).unwrap();
let mut out = vec![];
for (i, (x, y)) in pts.iter().enumerate() {
let kind = if i == 0 {
PointerKind::Down
} else if i == pts.len() - 1 {
PointerKind::Up
} else {
PointerKind::Drag
};
out.extend(s.pointer(&PointerEvent::new(kind, *x, *y).with_mods(mods), v).unwrap());
}
out
}
fn close(a: Rect, b: Rect) -> bool {
(a.x0 - b.x0).abs() < 1e-6 && (a.y0 - b.y0).abs() < 1e-6 && (a.x1 - b.x1).abs() < 1e-6 && (a.y1 - b.y1).abs() < 1e-6
}
fn undo_len(s: &Session) -> usize {
s.doc().unwrap().history.undo.len()
}
#[test]
fn rotate_tool_drag_rotates_about_center_one_undo() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let n = undo_len(&s);
gesture(&mut s, "rotate", &[(250.0, 125.0), (200.0, 175.0), (150.0, 225.0), (150.0, 225.0)], Mods::default());
// 90° about (150,125): 100×50 becomes 50×100.
assert!(close(bounds(&s, a), Rect::new(125.0, 75.0, 175.0, 175.0)), "{:?}", bounds(&s, a));
assert_eq!(undo_len(&s), n + 1);
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
}
#[test]
fn rotate_tool_alt_release_copies() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let alt = Mods { alt: true, ..Default::default() };
let v = ViewInfo::default();
s.select_tool("rotate", v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Down, 250.0, 125.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 150.0, 225.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 150.0, 225.0).with_mods(alt), v).unwrap();
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().len(), 2);
}
#[test]
fn rotate_tool_alt_click_requests_dialog() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 50.0);
let alt = Mods { alt: true, ..Default::default() };
let ui = gesture(&mut s, "rotate", &[(10.0, 20.0), (10.0, 20.0)], alt);
assert_eq!(ui, vec![UiRequest::Dialog("rotate".into(), json!({"angle": 0, "origin": [10.0, 20.0]}))]);
}
#[test]
fn scale_tool_click_origin_then_drag() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "scale", &[(100.0, 100.0), (100.0, 100.0)], Mods::default());
gesture(&mut s, "scale", &[(200.0, 200.0), (250.0, 250.0), (300.0, 300.0), (300.0, 300.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 300.0)), "{:?}", bounds(&s, a));
}
#[test]
fn reflect_and_shear_tools() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 50.0);
// Reflect across a vertical axis through the centre (150,125): bounds unchanged, geometry mirrored.
gesture(&mut s, "reflect", &[(150.0, 50.0), (150.0, 40.0), (150.0, 20.0), (150.0, 20.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 200.0, 150.0)));
// Shear horizontally: top edge shifts right by 25 relative to the centre row.
gesture(&mut s, "shear", &[(150.0, 100.0), (160.0, 100.0), (175.0, 100.0), (175.0, 100.0)], Mods::default());
let b = bounds(&s, a);
assert!((b.width() - 150.0).abs() < 1e-6, "{b:?}");
}
#[test]
fn distort_command_maps_corners() {
let mut s = session();
let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0);
s.execute("object.distort", &json!({"corners": [[10, 0], [90, 0], [100, 100], [0, 100]]})).unwrap();
let d = &s.doc().unwrap().doc;
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
for want in [Point::new(10.0, 0.0), Point::new(90.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)] {
assert!(pts.iter().any(|p| p.distance(want) < 1e-6), "{pts:?} missing {want:?}");
}
assert!(s.execute("object.distort", &json!({"corners": [[0, 0]]})).is_err());
s.execute("edit.undo", &json!({})).unwrap();
assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 100.0, 100.0)));
}
#[test]
fn distort_perspective_midpoint_is_projective() {
// Distort a tiny probe at the centre of a 100×100 source box into a trapezoid: with a real
// perspective warp the centre lands where the quad's diagonals cross (y = 100/6), not at y = 50.
let mut s = session();
let probe = rect(&mut s, 49.999, 49.999, 0.002, 0.002);
s.execute("object.distort", &json!({"corners": [[40, 0], [60, 0], [100, 100], [0, 100]], "from": [0, 0, 100, 100], "ids": [probe.0]})).unwrap();
let c = bounds(&s, probe).center();
assert!((c.x - 50.0).abs() < 1e-6);
assert!((c.y - 100.0 / 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn free_transform_distort_mode_via_pointer() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let v = ViewInfo::default();
s.select_tool("freeTransform", v).unwrap();
s.set_tool_option("mode", &json!("distort"));
s.pointer(&PointerEvent::new(PointerKind::Down, 200.0, 200.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Drag, 250.0, 260.0), v).unwrap();
s.pointer(&PointerEvent::new(PointerKind::Up, 250.0, 260.0), v).unwrap();
let d = &s.doc().unwrap().doc;
let pts: Vec<Point> = d.node(a).unwrap().path_data().unwrap().anchors().map(|(_, _, an)| an.p).collect();
assert!(pts.iter().any(|p| p.distance(Point::new(250.0, 260.0)) < 1e-6), "{pts:?}");
assert!(pts.iter().any(|p| p.distance(Point::new(100.0, 100.0)) < 1e-6));
assert_eq!(s.journal.last().map(|j| j.0.as_str()), Some("object.distort"));
}
#[test]
fn free_transform_side_handle_scales() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "freeTransform", &[(200.0, 150.0), (250.0, 150.0), (300.0, 150.0), (300.0, 150.0)], Mods::default());
assert!(close(bounds(&s, a), Rect::new(100.0, 100.0, 300.0, 200.0)));
}
#[test]
fn eyedropper_copies_appearance_to_selection_and_defaults() {
let mut s = session();
let src = rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("stroke.set", &json!({"weight": 4})).unwrap();
s.execute("object.setProps", &json!({"opacity": 0.5})).unwrap();
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods::default());
let d = &s.doc().unwrap().doc;
let n = d.node(dst).unwrap();
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!(n.appearance.stroke_width(), 4.0);
assert!((n.opacity - 0.5).abs() < 1e-6);
assert_eq!(s.paint.fill, Paint::solid(Color::from_hex("#ff0000").unwrap()));
assert_eq!(s.paint.stroke_width, 4.0);
let _ = src;
}
#[test]
fn eyedropper_shift_samples_color_into_active_proxy() {
let mut s = session();
rect(&mut s, 300.0, 300.0, 50.0, 50.0);
s.execute("paint.setFill", &json!({"color": "#00ff00"})).unwrap();
let dst = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
s.execute("paint.default", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [dst.0]})).unwrap();
s.execute("paint.toggleActive", &json!({})).unwrap(); // stroke active
gesture(&mut s, "eyedropper", &[(325.0, 325.0), (325.0, 325.0)], Mods { shift: true, ..Default::default() });
let n = s.doc().unwrap().doc.node(dst).unwrap().clone();
assert_eq!(n.appearance.stroke_paint(), Paint::solid(Color::from_hex("#00ff00").unwrap()));
assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::WHITE));
}
#[test]
fn gradient_tool_sets_vector_on_solid_fill() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
gesture(&mut s, "gradient", &[(100.0, 150.0), (150.0, 150.0), (200.0, 100.0), (200.0, 100.0)], Mods::default());
let n = s.doc().unwrap().doc.node(a).unwrap().clone();
let Paint::Gradient(g) = n.appearance.fill_paint() else { panic!("expected gradient") };
let geom = g.geom.unwrap();
assert_eq!(geom.start, Point::new(100.0, 150.0));
assert_eq!(geom.end, Point::new(200.0, 100.0));
assert_eq!(undo_len(&s), 2);
// Changing an existing gradient keeps its stops.
s.execute("paint.setGradientGeom", &json!({"start": [0, 0], "end": [10, 0]})).unwrap();
let Paint::Gradient(g2) = s.doc().unwrap().doc.node(a).unwrap().appearance.fill_paint() else { panic!() };
assert_eq!(g2.gradient, g.gradient);
assert!(s.execute("paint.setGradientGeom", &json!({"start": [0, 0]})).is_err());
}
#[test]
fn artboard_tool_moves_with_art_creates_and_deletes() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 50.0, 50.0);
let outside = rect(&mut s, 900.0, 100.0, 50.0, 50.0);
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "artboard", &[(400.0, 300.0), (420.0, 310.0), (450.0, 330.0), (450.0, 330.0)], Mods::default());
let d = &s.doc().unwrap().doc;
assert_eq!(d.artboards[0].rect, Rect::new(50.0, 30.0, 850.0, 630.0));
assert!(close(bounds(&s, a), Rect::new(150.0, 130.0, 200.0, 180.0)));
assert!(close(bounds(&s, outside), Rect::new(900.0, 100.0, 950.0, 150.0)));
// Draw a new artboard on the pasteboard.
gesture(&mut s, "artboard", &[(1000.0, 0.0), (1100.0, 50.0), (1200.0, 100.0), (1200.0, 100.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards.len(), 2);
assert_eq!(s.tool_options()["active"], 1);
assert_eq!(s.doc().unwrap().doc.artboards[1].rect, Rect::new(1000.0, 0.0, 1200.0, 100.0));
// Delete it.
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
// The last artboard can't be deleted.
s.tool_key(ToolKey::Delete, Mods::default(), ViewInfo::default()).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards.len(), 1);
}
#[test]
fn artboard_resize_via_handle_and_move_command() {
let mut s = session();
gesture(&mut s, "artboard", &[(800.0, 300.0), (850.0, 300.0), (900.0, 300.0), (900.0, 300.0)], Mods::default());
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(0.0, 0.0, 900.0, 600.0));
s.execute("artboard.move", &json!({"index": 0, "dx": -10, "dy": 5})).unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, Rect::new(-10.0, 5.0, 890.0, 605.0));
assert!(s.execute("artboard.move", &json!({"index": 7, "dx": 1})).is_err());
}
#[test]
fn magic_wand_selects_same_fill() {
let mut s = session();
let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0);
let b = rect(&mut s, 50.0, 10.0, 20.0, 20.0);
let c = rect(&mut s, 90.0, 10.0, 20.0, 20.0);
s.execute("paint.setFill", &json!({"color": "#ff0000", "ids": [c.0]})).unwrap();
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "magicWand", &[(20.0, 20.0), (20.0, 20.0)], Mods::default());
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
gesture(&mut s, "magicWand", &[(100.0, 20.0), (100.0, 20.0)], Mods { shift: true, ..Default::default() });
assert_eq!(s.doc().unwrap().selection.len(), 3);
}
#[test]
fn lasso_selects_anchor_subset() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0, 100.0, 100.0);
s.execute("select.none", &json!({})).unwrap();
gesture(&mut s, "lasso", &[(90.0, 90.0), (210.0, 90.0), (210.0, 110.0), (90.0, 110.0), (90.0, 110.0)], Mods::default());
let st = s.doc().unwrap();
assert_eq!(st.selection.objects, vec![a]);
assert_eq!(st.selection.partial(a).map(|p| p.len()), Some(2));
}
#[test]
fn measure_tool_leaves_document_untouched() {
let mut s = session();
rect(&mut s, 100.0, 100.0, 100.0, 100.0);
let rev = s.doc().unwrap().revision;
let n = undo_len(&s);
gesture(&mut s, "measure", &[(0.0, 0.0), (30.0, 40.0), (30.0, 40.0)], Mods::default());
assert_eq!(s.doc().unwrap().revision, rev);
assert_eq!(undo_len(&s), n);
assert!((s.tool_options()["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert!(!s.overlays(ViewInfo::default()).is_empty());
}
#[test]
fn copy_from_requires_source() {
let mut s = session();
assert!(s.execute("appearance.copyFrom", &json!({})).is_err());
assert!(s.execute("appearance.copyFrom", &json!({"source": 9999})).is_err());
}

View File

@ -49,7 +49,13 @@ struct File {
/// Serialize a document (pretty = human-diffable).
pub fn save(doc: &Document, pretty: bool) -> Vec<u8> {
let images = doc.images.iter().map(|(k, b)| (k.clone(), Image { mime: b.mime.clone(), data: base64_encode(&b.bytes) })).collect();
let f = File { format: "drawcraft".into(), version: VERSION, generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")), document: doc.clone(), images };
let f = File {
format: "drawcraft".into(),
version: VERSION,
generator: format!("DrawCraft {}", env!("CARGO_PKG_VERSION")),
document: doc.clone(),
images,
};
if pretty { serde_json::to_vec_pretty(&f).unwrap_or_default() } else { serde_json::to_vec(&f).unwrap_or_default() }
}

View File

@ -11,5 +11,13 @@ workspace = true
[dependencies]
drawcraft-geom = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-text = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
image = { workspace = true }
krilla = { workspace = true }
hayro-syntax = "0.7.2"
hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] }
log = { workspace = true }
smallvec = "1"

662
crates/pdf/src/export.rs Normal file
View File

@ -0,0 +1,662 @@
//! Document → PDF (krilla). Mirrors the tree walk of `drawcraft-render`.
use std::collections::HashMap;
use drawcraft_color::{BlendMode, Color, GradientKind, Paint};
use drawcraft_doc::{AppearanceItem, Arrowhead, Document, LineCap, LineJoin, Node, NodeKind, StrokeAlign, StrokeLayer, TextObject};
use drawcraft_geom::{Affine, BezPath, FillRule, Rect};
use krilla::color::{cmyk, luma, rgb};
use krilla::configure::{Archival, ConfigurationBuilder, PdfVersion};
use krilla::geom::{Path, PathBuilder, Size, Transform};
use krilla::image::Image;
use krilla::metadata::Metadata;
use krilla::num::NormalizedF32;
use krilla::page::PageSettings;
use krilla::paint::{Fill, LinearGradient, RadialGradient, SpreadMethod, Stop, Stroke, StrokeDash};
use krilla::surface::Surface;
use kurbo::{ParamCurve, ParamCurveDeriv, PathEl, Shape, Vec2};
use crate::{Compatibility, ExportReport, PdfError, PdfOptions};
/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`).
pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
export_with_report(doc, opts).map(|r| r.bytes)
}
/// Like [`export`], also returning warnings about approximated or dropped features.
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
if doc.artboards.is_empty() {
return Err(PdfError::NoArtboards);
}
let indices: Vec<usize> = match &opts.artboards {
Some(v) => v.clone(),
None => (0..doc.artboards.len()).collect(),
};
if indices.is_empty() {
return Err(PdfError::NoArtboards);
}
if let Some(bad) = indices.iter().find(|i| **i >= doc.artboards.len()) {
return Err(PdfError::BadArtboard(*bad));
}
let (version, archival) = match opts.compatibility {
Compatibility::Pdf14 => (PdfVersion::Pdf14, None),
Compatibility::Pdf15 => (PdfVersion::Pdf15, None),
Compatibility::Pdf16 => (PdfVersion::Pdf16, None),
Compatibility::Pdf17 => (PdfVersion::Pdf17, None),
Compatibility::Pdf20 => (PdfVersion::Pdf20, None),
Compatibility::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)),
Compatibility::PdfX4 => return Err(PdfError::Unsupported("PDF/X-4 output is not supported by the PDF writer yet".into())),
};
let mut cb = ConfigurationBuilder::new().with_version(version);
if let Some(a) = archival {
cb = cb.with_archival_validator(a);
}
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() };
let mut pdf = krilla::Document::new_with(settings);
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let mut meta = Metadata::new().creator("DrawCraft".into()).producer("DrawCraft".into());
if !title.is_empty() {
meta = meta.title(title);
}
if let Some(t) = opts.created.or_else(now_unix) {
meta = meta.creation_date(date_time(t));
}
pdf.set_metadata(meta);
let mut ex = Exporter { doc, warnings: vec![], images: HashMap::new() };
for i in indices {
let ab = &doc.artboards[i];
let r = ab.rect;
let size = Size::from_wh(r.width().max(1.0) as f32, r.height().max(1.0) as f32).ok_or(PdfError::BadArtboard(i))?;
let mut page = pdf.start_page_with(PageSettings::new(size));
let mut s = page.surface();
// krilla's page space is y-down with the origin at the top-left corner, like ours.
s.push_transform(&xf(Affine::translate((-r.x0, -r.y0))));
for layer in &doc.layers {
ex.node(&mut s, layer, r, false);
}
s.pop();
s.finish();
page.finish();
}
let bytes = pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let mut warnings = ex.warnings;
warnings.dedup();
Ok(ExportReport { bytes, warnings })
}
#[cfg(not(target_arch = "wasm32"))]
fn now_unix() -> Option<i64> {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs() as i64)
}
#[cfg(target_arch = "wasm32")]
fn now_unix() -> Option<i64> {
None
}
/// Unix seconds (UTC) → krilla date (civil-from-days, proleptic Gregorian).
fn date_time(t: i64) -> krilla::metadata::DateTime {
let days = t.div_euclid(86_400);
let secs = t.rem_euclid(86_400);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = if mp < 10 { mp + 3 } else { mp - 9 };
let year = yoe + era * 400 + i64::from(month <= 2);
krilla::metadata::DateTime::new(year.clamp(0, 9999) as u16)
.month(month as u8)
.day(day as u8)
.hour((secs / 3600) as u8)
.minute((secs / 60 % 60) as u8)
.second((secs % 60) as u8)
.utc_offset_hour(0)
.utc_offset_minute(0)
}
struct Exporter<'a> {
doc: &'a Document,
warnings: Vec<String>,
images: HashMap<String, Option<Image>>,
}
fn xf(a: Affine) -> Transform {
let c = a.as_coeffs();
Transform::from_row(c[0] as f32, c[1] as f32, c[2] as f32, c[3] as f32, c[4] as f32, c[5] as f32)
}
fn to_path(bp: &BezPath) -> Option<Path> {
let mut pb = PathBuilder::new();
for el in bp.elements() {
match *el {
PathEl::MoveTo(p) => pb.move_to(p.x as f32, p.y as f32),
PathEl::LineTo(p) => pb.line_to(p.x as f32, p.y as f32),
PathEl::QuadTo(a, p) => pb.quad_to(a.x as f32, a.y as f32, p.x as f32, p.y as f32),
PathEl::CurveTo(a, b, p) => pb.cubic_to(a.x as f32, a.y as f32, b.x as f32, b.y as f32, p.x as f32, p.y as f32),
PathEl::ClosePath => pb.close(),
}
}
pb.finish()
}
fn norm(v: f32) -> NormalizedF32 {
NormalizedF32::new(if v.is_finite() { v.clamp(0.0, 1.0) } else { 1.0 }).unwrap_or(NormalizedF32::ONE)
}
fn rule(r: FillRule) -> krilla::paint::FillRule {
match r {
FillRule::NonZero => krilla::paint::FillRule::NonZero,
FillRule::EvenOdd => krilla::paint::FillRule::EvenOdd,
}
}
fn blend(b: BlendMode) -> krilla::blend::BlendMode {
use krilla::blend::BlendMode as K;
match b {
BlendMode::Normal => K::Normal,
BlendMode::Darken => K::Darken,
BlendMode::Multiply => K::Multiply,
BlendMode::ColorBurn => K::ColorBurn,
BlendMode::Lighten => K::Lighten,
BlendMode::Screen => K::Screen,
BlendMode::ColorDodge => K::ColorDodge,
BlendMode::Overlay => K::Overlay,
BlendMode::SoftLight => K::SoftLight,
BlendMode::HardLight => K::HardLight,
BlendMode::Difference => K::Difference,
BlendMode::Exclusion => K::Exclusion,
BlendMode::Hue => K::Hue,
BlendMode::Saturation => K::Saturation,
BlendMode::Color => K::Color,
BlendMode::Luminosity => K::Luminosity,
}
}
fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
fn color(c: &Color) -> krilla::color::Color {
match *c {
Color::Rgb { r, g, b } => rgb::Color::new(q(r), q(g), q(b)).into(),
Color::Cmyk { c, m, y, k } => cmyk::Color::new(q(c), q(m), q(y), q(k)).into(),
// DrawCraft grey is ink coverage (0 = white); PDF DeviceGray is lightness.
Color::Gray { k } => luma::Color::new(q(1.0 - k)).into(),
}
}
fn rects_overlap(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
impl Exporter<'_> {
fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
if !self.warnings.contains(&w) {
self.warnings.push(w);
}
}
/// Convert a paint. `bounds` resolves unset gradient geometry (like the renderer).
fn paint(&mut self, p: &Paint, bounds: Rect) -> Option<krilla::paint::Paint> {
match p {
Paint::None => None,
Paint::Solid { color: c, .. } => Some(color(c).into()),
Paint::Gradient(g) => {
let geom = g.resolve(bounds);
let mut stops: Vec<Stop> = Vec::new();
let mut last = 0.0f32;
for (o, c, a) in g.gradient.expanded_stops() {
let o = o.clamp(last, 1.0);
last = o;
stops.push(Stop { offset: norm(o), color: color(&c), opacity: norm(a) });
}
if stops.is_empty() {
return None;
}
match g.gradient.kind {
GradientKind::Linear => {
let (s, mut e) = (geom.start, geom.end);
if s.distance(e) < 1e-9 {
e = s + Vec2::new(1.0, 0.0);
}
Some(
LinearGradient {
x1: s.x as f32,
y1: s.y as f32,
x2: e.x as f32,
y2: e.y as f32,
transform: Transform::identity(),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into(),
)
}
GradientKind::Radial => {
let r = geom.length().max(1e-6) as f32;
let angle = (geom.end - geom.start).atan2();
let t = Affine::translate(geom.start.to_vec2())
* Affine::rotate(angle)
* Affine::scale_non_uniform(1.0, geom.aspect.max(1e-3))
* Affine::rotate(-angle)
* Affine::translate(-geom.start.to_vec2());
let (cx, cy) = (geom.start.x as f32, geom.start.y as f32);
Some(
RadialGradient {
fx: cx,
fy: cy,
fr: 0.0,
cx,
cy,
cr: r,
transform: xf(t),
spread_method: SpreadMethod::Pad,
stops,
anti_alias: false,
}
.into(),
)
}
GradientKind::Freeform => {
self.warn("freeform gradients are exported as their average colour");
let n = g.gradient.stops.len().max(1) as f32;
let mut acc = [0.0f32; 3];
for s in &g.gradient.stops {
let c = s.color.to_rgb();
for i in 0..3 {
acc[i] += c[i] / n;
}
}
Some(color(&Color::rgb(acc[0], acc[1], acc[2])).into())
}
}
}
Paint::Pattern { .. } => {
self.warn("pattern fills are exported as mid-grey");
Some(rgb::Color::new(128, 128, 128).into())
}
}
}
fn node(&mut self, s: &mut Surface, n: &Node, page: Rect, force: bool) {
if !force && !n.visible {
return;
}
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
match n.visual_bounds() {
Some(b) if !force && !rects_overlap(b.inflate(1.0, 1.0), page) => return,
None if !n.is_container() => return,
_ => {}
}
if !n.appearance.effects.is_empty() {
self.warn("live effects are not exported to PDF yet");
}
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
let mut pushes = 0;
if n.blend != BlendMode::Normal {
s.push_blend_mode(blend(n.blend));
pushes += 1;
}
if n.opacity < 1.0 {
s.push_opacity(norm(n.opacity));
pushes += 1;
} else if container && (n.isolate || n.blend != BlendMode::Normal) {
s.push_isolated();
pushes += 1;
}
if n.knockout {
self.warn("knockout groups are exported as normal groups");
}
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
for c in children {
self.node(s, c, page, false);
}
}
NodeKind::Group { children, clip: true } => {
let clip = children.first().and_then(|c| match &c.kind {
NodeKind::Path { path, rule, .. } => Some((path.to_bezpath(), *rule)),
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for ch in children {
if let Some(p) = ch.path_data() {
bp.extend(p.to_bezpath());
}
}
Some((bp, *rule))
}
_ => None,
});
match clip.and_then(|(bp, r)| to_path(&bp).map(|p| (p, r))) {
Some((p, r)) => {
s.push_clip_path(&p, &rule(r));
for c in children.iter().skip(1) {
self.node(s, c, page, false);
}
s.pop();
}
None => {
for c in children {
self.node(s, c, page, false);
}
}
}
}
NodeKind::Path { path, rule, guide, .. } => {
if !*guide {
self.shape(s, n, &path.to_bezpath(), *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
self.shape(s, n, &bp, *rule);
}
NodeKind::Text(t) => self.text(s, n, t),
NodeKind::Image(im) => self.image(s, im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|x| &x.name == symbol) {
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.node(s, &art, page, true);
}
}
}
for _ in 0..pushes {
s.pop();
}
}
fn shape(&mut self, s: &mut Surface, n: &Node, bp: &BezPath, r: FillRule) {
let Some(path) = to_path(bp) else { return };
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
if !fl.effects.is_empty() {
self.warn("live effects are not exported to PDF yet");
}
let Some(paint) = self.paint(&fl.paint, bounds) else { continue };
let bl = fl.blend != BlendMode::Normal;
if bl {
s.push_blend_mode(blend(fl.blend));
}
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: rule(r) }));
s.draw_path(&path);
if bl {
s.pop();
}
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.stroke(s, bp, &path, r, st, bounds);
}
}
}
s.set_fill(None);
s.set_stroke(None);
}
fn stroke(&mut self, s: &mut Surface, bp: &BezPath, path: &Path, r: FillRule, st: &StrokeLayer, bounds: Rect) {
if st.profile.is_some() || st.brush.is_some() {
self.warn("variable-width profiles and brushes are exported as uniform strokes");
}
let Some(paint) = self.paint(&st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0)) else { return };
let closed = bp.elements().last().is_some_and(|e| matches!(e, PathEl::ClosePath));
let mut pushes = 0;
if st.blend != BlendMode::Normal {
s.push_blend_mode(blend(st.blend));
pushes += 1;
}
let width = match st.align {
StrokeAlign::Center => st.width,
_ if closed => st.width * 2.0,
_ => st.width,
};
match st.align {
StrokeAlign::Inside if closed => {
s.push_clip_path(path, &rule(r));
pushes += 1;
}
StrokeAlign::Outside if closed => {
// Clip to everything outside the path: a big frame plus the path, even-odd.
let big = bounds.inflate(width * 2.0 + 10.0, width * 2.0 + 10.0);
let mut outside = big.to_path(0.1);
outside.extend(bp.iter());
if let Some(p) = to_path(&outside) {
s.push_clip_path(&p, &krilla::paint::FillRule::EvenOdd);
pushes += 1;
}
}
_ => {}
}
let dash = st.dash.as_ref().filter(|d| d.pattern.iter().any(|v| *v > 0.0)).map(|d| {
let mut pat: Vec<f32> = d.pattern.iter().map(|v| v.max(0.0) as f32).collect();
if pat.len() % 2 == 1 {
pat.extend(pat.clone());
}
StrokeDash { array: pat, offset: d.offset as f32 }
});
s.set_fill(None);
s.set_stroke(Some(Stroke {
paint: paint.clone(),
width: width as f32,
miter_limit: st.miter_limit.max(1.0) as f32,
line_cap: match st.cap {
LineCap::Butt => krilla::paint::LineCap::Butt,
LineCap::Round => krilla::paint::LineCap::Round,
LineCap::Square => krilla::paint::LineCap::Square,
},
line_join: match st.join {
LineJoin::Miter => krilla::paint::LineJoin::Miter,
LineJoin::Round => krilla::paint::LineJoin::Round,
LineJoin::Bevel => krilla::paint::LineJoin::Bevel,
},
opacity: norm(st.opacity),
dash,
}));
s.draw_path(path);
s.set_stroke(None);
// Pop the alignment clip before drawing arrowheads.
if matches!(st.align, StrokeAlign::Inside | StrokeAlign::Outside) && closed && pushes > 0 {
s.pop();
pushes -= 1;
}
if st.start_arrow.is_some() || st.end_arrow.is_some() {
for head in arrowheads(bp, st) {
if let Some(p) = to_path(&head) {
s.set_fill(Some(Fill { paint: paint.clone(), opacity: norm(st.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&p);
}
}
s.set_fill(None);
}
for _ in 0..pushes {
s.pop();
}
}
fn text(&mut self, s: &mut Surface, n: &Node, t: &TextObject) {
let layout = drawcraft_text::layout(drawcraft_text::FontDb::global(), t);
let tb = t.xf.transform_rect_bbox(layout.bounds);
s.push_transform(&xf(t.xf));
for (i, run) in t.runs.iter().enumerate() {
let mut bp = BezPath::new();
for g in layout.glyphs.iter().filter(|g| g.run == i) {
bp.extend(g.outline.iter());
}
let Some(path) = to_path(&bp) else { continue };
if let Some(paint) = self.paint(&run.style.fill, layout.bounds) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: NormalizedF32::ONE, rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
if run.style.stroke_width > 0.0
&& let Some(paint) = self.paint(&run.style.stroke, layout.bounds)
{
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: run.style.stroke_width as f32, ..Default::default() }));
s.draw_path(&path);
}
}
s.set_fill(None);
s.set_stroke(None);
s.pop();
if !n.appearance.items.is_empty() {
let mut all = layout.to_bezpath();
all.apply_affine(t.xf);
let Some(path) = to_path(&all) else { return };
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(fl) if fl.visible => {
if let Some(paint) = self.paint(&fl.paint, tb) {
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity: norm(fl.opacity), rule: krilla::paint::FillRule::NonZero }));
s.draw_path(&path);
}
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 => {
if let Some(paint) = self.paint(&st.paint, tb) {
s.set_fill(None);
s.set_stroke(Some(Stroke { paint, width: st.width as f32, opacity: norm(st.opacity), ..Default::default() }));
s.draw_path(&path);
}
}
_ => {}
}
}
s.set_fill(None);
s.set_stroke(None);
}
}
fn load_image(&mut self, key: &str) -> Option<Image> {
if let Some(i) = self.images.get(key) {
return i.clone();
}
let img = self.doc.images.get(key).and_then(|blob| {
let bytes = blob.bytes.as_ref().clone();
let direct = match blob.mime.as_str() {
"image/png" => Image::from_png(bytes.clone().into(), true).ok(),
"image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), true).ok(),
"image/gif" => Image::from_gif(bytes.clone().into(), true).ok(),
"image/webp" => Image::from_webp(bytes.clone().into(), true).ok(),
_ => None,
};
direct.or_else(|| {
let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8();
let (w, h) = rgba.dimensions();
Some(Image::from_rgba8(rgba.into_raw(), w, h))
})
});
if img.is_none() {
self.warn(format!("image '{key}' could not be decoded and was skipped"));
}
self.images.insert(key.to_string(), img.clone());
img
}
fn image(&mut self, s: &mut Surface, im: &drawcraft_doc::ImageObject) {
let Some(img) = self.load_image(&im.key) else { return };
let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return };
s.push_transform(&xf(im.xf));
s.draw_image(img, size);
s.pop();
}
}
/// Arrowhead outlines for a stroke (same geometry as the renderer).
fn arrowheads(bp: &BezPath, st: &StrokeLayer) -> Vec<BezPath> {
let segs: Vec<kurbo::PathSeg> = bp.segments().collect();
let mut out = Vec::new();
let (Some(first), Some(last)) = (segs.first(), segs.last()) else { return out };
if let Some(a) = st.start_arrow {
out.push(head(a, first.eval(0.0), tangent(first, 0.0) * -1.0, st.width * st.arrow_scale.0 / 100.0));
}
if let Some(a) = st.end_arrow {
out.push(head(a, last.eval(1.0), tangent(last, 1.0), st.width * st.arrow_scale.1 / 100.0));
}
out
}
fn tangent(s: &kurbo::PathSeg, t: f64) -> Vec2 {
let d = match s {
kurbo::PathSeg::Line(l) => l.p1 - l.p0,
kurbo::PathSeg::Quad(q) => q.deriv().eval(t).to_vec2(),
kurbo::PathSeg::Cubic(c) => {
let v = c.deriv().eval(t).to_vec2();
if v.hypot() < 1e-9 { c.p3 - c.p0 } else { v }
}
};
let l = d.hypot();
if l < 1e-12 { Vec2::new(1.0, 0.0) } else { d / l }
}
fn head(kind: Arrowhead, tip: kurbo::Point, dir: Vec2, w: f64) -> BezPath {
let w = w.max(0.25);
let n = Vec2::new(-dir.y, dir.x);
let len = 4.0 * w;
let half = 2.0 * w;
let base = tip - dir * len;
let mut p = BezPath::new();
match kind {
Arrowhead::Triangle | Arrowhead::TriangleOpen | Arrowhead::Arrow | Arrowhead::ArrowOpen => {
p.move_to(tip);
p.line_to(base + n * half);
if matches!(kind, Arrowhead::Arrow | Arrowhead::ArrowOpen) {
p.line_to(tip - dir * (len * 0.7));
}
p.line_to(base - n * half);
p.close_path();
}
Arrowhead::Circle | Arrowhead::CircleOpen => {
p = kurbo::Circle::new(tip - dir * half, half).to_path(0.01);
}
Arrowhead::Square | Arrowhead::SquareOpen => {
let c = tip - dir * half;
p.move_to(c + dir * half + n * half);
p.line_to(c - dir * half + n * half);
p.line_to(c - dir * half - n * half);
p.line_to(c + dir * half - n * half);
p.close_path();
}
Arrowhead::Diamond => {
let c = tip - dir * half;
p.move_to(tip);
p.line_to(c + n * half);
p.line_to(c - dir * half);
p.line_to(c - n * half);
p.close_path();
}
Arrowhead::Bar => {
let t = w * 0.75;
p.move_to(tip + n * half);
p.line_to(tip + n * half - dir * t);
p.line_to(tip - n * half - dir * t);
p.line_to(tip - n * half);
p.close_path();
}
}
p
}

650
crates/pdf/src/import.rs Normal file
View File

@ -0,0 +1,650 @@
//! PDF → Document (hayro-interpret device that builds a DrawCraft node tree).
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use drawcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
use drawcraft_doc::{
Appearance, AppearanceItem, Artboard, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind,
StrokeLayer,
};
use drawcraft_geom::{FillRule, PathData};
use hayro_interpret::font::Glyph;
use hayro_interpret::pattern::Pattern;
use hayro_interpret::shading::ShadingType;
use hayro_interpret::{
ClipPath, Context, Device, GlyphDrawMode, Image, ImageData, InterpreterCache, InterpreterSettings, InterpreterWarning, LumaData, PathDrawMode,
SoftMask, StrokeProps, interpret_page,
};
use hayro_syntax::Pdf;
use hayro_syntax::object::Name;
use kurbo::{Affine, BezPath, Point, Rect, Shape, Vec2};
use crate::{ImportOptions, ImportReport, PdfError};
/// Import a PDF (or PDF-compatible `.ai`) with default options.
pub fn import(bytes: &[u8]) -> Result<Document, PdfError> {
import_with_report(bytes, &ImportOptions::default()).map(|r| r.document)
}
/// Import a PDF, returning the document plus warnings about content that was approximated or skipped.
pub fn import_with_report(bytes: &[u8], opts: &ImportOptions) -> Result<ImportReport, PdfError> {
let pdf = Pdf::new(bytes.to_vec()).map_err(|e| PdfError::Parse(format!("{e:?}")))?;
let pages = pdf.pages();
let n = opts.max_pages.map_or(pages.len(), |m| m.min(pages.len()));
if n == 0 {
return Err(PdfError::NoPages);
}
let sink: Arc<Mutex<Vec<String>>> = Arc::default();
let sink2 = sink.clone();
let settings = InterpreterSettings {
warning_sink: Arc::new(move |w| {
let msg = match w {
InterpreterWarning::UnsupportedFont => "a font could not be read; its text was skipped",
InterpreterWarning::ImageDecodeFailure => "an image could not be decoded and was skipped",
};
if let Ok(mut v) = sink2.lock()
&& !v.iter().any(|x| x == msg)
{
v.push(msg.to_string());
}
}),
render_annotations: false,
..Default::default()
};
let mut doc = Document::new(1.0, 1.0);
doc.title = "Imported PDF".into();
doc.artboards.clear();
doc.layers.clear();
let mut b = Builder::new(doc.peek_next_id());
let cache = InterpreterCache::new();
let mut x = 0.0;
for (i, page) in pages.iter().take(n).enumerate() {
let (w, h) = page.render_dimensions();
let (w, h) = (w as f64, h as f64);
let ab = Rect::new(x, 0.0, x + w, h);
x += w + opts.artboard_gap;
doc.artboards.push(Artboard {
id: i as u32 + 1,
name: format!("Artboard {}", i + 1),
rect: ab,
show_center_mark: false,
show_cross_hairs: false,
});
let init = Affine::translate((ab.x0, ab.y0)) * Affine::new(page.initial_transform(true).as_coeffs());
let mut ctx = Context::new(init, ab, &cache, pdf.xref(), settings.clone());
b.page = ab;
b.begin_page();
interpret_page(page, &mut ctx, &mut b);
let children = b.end_page();
let mut layer = Node::layer(b.id(), &format!("Page {}", i + 1), LayerColor::Preset((i % 27) as u8));
if let NodeKind::Layer { children: c, .. } = &mut layer.kind {
*c = children;
}
doc.layers.push(Arc::new(layer));
}
for (k, blob) in b.images.drain() {
doc.images.insert(k, blob);
}
doc.fix_next_id();
let mut warnings = b.warnings;
if let Ok(v) = sink.lock() {
for w in v.iter() {
if !warnings.contains(w) {
warnings.push(w.clone());
}
}
}
Ok(ImportReport { document: doc, warnings })
}
/// Decoded RGBA pixels, width, height and hayro's scale factors.
type Decoded = (Vec<u8>, u32, u32, (f32, f32));
enum FrameKind {
Root,
/// A clip group whose clip path node is stored here.
Clip(Box<Node>),
/// A clip that was redundant (covers the page); children go straight to the parent.
Skip,
Group {
opacity: f32,
blend: BlendMode,
masked: bool,
},
}
struct Frame {
kind: FrameKind,
children: Vec<Arc<Node>>,
}
/// Glyphs drawn consecutively with the same paint, merged into one path.
struct GlyphRun {
path: BezPath,
paint: Paint,
opacity: f32,
stroke: Option<StrokeProps>,
scale: f64,
}
struct Builder {
next: u64,
stack: Vec<Frame>,
blend: BlendMode,
page: Rect,
glyphs: Option<GlyphRun>,
images: HashMap<String, ImageBlob>,
image_keys: HashMap<u128, (String, u32, u32)>,
warnings: Vec<String>,
}
fn blend(b: hayro_interpret::BlendMode) -> BlendMode {
use hayro_interpret::BlendMode as H;
match b {
H::Normal => BlendMode::Normal,
H::Multiply => BlendMode::Multiply,
H::Screen => BlendMode::Screen,
H::Overlay => BlendMode::Overlay,
H::Darken => BlendMode::Darken,
H::Lighten => BlendMode::Lighten,
H::ColorDodge => BlendMode::ColorDodge,
H::ColorBurn => BlendMode::ColorBurn,
H::HardLight => BlendMode::HardLight,
H::SoftLight => BlendMode::SoftLight,
H::Difference => BlendMode::Difference,
H::Exclusion => BlendMode::Exclusion,
H::Hue => BlendMode::Hue,
H::Saturation => BlendMode::Saturation,
H::Color => BlendMode::Color,
H::Luminosity => BlendMode::Luminosity,
}
}
fn fill_rule(r: hayro_interpret::FillRule) -> FillRule {
match r {
hayro_interpret::FillRule::NonZero => FillRule::NonZero,
hayro_interpret::FillRule::EvenOdd => FillRule::EvenOdd,
}
}
fn mean_scale(a: Affine) -> f64 {
a.determinant().abs().sqrt()
}
fn stroke_layer(paint: Paint, opacity: f32, p: &StrokeProps, scale: f64) -> StrokeLayer {
let mut st = StrokeLayer::new(paint, p.line_width as f64 * scale);
st.opacity = opacity;
st.cap = match p.line_cap {
kurbo::Cap::Butt => LineCap::Butt,
kurbo::Cap::Round => LineCap::Round,
kurbo::Cap::Square => LineCap::Square,
};
st.join = match p.line_join {
kurbo::Join::Miter => LineJoin::Miter,
kurbo::Join::Round => LineJoin::Round,
kurbo::Join::Bevel => LineJoin::Bevel,
};
st.miter_limit = p.miter_limit as f64;
if p.dash_array.iter().any(|v| *v > 0.0) {
st.dash = Some(Dash {
pattern: p.dash_array.iter().map(|v| *v as f64 * scale).collect(),
offset: p.dash_offset as f64 * scale,
align_corners: false,
});
}
st
}
fn round3(v: f32) -> f32 {
(v * 1000.0).round() / 1000.0
}
impl Builder {
fn new(next: u64) -> Self {
Self {
next,
stack: vec![],
blend: BlendMode::Normal,
page: Rect::ZERO,
glyphs: None,
images: HashMap::new(),
image_keys: HashMap::new(),
warnings: vec![],
}
}
fn id(&mut self) -> NodeId {
let id = NodeId(self.next);
self.next += 1;
id
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn begin_page(&mut self) {
self.stack = vec![Frame { kind: FrameKind::Root, children: vec![] }];
self.blend = BlendMode::Normal;
self.glyphs = None;
}
fn end_page(&mut self) -> Vec<Arc<Node>> {
self.flush_glyphs();
while self.stack.len() > 1 {
self.pop_frame();
}
self.stack.pop().map(|f| f.children).unwrap_or_default()
}
fn push_node(&mut self, mut n: Node) {
if self.blend != BlendMode::Normal && !n.is_container() {
n.blend = self.blend;
}
if let Some(f) = self.stack.last_mut() {
f.children.push(Arc::new(n));
}
}
fn pop_frame(&mut self) {
if self.stack.len() <= 1 {
return;
}
let f = self.stack.pop().expect("frame");
let node = match f.kind {
FrameKind::Root => None,
FrameKind::Skip => {
if let Some(p) = self.stack.last_mut() {
p.children.extend(f.children);
}
None
}
FrameKind::Clip(clip) => {
if f.children.is_empty() {
None
} else {
let mut ch = vec![Arc::new(*clip)];
ch.extend(f.children);
Some(Node::new(self.id(), NodeKind::Group { children: ch, clip: true }))
}
}
FrameKind::Group { opacity, blend, masked } => {
if f.children.is_empty() {
None
} else if opacity >= 0.999 && blend == BlendMode::Normal && !masked {
if let Some(p) = self.stack.last_mut() {
p.children.extend(f.children);
}
None
} else if f.children.len() == 1 && !f.children[0].is_container() && !masked {
// A single object in a group: fold opacity/blend into the object itself.
let mut only = (*f.children[0]).clone();
only.opacity *= opacity;
if blend != BlendMode::Normal {
only.blend = blend;
}
Some(only)
} else {
let mut g = Node::group(self.id(), f.children);
g.opacity = opacity;
g.blend = blend;
Some(g)
}
}
};
if let Some(n) = node
&& let Some(p) = self.stack.last_mut()
{
p.children.push(Arc::new(n));
}
}
fn flush_glyphs(&mut self) {
let Some(run) = self.glyphs.take() else { return };
if run.path.elements().is_empty() {
return;
}
let mut ap = Appearance::default();
match &run.stroke {
None => {
let mut f = FillLayer::new(run.paint);
f.opacity = run.opacity;
ap.items.push(AppearanceItem::Fill(f));
}
Some(p) => ap.items.push(AppearanceItem::Stroke(stroke_layer(run.paint, run.opacity, p, run.scale))),
}
let id = self.id();
let mut n = Node::path(id, PathData::from_bezpath(&run.path), ap);
n.name = Some("<Text Outlines>".into());
self.push_node(n);
}
/// Convert a hayro paint to a DrawCraft paint and opacity. `bbox` is the painted area in
/// document coordinates (for patterns).
fn paint(&mut self, p: &hayro_interpret::Paint<'_>) -> (Paint, f32) {
match p {
hayro_interpret::Paint::Color(c) => {
let [r, g, b, a] = c.to_rgba().components();
(Paint::solid(Color::rgb(round3(r), round3(g), round3(b))), a)
}
hayro_interpret::Paint::Pattern(pat) => match pat.as_ref() {
Pattern::Shading(sp) => match shading_gradient(sp) {
Some(g) => (Paint::Gradient(Box::new(g)), sp.opacity),
None => {
self.warn("mesh/function shadings are imported as a flat colour");
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), sp.opacity)
}
},
Pattern::Tiling(_) => {
self.warn("tiling patterns are imported as a flat grey");
(Paint::solid(Color::rgb(0.5, 0.5, 0.5)), 1.0)
}
},
}
}
fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
let (k, w, h) = if let Some(v) = self.image_keys.get(&key).cloned() {
v
} else {
let Some((blob, w, h)) = make() else {
self.warn("an image could not be decoded and was skipped");
return;
};
let k = format!("pdf-image-{}", self.image_keys.len() + 1);
self.images.insert(k.clone(), blob);
self.image_keys.insert(key, (k.clone(), w, h));
(k, w, h)
};
let id = self.id();
self.push_node(Node::new(id, NodeKind::Image(ImageObject { key: k, width: w, height: h, xf, link: None })));
}
}
/// Axial/radial shading → gradient paint (stops sampled from the shading function).
fn shading_gradient(sp: &hayro_interpret::pattern::ShadingPattern) -> Option<GradientPaint> {
let ShadingType::RadialAxial { coords, domain, function, axial, .. } = sp.shading.shading_type.as_ref() else {
return None;
};
let m = sp.matrix;
let cs = &sp.shading.color_space;
let sample = |t: f32| -> Option<(Color, f32)> {
let x = domain[0] + (domain[1] - domain[0]) * t;
let v = function.eval(&smallvec::smallvec![x])?;
let [r, g, b, a] = cs.to_rgba(&v, 1.0, false).components();
Some((Color::rgb(round3(r), round3(g), round3(b)), a))
};
// Sample densely, then drop samples that linear interpolation reproduces.
const N: usize = 64;
let mut pts: Vec<(f32, [f32; 3])> = Vec::with_capacity(N + 1);
for i in 0..=N {
let t = i as f32 / N as f32;
let (c, _) = sample(t)?;
pts.push((t, c.to_rgb()));
}
let mut keep = vec![0usize];
for i in 1..N {
let a = pts[*keep.last().expect("non-empty")];
let b = pts[i + 1];
let u = (pts[i].0 - a.0) / (b.0 - a.0).max(1e-6);
let off = (0..3).map(|k| (a.1[k] + (b.1[k] - a.1[k]) * u - pts[i].1[k]).abs()).fold(0.0f32, f32::max);
if off > 1.5 / 255.0 {
keep.push(i);
}
}
keep.push(N);
let stop_at = |t: f32, offset: f32| -> GradientStop {
let (color, _) = sample(t).unwrap_or((Color::BLACK, 1.0));
GradientStop { offset, color, opacity: 1.0, midpoint: 0.5 }
};
let c = |x: f32, y: f32| m * Point::new(x as f64, y as f64);
let (kind, geom, stops) = if *axial {
let geom = GradientGeom { start: c(coords[0], coords[1]), end: c(coords[2], coords[3]), aspect: 1.0 };
(GradientKind::Linear, geom, keep.iter().map(|&i| stop_at(pts[i].0, pts[i].0)).collect::<Vec<_>>())
} else {
let (r0, r1) = (coords[2].max(0.0), coords[5].max(1e-6));
let centre = c(coords[3], coords[4]);
let k = m.as_coeffs();
let ex = Vec2::new(k[0], k[1]) * r1 as f64;
let ey = Vec2::new(k[2], k[3]) * r1 as f64;
let aspect = if ex.hypot() > 1e-9 { ey.hypot() / ex.hypot() } else { 1.0 };
let geom = GradientGeom { start: centre, end: centre + ex, aspect };
// Offsets are relative to the outer radius; an inner radius shifts them outwards.
let stops = keep.iter().map(|&i| stop_at(pts[i].0, (r0 + (r1 - r0) * pts[i].0) / r1)).collect();
(GradientKind::Radial, geom, stops)
};
let mut g = GradientPaint::new(Gradient { kind, stops });
g.geom = Some(geom);
Some(g)
}
fn rgba_png(rgba: Vec<u8>, w: u32, h: u32) -> Option<Vec<u8>> {
let img = image::RgbaImage::from_raw(w, h, rgba)?;
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
}
fn resize_alpha(a: &LumaData, w: u32, h: u32) -> Vec<u8> {
if a.width == w && a.height == h {
return a.data.clone();
}
match image::GrayImage::from_raw(a.width, a.height, a.data.clone()) {
Some(g) => image::imageops::resize(&g, w, h, image::imageops::FilterType::Triangle).into_raw(),
None => vec![255; (w * h) as usize],
}
}
impl<'a> Device<'a> for Builder {
fn set_soft_mask(&mut self, mask: Option<SoftMask<'a>>) {
if mask.is_some() {
self.warn("soft masks are not imported (content drawn unmasked)");
}
}
fn set_blend_mode(&mut self, blend_mode: hayro_interpret::BlendMode) {
self.blend = blend(blend_mode);
}
fn draw_path(&mut self, path: &BezPath, transform: Affine, paint: &hayro_interpret::Paint<'a>, draw_mode: &PathDrawMode) {
self.flush_glyphs();
let mut bp = path.clone();
bp.apply_affine(transform);
if bp.elements().is_empty() {
return;
}
let pd = PathData::from_bezpath(&bp);
let (paint, opacity) = self.paint(paint);
match draw_mode {
PathDrawMode::Fill(rule) => {
let mut f = FillLayer::new(paint);
f.opacity = opacity;
let mut n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Fill(f)], effects: vec![] });
if let NodeKind::Path { rule: r, .. } = &mut n.kind {
*r = fill_rule(*rule);
}
self.push_node(n);
}
PathDrawMode::Stroke(props) => {
let st = stroke_layer(paint, opacity, props, mean_scale(transform));
// Fill-then-stroke of the same path (the `B` operator) becomes one object.
let blend = self.blend;
if let Some(f) = self.stack.last_mut()
&& let Some(last) = f.children.last_mut()
&& last.blend == blend
&& last.path_data() == Some(&pd)
&& last.appearance.stroke().is_none()
{
Arc::make_mut(last).appearance.items.push(AppearanceItem::Stroke(st));
return;
}
let n = Node::path(self.id(), pd, Appearance { items: vec![AppearanceItem::Stroke(st)], effects: vec![] });
self.push_node(n);
}
}
}
fn push_clip_path(&mut self, clip_path: &ClipPath) {
self.flush_glyphs();
let bb = clip_path.path.bounding_box();
// Clips that contain the whole page do nothing visible; don't create groups for them.
let redundant = clip_path.path.elements().len() <= 6
&& bb.x0 <= self.page.x0 + 0.01
&& bb.y0 <= self.page.y0 + 0.01
&& bb.x1 >= self.page.x1 - 0.01
&& bb.y1 >= self.page.y1 - 0.01
&& self.page.area() > 0.0;
let kind = if redundant {
FrameKind::Skip
} else {
let mut clip = Node::new(
self.id(),
NodeKind::Path {
path: PathData::from_bezpath(&clip_path.path),
rule: fill_rule(clip_path.fill),
live: None,
clipping: true,
guide: false,
},
);
clip.name = Some("<Clipping Path>".into());
FrameKind::Clip(Box::new(clip))
};
self.stack.push(Frame { kind, children: vec![] });
}
fn push_transparency_group(&mut self, opacity: f32, mask: Option<SoftMask<'a>>, blend_mode: hayro_interpret::BlendMode) {
self.flush_glyphs();
let masked = mask.is_some();
if masked {
self.warn("soft masks are not imported (content drawn unmasked)");
}
self.stack.push(Frame { kind: FrameKind::Group { opacity, blend: blend(blend_mode), masked }, children: vec![] });
// Blend mode applies to the group as a whole, not to its children.
self.blend = BlendMode::Normal;
}
fn draw_glyph(
&mut self,
glyph: &Glyph<'a>,
transform: Affine,
glyph_transform: Affine,
paint: &hayro_interpret::Paint<'a>,
draw_mode: &GlyphDrawMode,
) {
let stroke = match draw_mode {
GlyphDrawMode::Invisible => return,
GlyphDrawMode::Fill => None,
GlyphDrawMode::Stroke(p) => Some(p.clone()),
};
match glyph {
Glyph::Outline(o) => {
let mut bp = o.outline();
bp.apply_affine(transform * glyph_transform);
let (paint, opacity) = self.paint(paint);
let scale = mean_scale(transform);
let same = self.glyphs.as_ref().is_some_and(|r| {
r.paint == paint
&& r.opacity == opacity
&& r.stroke.as_ref().map(|s| s.line_width) == stroke.as_ref().map(|s| s.line_width)
&& (r.scale - scale).abs() < 1e-9
});
if !same {
self.flush_glyphs();
self.glyphs = Some(GlyphRun { path: BezPath::new(), paint, opacity, stroke, scale });
}
if let Some(r) = &mut self.glyphs {
r.path.extend(bp.iter());
}
self.warn("text was converted to outlines");
}
Glyph::Type3(t3) => {
self.flush_glyphs();
t3.interpret(self, transform, glyph_transform, paint);
}
}
}
fn draw_image(&mut self, image: Image<'a, '_>, transform: Affine) {
self.flush_glyphs();
match image {
Image::Raster(r) => {
let key = hayro_interpret::CacheKey::cache_key(&r);
// JPEG passthrough for plain DeviceRGB/DeviceGray DCT images.
let st = r.stream();
let dict = st.dict();
let filters = st.filters();
let cs_ok = dict.get::<Name<'_>>(b"ColorSpace").is_some_and(|n| matches!(n.as_ref(), b"DeviceRGB" | b"DeviceGray"));
let jpeg = filters.len() == 1
&& matches!(filters[0], hayro_syntax::Filter::DctDecode)
&& cs_ok
&& !dict.contains_key(b"SMask")
&& !dict.contains_key(b"Mask")
&& !dict.contains_key(b"Decode");
if jpeg {
let (w, h) = (r.width(), r.height());
let bytes = st.raw_data().to_vec();
self.add_image(key, || Some((ImageBlob { mime: "image/jpeg".into(), bytes: Arc::new(bytes) }, w, h)), transform);
return;
}
let mut decoded: Option<Decoded> = None;
r.with_rgba(
|img, alpha| {
let (w, h, sf) = (img.width(), img.height(), img.scale_factors());
let a = alpha.map(|a| resize_alpha(&a, w, h)).unwrap_or_else(|| vec![255; (w * h) as usize]);
let rgba: Vec<u8> = match img {
ImageData::Rgb(d) => d.data.chunks_exact(3).zip(a).flat_map(|(c, a)| [c[0], c[1], c[2], a]).collect(),
ImageData::Luma(d) => d.data.iter().zip(a).flat_map(|(g, a)| [*g, *g, *g, a]).collect(),
};
decoded = Some((rgba, w, h, sf));
},
None,
);
let Some((rgba, w, h, sf)) = decoded else {
self.warn("an image could not be decoded and was skipped");
return;
};
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
self.add_image(key, || rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)), xf);
}
Image::Stencil(s) => {
let key = hayro_interpret::CacheKey::cache_key(&s);
let mut decoded: Option<Decoded> = None;
s.with_stencil(
|luma, paint| {
let rgb = match paint {
hayro_interpret::Paint::Color(c) => c.to_rgba().to_rgba8(),
_ => [0, 0, 0, 255],
};
let rgba = luma.data.iter().flat_map(|a| [rgb[0], rgb[1], rgb[2], *a]).collect();
decoded = Some((rgba, luma.width, luma.height, luma.scale_factors));
},
None,
);
let Some((rgba, w, h, sf)) = decoded else { return };
let xf = transform * Affine::scale_non_uniform(sf.0 as f64, sf.1 as f64);
self.add_image(
key ^ 0x5_7e9c,
|| rgba_png(rgba, w, h).map(|png| (ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }, w, h)),
xf,
);
}
}
}
fn pop_clip_path(&mut self) {
self.flush_glyphs();
self.pop_frame();
}
fn pop_transparency_group(&mut self) {
self.flush_glyphs();
self.pop_frame();
}
}

View File

@ -1,2 +1,107 @@
//! DrawCraft PDF export and import
//! DrawCraft PDF export and import.
//!
//! - [`export`] writes one PDF page per artboard with `krilla`: vector paths (fills, strokes with
//! caps/joins/miter/dashes, non-zero/even-odd), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), embedded images and text as outlined glyph
//! paths. Hidden objects, guides and template layers are skipped.
//! - [`import`] reads PDF (and PDF-compatible `.ai`) pages with `hayro-interpret` into a
//! [`Document`]: one artboard and one layer per page, paths with fill/stroke, clip groups,
//! transparency groups, axial/radial shadings → gradients, images (JPEG passthrough, others
//! re-encoded as PNG) and text as glyph outlines.
#![forbid(unsafe_code)]
mod export;
mod import;
pub use export::{export, export_with_report};
pub use import::{import, import_with_report};
use drawcraft_doc::Document;
/// PDF standard / version to target.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Compatibility {
/// Acrobat 5 (PDF 1.4).
Pdf14,
/// Acrobat 6 (PDF 1.5).
Pdf15,
/// Acrobat 7 (PDF 1.6).
Pdf16,
/// Acrobat 8+ (PDF 1.7). The default.
#[default]
Pdf17,
/// PDF 2.0.
Pdf20,
/// PDF/A-2b (archival; validated by krilla).
PdfA2b,
/// PDF/X-4 — not supported by the writer yet; export returns [`PdfError::Unsupported`].
PdfX4,
}
/// Export options.
#[derive(Clone, Debug)]
pub struct PdfOptions {
/// 0-based artboard indices to export, in page order; `None` = all artboards.
pub artboards: Option<Vec<usize>>,
pub compatibility: Compatibility,
/// Compress content streams (Flate).
pub compress: bool,
/// Document title for the metadata; `None` = the document's title.
pub title: Option<String>,
/// Creation date as Unix seconds (UTC); `None` = now (native) / omitted (wasm). PDF/A needs a date.
pub created: Option<i64>,
}
impl Default for PdfOptions {
fn default() -> Self {
Self { artboards: None, compatibility: Compatibility::Pdf17, compress: true, title: None, created: None }
}
}
/// Import options.
#[derive(Clone, Debug)]
pub struct ImportOptions {
/// Import at most this many pages (from the first); `None` = all pages.
pub max_pages: Option<usize>,
/// Horizontal gap in points between the artboards created for consecutive pages.
pub artboard_gap: f64,
}
impl Default for ImportOptions {
fn default() -> Self {
Self { max_pages: None, artboard_gap: 36.0 }
}
}
/// Result of an import with non-fatal warnings (unsupported features, skipped content).
#[derive(Clone, Debug)]
pub struct ImportReport {
pub document: Document,
pub warnings: Vec<String>,
}
/// Result of an export with non-fatal warnings (features approximated or dropped).
#[derive(Clone, Debug)]
pub struct ExportReport {
pub bytes: Vec<u8>,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error, PartialEq)]
pub enum PdfError {
#[error("the document has no artboards")]
NoArtboards,
#[error("artboard {0} does not exist")]
BadArtboard(usize),
#[error("unsupported: {0}")]
Unsupported(String),
#[error("PDF writer error: {0}")]
Write(String),
#[error("cannot read PDF: {0}")]
Parse(String),
#[error("the PDF has no pages")]
NoPages,
}
#[cfg(test)]
mod tests;

534
crates/pdf/src/tests.rs Normal file
View File

@ -0,0 +1,534 @@
use std::sync::Arc;
use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use drawcraft_doc::{
Appearance, AppearanceItem, Artboard, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject,
};
use drawcraft_geom::{Affine, FillRule, PathData, Point, Rect, shapes};
use crate::*;
fn doc(w: f64, h: f64) -> Document {
Document::new(w, h)
}
fn add(d: &mut Document, mut n: Node) -> NodeId {
n.id = d.alloc_id();
let layer = d.default_layer().expect("layer");
let len = d.children(Some(layer)).map_or(0, |c| c.len());
d.insert(Some(layer), len, n).expect("insert")
}
fn rect_node(r: Rect, fill: Color) -> Node {
Node::path(NodeId(0), shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0))
}
fn leaves(d: &Document) -> Vec<&Node> {
let mut out = vec![];
d.walk(|n| {
if !n.is_container() && !matches!(n.kind, NodeKind::Path { clipping: true, .. }) {
out.push(n);
}
});
out
}
fn close(a: Rect, b: Rect, tol: f64) -> bool {
(a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol
}
fn roundtrip(d: &Document) -> Document {
let bytes = export(d, &PdfOptions::default()).expect("export");
import(&bytes).expect("import")
}
fn uncompressed(d: &Document) -> String {
let bytes = export(d, &PdfOptions { compress: false, ..Default::default() }).expect("export");
String::from_utf8_lossy(&bytes).into_owned()
}
fn page_count(bytes: &[u8]) -> usize {
hayro_syntax::Pdf::new(bytes.to_vec()).expect("parse").pages().len()
}
#[test]
fn export_is_valid_pdf() {
let mut d = doc(200.0, 100.0);
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::rgb(1.0, 0.0, 0.0)));
let bytes = export(&d, &PdfOptions::default()).unwrap();
assert!(bytes.starts_with(b"%PDF-1.7"));
let tail = String::from_utf8_lossy(&bytes[bytes.len().saturating_sub(64)..]).into_owned();
assert!(tail.contains("startxref") && tail.contains("%%EOF"), "{tail}");
assert!(String::from_utf8_lossy(&bytes).contains("xref"));
assert_eq!(page_count(&bytes), 1);
}
#[test]
fn metadata_creator_and_title() {
let mut d = doc(100.0, 100.0);
d.title = "Poster".into();
let s = uncompressed(&d);
assert!(s.contains("DrawCraft"));
assert!(s.contains("Poster"));
}
#[test]
fn one_page_per_artboard_with_sizes() {
let mut d = doc(200.0, 100.0);
d.artboards.push(Artboard {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
});
d.artboards.push(Artboard {
id: 3,
name: "C".into(),
rect: Rect::new(0.0, 500.0, 612.0, 1292.0),
show_center_mark: false,
show_cross_hairs: false,
});
let bytes = export(&d, &PdfOptions::default()).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
let sizes: Vec<(f32, f32)> = pdf.pages().iter().map(|p| p.render_dimensions()).collect();
assert_eq!(sizes, vec![(200.0, 100.0), (100.0, 400.0), (612.0, 792.0)]);
}
#[test]
fn artboard_range_and_bad_index() {
let mut d = doc(200.0, 100.0);
d.artboards.push(Artboard {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
});
let bytes = export(&d, &PdfOptions { artboards: Some(vec![1]), ..Default::default() }).unwrap();
let pdf = hayro_syntax::Pdf::new(bytes).unwrap();
assert_eq!(pdf.pages().len(), 1);
assert_eq!(pdf.pages()[0].render_dimensions(), (100.0, 400.0));
assert_eq!(export(&d, &PdfOptions { artboards: Some(vec![5]), ..Default::default() }), Err(PdfError::BadArtboard(5)));
d.artboards.clear();
assert_eq!(export(&d, &PdfOptions::default()), Err(PdfError::NoArtboards));
}
#[test]
fn roundtrip_rect_and_ellipse_bounds() {
let mut d = doc(300.0, 200.0);
add(&mut d, rect_node(Rect::new(10.0, 20.0, 110.0, 70.0), Color::rgb(1.0, 0.0, 0.0)));
let e = Node::path(
NodeId(0),
shapes::ellipse(Rect::new(150.0, 40.0, 250.0, 180.0)),
Appearance::basic(Paint::solid(Color::rgb(0.0, 0.0, 1.0)), Paint::None, 0.0),
);
add(&mut d, e);
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 2);
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(10.0, 20.0, 110.0, 70.0), 0.01), "{:?}", l[0].geometric_bounds());
assert!(close(l[1].geometric_bounds().unwrap(), Rect::new(150.0, 40.0, 250.0, 180.0), 0.05), "{:?}", l[1].geometric_bounds());
assert_eq!(out.artboards.len(), 1);
assert!(close(out.artboards[0].rect, Rect::new(0.0, 0.0, 300.0, 200.0), 1e-6));
}
#[test]
fn roundtrip_colours_preserved() {
let mut d = doc(300.0, 100.0);
let cols = [Color::rgb8(0x12, 0x34, 0x56), Color::rgb8(0xff, 0x80, 0x00), Color::rgb8(0x00, 0xc0, 0x40)];
for (i, c) in cols.iter().enumerate() {
add(&mut d, rect_node(Rect::new(10.0 + 90.0 * i as f64, 10.0, 90.0 + 90.0 * i as f64, 90.0), *c));
}
let out = roundtrip(&d);
let got: Vec<String> = leaves(&out).iter().map(|n| n.appearance.fill_paint().color().unwrap().to_hex()).collect();
assert_eq!(got, cols.iter().map(|c| c.to_hex()).collect::<Vec<_>>());
}
#[test]
fn roundtrip_stroke_params_merge_fill_and_stroke() {
let mut d = doc(200.0, 200.0);
let mut p = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)),
Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 4.0),
);
{
let st = p.appearance.stroke_mut().unwrap();
st.cap = LineCap::Round;
st.join = LineJoin::Bevel;
st.miter_limit = 7.0;
st.dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 1.0, align_corners: false });
}
add(&mut d, p);
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1, "fill + stroke of one path should import as one object");
let st = l[0].appearance.stroke().unwrap();
assert!((st.width - 4.0).abs() < 1e-3);
assert_eq!(st.cap, LineCap::Round);
assert_eq!(st.join, LineJoin::Bevel);
assert!((st.miter_limit - 7.0).abs() < 1e-3);
let dash = st.dash.as_ref().unwrap();
assert_eq!(dash.pattern.len(), 2);
assert!((dash.pattern[0] - 6.0).abs() < 1e-3 && (dash.offset - 1.0).abs() < 1e-3);
assert_eq!(l[0].appearance.fill_paint().color().unwrap().to_hex(), "#ffffff");
}
#[test]
fn roundtrip_open_path_and_even_odd() {
let mut d = doc(200.0, 200.0);
let mut bp = drawcraft_geom::BezPath::new();
bp.move_to((10.0, 10.0));
bp.curve_to((50.0, 0.0), (80.0, 100.0), (150.0, 60.0));
add(&mut d, Node::path(NodeId(0), PathData::from_bezpath(&bp), Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0)));
let mut ring = shapes::rectangle(Rect::new(20.0, 100.0, 120.0, 190.0));
ring.subpaths.extend(shapes::rectangle(Rect::new(40.0, 120.0, 100.0, 170.0)).subpaths);
let mut n = rect_node(Rect::ZERO, Color::BLACK);
n.kind = NodeKind::Path { path: ring, rule: FillRule::EvenOdd, live: None, clipping: false, guide: false };
add(&mut d, n);
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 2);
let pb = l[0].path_data().unwrap();
assert!(!pb.is_closed());
let b = l[0].geometric_bounds().unwrap();
let want = bp.bounding_box_exact();
assert!(close(b, want, 0.05), "{b:?} vs {want:?}");
assert!(matches!(l[1].kind, NodeKind::Path { rule: FillRule::EvenOdd, .. }));
assert_eq!(l[1].path_data().unwrap().subpaths.len(), 2);
}
trait Exact {
fn bounding_box_exact(&self) -> Rect;
}
impl Exact for drawcraft_geom::BezPath {
fn bounding_box_exact(&self) -> Rect {
kurbo::Shape::bounding_box(self)
}
}
#[test]
fn gradients_exported_as_shadings_and_reimported() {
let mut d = doc(300.0, 100.0);
let lin = GradientPaint::new(Gradient::default());
let mut n = rect_node(Rect::new(0.0, 0.0, 140.0, 100.0), Color::BLACK);
n.appearance.set_fill(Paint::Gradient(Box::new(lin)));
add(&mut d, n);
let rad = GradientPaint::new(Gradient { kind: GradientKind::Radial, ..Gradient::default() });
let mut n = rect_node(Rect::new(150.0, 0.0, 250.0, 100.0), Color::BLACK);
n.appearance.set_fill(Paint::Gradient(Box::new(rad)));
add(&mut d, n);
let s = uncompressed(&d);
let s = s.replace(' ', "");
assert!(s.contains("/ShadingType2"), "axial shading");
assert!(s.contains("/ShadingType3"), "radial shading");
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 2);
let Paint::Gradient(g) = l[0].appearance.fill_paint() else { panic!("linear gradient lost: {:?}", l[0].appearance) };
assert_eq!(g.gradient.kind, GradientKind::Linear);
let geom = g.geom.unwrap();
assert!((geom.start.x - 0.0).abs() < 0.1 && (geom.end.x - 140.0).abs() < 0.1, "{geom:?}");
assert_eq!(g.gradient.stops.first().unwrap().color.to_hex(), "#ffffff");
assert_eq!(g.gradient.stops.last().unwrap().color.to_hex(), "#000000");
let Paint::Gradient(g) = l[1].appearance.fill_paint() else { panic!("radial gradient lost") };
assert_eq!(g.gradient.kind, GradientKind::Radial);
assert!((g.geom.unwrap().start - Point::new(200.0, 50.0)).hypot() < 0.1);
}
#[test]
fn hidden_objects_and_template_layers_skipped() {
let mut d = doc(200.0, 200.0);
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
let mut hidden = rect_node(Rect::new(60.0, 10.0, 90.0, 50.0), Color::BLACK);
hidden.visible = false;
add(&mut d, hidden);
let t = d.add_layer(Some("Template"));
if let NodeKind::Layer { template, .. } = &mut d.node_mut(t).unwrap().kind {
*template = true;
}
let mut n = rect_node(Rect::new(100.0, 100.0, 150.0, 150.0), Color::BLACK);
n.id = d.alloc_id();
d.insert(Some(t), 0, n).unwrap();
let mut guide = rect_node(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK);
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
*g = true;
}
add(&mut d, guide);
let out = roundtrip(&d);
assert_eq!(leaves(&out).len(), 1);
}
#[test]
fn objects_outside_artboard_are_culled() {
let mut d = doc(100.0, 100.0);
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
add(&mut d, rect_node(Rect::new(500.0, 500.0, 550.0, 550.0), Color::BLACK));
assert_eq!(leaves(&roundtrip(&d)).len(), 1);
}
#[test]
fn opacity_and_blend_modes() {
let mut d = doc(200.0, 200.0);
let mut n = rect_node(Rect::new(10.0, 10.0, 100.0, 100.0), Color::rgb(1.0, 0.0, 0.0));
n.opacity = 0.5;
n.blend = BlendMode::Multiply;
add(&mut d, n);
let s = uncompressed(&d);
let s = s.replace(' ', "");
assert!(s.contains("/BM/Multiply"), "blend mode in ExtGState");
assert!(s.contains("/ca0.5"), "opacity in ExtGState");
assert!(s.contains("/Transparency"), "transparency group");
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1);
// Opacity/blend end up on the object or an enclosing group.
let mut op = 1.0;
let mut bm = BlendMode::Normal;
out.walk(|n| {
op *= n.opacity * n.appearance.fill().map_or(1.0, |f| f.opacity);
if n.blend != BlendMode::Normal {
bm = n.blend;
}
});
assert!((op - 0.5).abs() < 0.01, "{op}");
assert_eq!(bm, BlendMode::Multiply);
}
#[test]
fn clip_group_roundtrip() {
let mut d = doc(200.0, 200.0);
let clip = Node::new(
NodeId(0),
NodeKind::Path {
path: shapes::ellipse(Rect::new(20.0, 20.0, 120.0, 120.0)),
rule: FillRule::NonZero,
live: None,
clipping: true,
guide: false,
},
);
let mut clip = clip;
clip.id = d.alloc_id();
let mut a = rect_node(Rect::new(0.0, 0.0, 100.0, 100.0), Color::rgb(0.0, 1.0, 0.0));
a.id = d.alloc_id();
let mut b = rect_node(Rect::new(50.0, 50.0, 150.0, 150.0), Color::rgb(0.0, 0.0, 1.0));
b.id = d.alloc_id();
let g = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(a), Arc::new(b)], clip: true });
add(&mut d, g);
let out = roundtrip(&d);
let mut groups = vec![];
out.walk(|n| {
if let NodeKind::Group { clip: true, children } = &n.kind {
groups.push(children.len());
}
});
assert_eq!(groups, vec![3]);
let cg = {
let mut r = None;
out.walk(|n| {
if matches!(n.kind, NodeKind::Group { clip: true, .. }) {
r = n.geometric_bounds();
}
});
r.unwrap()
};
assert!(close(cg, Rect::new(20.0, 20.0, 120.0, 120.0), 0.05), "{cg:?}");
}
#[test]
fn text_exported_as_outlines() {
let mut d = doc(300.0, 100.0);
let t = TextObject::point(Point::new(10.0, 50.0), "Hello", CharStyle { size: 24.0, ..CharStyle::default() });
add(&mut d, Node::new(NodeId(0), NodeKind::Text(Box::new(t))));
let s = uncompressed(&d);
assert!(!s.contains("/Font"), "no fonts when outlining text");
let r = import_with_report(&export(&d, &PdfOptions::default()).unwrap(), &ImportOptions::default()).unwrap();
let l = leaves(&r.document);
assert!(!l.is_empty());
assert!(l.iter().all(|n| matches!(n.kind, NodeKind::Path { .. })));
let b = l.iter().fold(None, |acc, n| drawcraft_geom::union_opt(acc, n.geometric_bounds())).unwrap();
assert!(b.x0 >= 9.0 && b.x1 < 120.0 && b.y1 <= 56.0 && b.y0 > 25.0, "{b:?}");
}
fn tiny_png() -> Vec<u8> {
let mut img = image::RgbaImage::new(4, 2);
for (x, _, p) in img.enumerate_pixels_mut() {
*p = image::Rgba([if x < 2 { 255 } else { 0 }, 0, 0, 255]);
}
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
#[test]
fn image_roundtrip() {
let mut d = doc(200.0, 200.0);
d.images.insert("img1".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(tiny_png()) });
let im = ImageObject { key: "img1".into(), width: 4, height: 2, xf: Affine::translate((20.0, 30.0)) * Affine::scale(10.0), link: None };
add(&mut d, Node::new(NodeId(0), NodeKind::Image(im)));
let out = roundtrip(&d);
let l = leaves(&out);
assert_eq!(l.len(), 1);
let NodeKind::Image(im) = &l[0].kind else { panic!("expected image, got {:?}", l[0].kind_label()) };
assert_eq!((im.width, im.height), (4, 2));
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(20.0, 30.0, 60.0, 50.0), 0.01), "{:?}", l[0].geometric_bounds());
let blob = &out.images[&im.key];
let decoded = image::load_from_memory(&blob.bytes).unwrap().to_rgba8();
assert_eq!(decoded.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(decoded.get_pixel(3, 0).0, [0, 0, 0, 255]);
}
/// Build a PDF with a correct xref table from object bodies.
fn handmade_pdf(content: &str, media: &str) -> Vec<u8> {
let objs = [
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox {media} /Contents 4 0 R /Resources << >> >>"),
format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1),
];
let mut out = b"%PDF-1.4\n".to_vec();
let mut offs = vec![];
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).bytes());
}
let xref = out.len();
out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).bytes());
for o in offs {
out.extend(format!("{o:010} 00000 n \n").bytes());
}
out.extend(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).bytes());
out
}
#[test]
fn import_handmade_pdf() {
let content = "1 0 0 RG 0 0 1 rg 3 w 10 10 50 30 re B \
0 1 1 0 k 100 100 20 20 re f \
0.5 g q 1 0 0 1 150 0 cm 0 0 m 40 0 l 20 30 l h f Q";
let bytes = handmade_pdf(content, "[0 0 200 300]");
let r = import_with_report(&bytes, &ImportOptions::default()).unwrap();
let d = r.document;
assert!(close(d.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 300.0), 1e-9));
let l = leaves(&d);
assert_eq!(l.len(), 3);
// y-up PDF rect (10,10)-(60,40) → y-down (10,260)-(60,290).
assert!(close(l[0].geometric_bounds().unwrap(), Rect::new(10.0, 260.0, 60.0, 290.0), 1e-6), "{:?}", l[0].geometric_bounds());
assert_eq!(l[0].appearance.fill_paint().color().unwrap().to_hex(), "#0000ff");
assert_eq!(l[0].appearance.stroke_paint().color().unwrap().to_hex(), "#ff0000");
assert!((l[0].appearance.stroke_width() - 3.0).abs() < 1e-6);
// CMYK red-ish (0,1,1,0) → approximately red through the CMYK profile.
let [r_, g_, b_] = l[1].appearance.fill_paint().color().unwrap().to_rgb();
assert!(r_ > 0.8 && g_ < 0.3 && b_ < 0.3, "{r_} {g_} {b_}");
// Gray 0.5 and the `cm` translation.
assert_eq!(l[2].appearance.fill_paint().color().unwrap().to_hex(), "#808080");
assert!(close(l[2].geometric_bounds().unwrap(), Rect::new(150.0, 270.0, 190.0, 300.0), 1e-6));
}
#[test]
fn import_clip_from_handmade_pdf() {
let content = "q 0 0 50 50 re W n 1 0 0 rg 0 0 100 100 re f Q";
let d = import(&handmade_pdf(content, "[0 0 100 100]")).unwrap();
let mut clip_groups = 0;
d.walk(|n| {
if matches!(n.kind, NodeKind::Group { clip: true, .. }) {
clip_groups += 1;
assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 50.0, 50.0, 100.0), 1e-6));
}
});
assert_eq!(clip_groups, 1);
}
#[test]
fn import_max_pages_and_multi_page() {
let mut d = doc(200.0, 100.0);
d.artboards.push(Artboard {
id: 2,
name: "B".into(),
rect: Rect::new(300.0, 0.0, 400.0, 400.0),
show_center_mark: false,
show_cross_hairs: false,
});
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::BLACK));
add(&mut d, rect_node(Rect::new(310.0, 10.0, 350.0, 50.0), Color::BLACK));
let bytes = export(&d, &PdfOptions::default()).unwrap();
let all = import(&bytes).unwrap();
assert_eq!(all.artboards.len(), 2);
assert_eq!(all.layers.len(), 2);
assert!((all.artboards[1].rect.width() - 100.0).abs() < 1e-9 && (all.artboards[1].rect.height() - 400.0).abs() < 1e-9);
// The second page's rect lands at the same place relative to its artboard.
let second = leaves(&all)[1].geometric_bounds().unwrap();
let ab = all.artboards[1].rect;
assert!(close(second, Rect::new(ab.x0 + 10.0, 10.0, ab.x0 + 50.0, 50.0), 0.01), "{second:?}");
let one = import_with_report(&bytes, &ImportOptions { max_pages: Some(1), ..Default::default() }).unwrap();
assert_eq!(one.document.artboards.len(), 1);
assert_eq!(leaves(&one.document).len(), 1);
}
#[test]
fn import_rejects_garbage() {
assert!(matches!(import(b"not a pdf at all"), Err(PdfError::Parse(_)) | Err(PdfError::NoPages)));
}
#[test]
fn compatibility_levels() {
let mut d = doc(100.0, 100.0);
add(&mut d, rect_node(Rect::new(10.0, 10.0, 50.0, 50.0), Color::rgb(1.0, 0.0, 0.0)));
let b14 = export(&d, &PdfOptions { compatibility: Compatibility::Pdf14, ..Default::default() }).unwrap();
assert!(b14.starts_with(b"%PDF-1.4"));
let a = export(&d, &PdfOptions { compatibility: Compatibility::PdfA2b, ..Default::default() }).unwrap();
assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP");
assert!(matches!(export(&d, &PdfOptions { compatibility: Compatibility::PdfX4, ..Default::default() }), Err(PdfError::Unsupported(_))));
}
#[test]
fn compress_option_changes_output() {
let mut d = doc(100.0, 100.0);
for i in 0..20 {
add(&mut d, rect_node(Rect::new(i as f64, i as f64, 50.0 + i as f64, 50.0), Color::BLACK));
}
let c = export(&d, &PdfOptions::default()).unwrap();
let u = export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap();
assert!(u.len() > c.len());
assert!(String::from_utf8_lossy(&u).contains(" re") || String::from_utf8_lossy(&u).contains(" l"));
}
#[test]
fn stroke_alignment_and_arrowheads_export() {
let mut d = doc(200.0, 200.0);
let mut p = Node::path(
NodeId(0),
shapes::rectangle(Rect::new(20.0, 20.0, 120.0, 120.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 6.0),
);
p.appearance.stroke_mut().unwrap().align = drawcraft_doc::StrokeAlign::Inside;
add(&mut d, p);
let mut line = Node::path(
NodeId(0),
shapes::line(Point::new(10.0, 150.0), Point::new(150.0, 150.0)),
Appearance::basic(Paint::None, Paint::solid(Color::BLACK), 2.0),
);
line.appearance.stroke_mut().unwrap().end_arrow = Some(drawcraft_doc::Arrowhead::Triangle);
add(&mut d, line);
let r = export_with_report(&d, &PdfOptions::default()).unwrap();
let out = import(&r.bytes).unwrap();
let mut widths = vec![];
out.walk(|n| {
if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Stroke(_))) {
widths.push(s.width);
}
});
assert!(widths.iter().any(|w| (w - 12.0).abs() < 1e-3), "inside stroke doubled and clipped: {widths:?}");
// The arrowhead is an extra filled object.
assert!(leaves(&out).len() >= 3);
}
#[test]
fn creation_date_written() {
let d = doc(100.0, 100.0);
// 2024-02-29 12:34:56 UTC
let bytes = export(&d, &PdfOptions { created: Some(1_709_210_096), compress: false, ..Default::default() }).unwrap();
assert!(String::from_utf8_lossy(&bytes).contains("D:20240229123456"));
}

View File

@ -13,6 +13,12 @@ drawcraft-geom = { workspace = true }
drawcraft-color = { workspace = true }
drawcraft-doc = { workspace = true }
drawcraft-text = { workspace = true }
drawcraft-effects = { workspace = true }
vello_cpu = { workspace = true }
# Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2.
vello_common = { version = "0.2", default-features = false }
image = { workspace = true }
log = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }

362
crates/render/src/fx.rs Normal file
View File

@ -0,0 +1,362 @@
//! Live effects in the renderer.
//!
//! Geometry effects (object-level, then per fill/stroke) rewrite the path before painting via
//! `drawcraft-effects`. Raster effects use vello_cpu filter layers (single-threaded contexts only,
//! which is what the renderer creates):
//!
//! - Drop Shadow / Outer Glow: the object's silhouette in a `DropShadowOnly` filter layer, painted
//! below the object with the effect's blend mode and opacity;
//! - Gaussian Blur: the object painted inside a Gaussian filter layer;
//! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward;
//! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped
//! to the shape and painted above it.
use drawcraft_doc::{AppearanceItem, Effect, Node, NodeKind};
use drawcraft_effects::{self as effects, GeomContext, RasterFx};
use drawcraft_geom::{Affine, BezPath, FillRule, Rect, Shape};
use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive};
use vello_cpu::RenderContext;
use vello_cpu::peniko;
use crate::{Frame, Renderer, blend_mode, fill_rule, paint};
fn visible(effects: &[Effect]) -> bool {
effects.iter().any(|e| e.visible)
}
/// Does this node need the effects path (a path or compound path with visible effects)?
pub(crate) fn has_fx(n: &Node) -> bool {
if !matches!(n.kind, NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. }) {
return false;
}
visible(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| match i {
AppearanceItem::Fill(f) => visible(&f.effects),
AppearanceItem::Stroke(s) => visible(&s.effects),
})
}
fn node_bezpath(n: &Node) -> Option<BezPath> {
match &n.kind {
NodeKind::Path { path, .. } => Some(path.to_bezpath()),
NodeKind::Compound { children, .. } => {
let mut bp = BezPath::new();
for c in children {
if let Some(p) = c.path_data() {
bp.extend(p.to_bezpath());
}
}
Some(bp)
}
_ => None,
}
}
fn geom_ctx(n: &Node) -> GeomContext {
let w = n.appearance.stroke_width();
GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }
}
fn apply(effects: &[Effect], bp: &BezPath, ctx: &GeomContext) -> BezPath {
if !effects::has_geometry(effects) || bp.elements().is_empty() {
return bp.clone();
}
effects::apply_geometry_bez(effects, bp, bp.bounding_box(), ctx)
}
/// The object-level effected geometry of `n` (base path `bp`).
pub(crate) fn effected_path(n: &Node, bp: &BezPath) -> BezPath {
apply(&n.appearance.effects, bp, &geom_ctx(n))
}
fn item_effects(item: &AppearanceItem) -> &[Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
let bp = node_bezpath(n)?;
let ctx = geom_ctx(n);
let g = effected_path(n, &bp);
let mut r: Option<Rect> = (!g.elements().is_empty()).then(|| g.bounding_box());
for item in &n.appearance.items {
let fx = item_effects(item);
if effects::has_geometry(fx) {
let ig = apply(fx, &g, &ctx);
if !ig.elements().is_empty() {
let b = ig.bounding_box();
r = Some(r.map_or(b, |r| r.union(b)));
}
}
}
let o = n.appearance.outset() + effects::outset(&n.appearance.effects);
r.map(|r| r.inflate(o, o))
}
/// Bounds used for culling: like `Node::visual_bounds`, but aware of live effects anywhere in
/// the subtree (a shadow can reach the view while its object is outside it).
pub(crate) fn cull_bounds(n: &Node) -> Option<Rect> {
match &n.kind {
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| drawcraft_geom::union_opt(acc, cull_bounds(c)))
}
_ if has_fx(n) => visual_bounds(n),
_ => n.visual_bounds(),
}
}
fn shadow_filter(dx: f64, dy: f64, blur: f64, color: peniko::Color) -> Filter {
Filter::from_primitive(FilterPrimitive::DropShadowOnly {
dx: dx as f32,
dy: dy as f32,
std_deviation: (blur / 2.0).max(0.0) as f32,
color,
edge_mode: EdgeMode::None,
})
}
fn blur_filter(sigma: f64) -> Filter {
Filter::from_primitive(FilterPrimitive::GaussianBlur { std_deviation: sigma.max(0.0) as f32, edge_mode: EdgeMode::None })
}
fn pcolor(c: &drawcraft_doc::color::Color) -> peniko::Color {
let [r, g, b] = c.to_rgb();
peniko::Color::new([r, g, b, 1.0])
}
impl Renderer {
/// `draw_shape` for nodes with live effects.
pub(crate) fn draw_shape_fx(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
let g = effected_path(n, bp);
if g.elements().is_empty() {
return;
}
if f.opts.outline {
self.hairline(ctx, f, &g, [0, 0, 0, 255]);
return;
}
let gctx = geom_ctx(n);
let rfx = effects::raster_effects(&n.appearance.effects);
// Below the object: shadows and outer glows.
for fx in rfx.iter().filter(|x| x.is_below()) {
// The offset is applied to the geometry rather than in the filter: vello_cpu drops
// layer content that lies entirely outside the viewport before filtering.
let (mode, opacity, dx, dy, filter) = match fx {
RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => {
(*mode, *opacity, *dx, *dy, shadow_filter(0.0, 0.0, *blur, pcolor(color)))
}
RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, shadow_filter(0.0, 0.0, *blur, pcolor(color))),
_ => continue,
};
let mut gs = g.clone();
gs.apply_affine(Affine::translate((dx, dy)));
ctx.set_transform(f.view);
ctx.push_layer(None, Some(blend_mode(mode)), Some(opacity), None, Some(filter));
self.paint_items(ctx, f, n, &gs, rule, &gctx);
ctx.pop_layer();
}
// The object itself (blurred / feathered).
let mut layers = 0;
for fx in &rfx {
match fx {
RasterFx::Feather { radius } if *radius > 0.0 => {
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(&g);
ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 2;
}
RasterFx::GaussianBlur { radius } if *radius > 0.0 => {
ctx.set_transform(f.view);
ctx.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0)));
layers += 1;
}
_ => {}
}
}
self.paint_items(ctx, f, n, &g, rule, &gctx);
for _ in 0..layers {
ctx.pop_layer();
}
// Above the object: inner glows, clipped to the shape.
for fx in &rfx {
let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue };
ctx.set_transform(f.view);
ctx.set_fill_rule(fill_rule(rule));
ctx.push_clip_layer(&g);
ctx.push_layer(None, Some(blend_mode(*mode)), Some(*opacity), None, Some(shadow_filter(0.0, 0.0, *blur, pcolor(color))));
ctx.set_paint(peniko::Color::BLACK);
if *center {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(&g);
} else {
// Everything outside the shape, so the glow bleeds in from the edges.
let pad = blur * 2.0 + 4.0 * f.px;
let mut inv = g.bounding_box().inflate(pad, pad).to_path(0.1);
inv.extend(g.iter());
ctx.set_fill_rule(peniko::Fill::EvenOdd);
ctx.fill_path(&inv);
}
ctx.pop_layer();
ctx.pop_layer();
}
ctx.set_transform(Affine::IDENTITY);
}
/// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects.
fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) {
let bounds = g.bounding_box();
for item in &n.appearance.items {
let ig = apply(item_effects(item), g, gctx);
let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds };
match item {
AppearanceItem::Fill(fl) => {
if !fl.visible || fl.paint.is_none() {
continue;
}
let layered = fl.opacity < 1.0 || fl.blend != drawcraft_doc::color::BlendMode::Normal;
if layered {
ctx.set_transform(Affine::IDENTITY);
ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None);
}
ctx.set_transform(f.view);
if paint::set_paint(ctx, &fl.paint, ib, f.doc) {
ctx.set_fill_rule(fill_rule(rule));
ctx.fill_path(&ig);
}
if layered {
ctx.pop_layer();
}
}
AppearanceItem::Stroke(st) => {
if !st.visible || st.paint.is_none() || st.width <= 0.0 {
continue;
}
self.draw_stroke(ctx, f, &ig, rule, st, ib);
}
}
}
}
}
#[cfg(test)]
mod tests {
use drawcraft_doc::color::{Color, Paint};
use drawcraft_doc::{Appearance, Document, Effect, Node};
use drawcraft_geom::{Affine, Rect, shapes};
use serde_json::json;
use crate::{RenderOptions, Rendered, Renderer};
fn doc_with(r: Rect, fill: Color, effects: Vec<(&str, serde_json::Value)>) -> Document {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let mut n = Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 0.0));
n.appearance.effects = effects.into_iter().map(|(id, p)| Effect { id: id.into(), params: p, visible: true }).collect();
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
fn render(d: &Document) -> Rendered {
Renderer::new().render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() })
}
fn lum(p: [u8; 4]) -> u32 {
p[0] as u32 + p[1] as u32 + p[2] as u32
}
const WHITE: u32 = 765;
#[test]
fn drop_shadow_darkens_outside_the_shape() {
let r = Rect::new(20.0, 20.0, 60.0, 60.0);
let plain = render(&doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![]));
assert_eq!(lum(plain.pixel(66, 66)), WHITE);
let d = doc_with(r, Color::rgb(1.0, 0.0, 0.0), vec![("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 2, "opacity": 100}))]);
let img = render(&d);
assert!(lum(img.pixel(66, 66)) < 200, "shadow below-right: {:?}", img.pixel(66, 66));
// The object stays on top and unshadowed; the far side has no shadow.
assert_eq!(img.pixel(40, 40), [255, 0, 0, 255]);
assert_eq!(lum(img.pixel(15, 15)), WHITE);
}
#[test]
fn shadow_of_offscreen_object_is_not_culled() {
let d = doc_with(
Rect::new(-60.0, 20.0, -5.0, 60.0),
Color::WHITE,
vec![("stylize.dropShadow", json!({"x": 30, "y": 0, "blur": 0, "opacity": 100}))],
);
let img = render(&d);
let row: Vec<u32> = (0..10).map(|i| lum(img.pixel(i * 10, 40))).collect();
assert!(lum(img.pixel(10, 40)) < 100, "{row:?}");
}
#[test]
fn geometry_effect_is_rendered() {
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let d = doc_with(r, Color::BLACK, vec![("path.offsetPath", json!({"offset": 10}))]);
let img = render(&d);
assert_eq!(lum(img.pixel(25, 50)), 0, "offset grows the fill");
let d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]);
let img = render(&d);
assert_eq!(lum(img.pixel(10, 50)), 0);
assert_eq!(lum(img.pixel(60, 50)), WHITE);
}
#[test]
fn per_fill_effects_apply_to_that_fill_only() {
let mut d = doc_with(Rect::new(30.0, 30.0, 70.0, 70.0), Color::BLACK, vec![]);
let id = d.layers[0].children().unwrap()[0].id;
let n = d.node_mut(id).unwrap();
n.appearance.fill_mut().unwrap().effects.push(Effect { id: "distort.transform".into(), params: json!({"moveH": 20}), visible: true });
let img = render(&d);
assert_eq!(lum(img.pixel(85, 50)), 0);
assert_eq!(lum(img.pixel(40, 50)), WHITE);
}
#[test]
fn blur_glow_and_feather_soften() {
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let blur = render(&doc_with(r, Color::BLACK, vec![("blur.gaussian", json!({"radius": 6}))]));
let edge = lum(blur.pixel(29, 50));
assert!(edge > 0 && edge < WHITE, "blur spreads over the edge: {edge}");
let glow =
render(&doc_with(r, Color::BLACK, vec![("stylize.outerGlow", json!({"color": "#ff0000", "mode": "normal", "opacity": 100, "blur": 4}))]));
let g = glow.pixel(27, 50);
assert!(g[0] > g[1] + 20, "outer glow is red: {g:?}");
let inner =
render(&doc_with(r, Color::BLACK, vec![("stylize.innerGlow", json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4}))]));
assert!(lum(inner.pixel(31, 50)) > 60, "inner glow lightens the edge: {:?}", inner.pixel(31, 50));
assert!(lum(inner.pixel(50, 50)) < 30, "centre stays dark");
let feather = render(&doc_with(r, Color::BLACK, vec![("stylize.feather", json!({"radius": 8}))]));
assert!(lum(feather.pixel(31, 50)) > 20, "feather fades the edge inward");
assert_eq!(lum(feather.pixel(26, 50)), WHITE, "feather stays inside");
}
#[test]
fn hidden_effects_are_ignored_and_outline_mode_uses_effected_geometry() {
let r = Rect::new(30.0, 30.0, 70.0, 70.0);
let mut d = doc_with(r, Color::BLACK, vec![("distort.transform", json!({"moveH": -25}))]);
let id = d.layers[0].children().unwrap()[0].id;
let opts = RenderOptions { background: Some([255, 255, 255, 255]), outline: true, ..Default::default() };
let img = Renderer::new().render(&d, 100, 100, Affine::IDENTITY, &opts);
assert!(lum(img.pixel(5, 50)) < 400, "outline follows the moved path");
d.node_mut(id).unwrap().appearance.effects[0].visible = false;
let img = render(&d);
assert_eq!(lum(img.pixel(50, 50)), 0);
assert_eq!(lum(img.pixel(10, 50)), WHITE);
}
#[test]
fn visual_bounds_include_shadow() {
let d = doc_with(Rect::new(0.0, 0.0, 10.0, 10.0), Color::BLACK, vec![("stylize.dropShadow", json!({"x": 7, "y": 7, "blur": 5}))]);
let n = &d.layers[0].children().unwrap()[0];
let b = super::visual_bounds(n).unwrap();
assert!(b.x1 >= 10.0 + 7.0 + 7.5 - 1e-9);
}
}

View File

@ -6,6 +6,7 @@
//! gradients, images and text (via `drawcraft-text` glyph outlines).
#![forbid(unsafe_code)]
mod fx;
mod paint;
use std::collections::HashMap;
@ -17,6 +18,7 @@ use vello_cpu::kurbo;
use vello_cpu::peniko::{self, BlendMode, Compose, Mix};
use vello_cpu::{Pixmap, RenderContext, Resources};
pub use drawcraft_effects as effects;
pub use vello_cpu;
/// Rendering options.
@ -171,7 +173,7 @@ impl Renderer {
/// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels.
pub fn render_thumbnail(&mut self, doc: &Document, id: NodeId, size: u32) -> Option<Rendered> {
let n = doc.node(id)?;
let b = n.visual_bounds()?;
let b = fx::cull_bounds(n)?;
let s = (size as f64 - 2.0) / b.width().max(b.height()).max(1e-6);
let view = Affine::translate((size as f64 / 2.0, size as f64 / 2.0)) * Affine::scale(s) * Affine::translate(-b.center().to_vec2());
let w = size.clamp(1, u16::MAX as u32) as u16;
@ -188,7 +190,7 @@ impl Renderer {
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
return;
}
if let Some(b) = n.visual_bounds() {
if let Some(b) = fx::cull_bounds(n) {
let pad = f.px * 2.0;
if !rects_overlap(b.inflate(pad, pad), f.visible) {
self.stats.culled += n.count();
@ -274,6 +276,9 @@ impl Renderer {
}
fn draw_shape(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, bp: &BezPath, rule: FillRule) {
if fx::has_fx(n) {
return self.draw_shape_fx(ctx, f, n, bp, rule);
}
if f.opts.outline {
self.hairline(ctx, f, bp, [0, 0, 0, 255]);
return;

View File

@ -1,8 +1,169 @@
//! Drawing and path-editing tools (pencil, curvature, anchor tools, scissors, knife, line family…).
//!
//! Like every tool these only emit [`crate::Action`]s; the geometry work happens in the engine's
//! `path.*` commands (`crates/engine/src/cmd/draw2.rs`), so gestures replay through the journal.
use crate::Tool;
mod anchor;
mod curvature;
mod family;
mod gesture;
use drawcraft_doc::{NodeId, NodeKind};
use drawcraft_geom::{Anchor, AnchorKind, BezPath, Point, SubPath};
use serde_json::{Value, json};
use crate::{Tool, ToolContext};
pub use anchor::AnchorTool;
pub use curvature::CurvatureTool;
pub use family::FamilyTool;
pub use gesture::GestureTool;
/// Colour of tool feedback (Illustrator's selection blue).
pub const FEEDBACK: [u8; 3] = [0x4f, 0x9d, 0xff];
/// Create a drawing tool by id (None = not one of ours).
pub fn create(_id: &str) -> Option<Box<dyn Tool>> {
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"pencil" | "paintbrush" | "blobBrush" | "eraser" | "knife" | "smooth" | "pathEraser" | "join" => Box::new(GestureTool::new(id)),
"curvature" => Box::new(CurvatureTool::default()),
"addAnchor" | "deleteAnchor" | "anchorPoint" | "scissors" => Box::new(AnchorTool::new(id)),
"arc" | "spiral" | "rectangularGrid" | "polarGrid" => Box::new(FamilyTool::new(id)),
_ => return None,
})
}
/// A spline through `points` (Catmull-Rom converted to cubic Béziers). Points flagged `true` are
/// corners (no handles); open ends have no handles either. Used by the Curvature tool and by the
/// engine's freehand fitting.
pub fn catmull_rom(points: &[(Point, bool)], closed: bool) -> SubPath {
let n = points.len();
let mut anchors = Vec::with_capacity(n);
for i in 0..n {
let (p, corner) = points[i];
let ends = !closed && (i == 0 || i + 1 == n);
if corner || ends || n < 3 {
anchors.push(Anchor::corner(p));
continue;
}
let prev = points[(i + n - 1) % n].0;
let next = points[(i + 1) % n].0;
let t = (next - prev) / 6.0;
if t.hypot() < 1e-9 {
anchors.push(Anchor::corner(p));
continue;
}
anchors.push(Anchor { p, h_in: p - t, h_out: p + t, kind: AnchorKind::Smooth });
}
SubPath::new(anchors, closed && n > 2)
}
/// `[[x, y], …]` for command params.
pub fn points_json(pts: &[Point]) -> Value {
Value::Array(pts.iter().map(|p| json!([p.x, p.y])).collect())
}
/// A polyline preview path.
pub fn polyline(pts: &[Point]) -> BezPath {
let mut bp = BezPath::new();
if let Some(f) = pts.first() {
bp.move_to(*f);
for p in &pts[1..] {
bp.line_to(*p);
}
}
bp
}
/// Editable path nodes, selected ones first, then the rest top-most first.
fn candidate_paths(cx: &ToolContext) -> Vec<NodeId> {
let mut v: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| cx.doc.node(*id).is_some_and(|n| n.path_data().is_some())).collect();
let mut rest = vec![];
cx.doc.walk(|n| {
if let NodeKind::Path { guide: false, .. } = &n.kind {
rest.push(n.id);
}
});
rest.reverse();
for id in rest {
if !v.contains(&id) {
v.push(id);
}
}
v.retain(|id| cx.doc.is_editable(*id));
v
}
/// Nearest anchor within `tol`: (path, subpath, anchor).
pub(crate) fn hit_anchor(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize)> {
let mut best: Option<((NodeId, usize, usize), f64)> = None;
for id in candidate_paths(cx) {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
for (si, ai, a) in pd.anchors() {
let d = a.p.distance(p);
if d <= tol && best.is_none_or(|b| d < b.1 - 1e-9) {
best = Some(((id, si, ai), d));
}
}
if best.is_some() {
break;
}
}
best.map(|b| b.0)
}
/// Nearest point on a path segment within `tol`: (path, subpath, segment, t).
pub(crate) fn hit_segment(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, f64)> {
for id in candidate_paths(cx) {
let Some(pd) = cx.doc.node(id).and_then(|n| n.path_data()) else { continue };
if let Some((si, seg, t, _, d)) = pd.nearest(p)
&& d <= tol
{
return Some((id, si, seg, t));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catmull_rom_passes_through_points() {
let pts = [(Point::new(0.0, 0.0), false), (Point::new(50.0, 50.0), false), (Point::new(100.0, 0.0), false)];
let sp = catmull_rom(&pts, false);
assert_eq!(sp.anchors.len(), 3);
assert_eq!(sp.anchors[1].p, Point::new(50.0, 50.0));
assert_eq!(sp.anchors[1].kind, AnchorKind::Smooth);
// The middle tangent is horizontal (parallel to first → last).
assert!((sp.anchors[1].h_out.y - 50.0).abs() < 1e-9);
assert!(!sp.anchors[0].has_out());
}
#[test]
fn all_tools_exist() {
for id in [
"pencil",
"paintbrush",
"blobBrush",
"eraser",
"knife",
"smooth",
"pathEraser",
"join",
"curvature",
"addAnchor",
"deleteAnchor",
"anchorPoint",
"scissors",
"arc",
"spiral",
"rectangularGrid",
"polarGrid",
] {
assert_eq!(create(id).unwrap().id(), id);
}
assert!(create("selection").is_none());
}
}

View File

@ -0,0 +1,251 @@
//! Add Anchor Point (+), Delete Anchor Point (−), Anchor Point (Shift+C) and Scissors (C).
//!
//! Add: click a segment to insert an anchor without changing the shape (Alt = delete).
//! Delete: click an anchor to remove it, re-fitting the neighbouring curve (Alt = add).
//! Anchor Point: click a smooth anchor → corner; drag an anchor → pull out smooth handles; drag a
//! handle → move it independently; drag a segment → reshape the curve.
//! Scissors: click a path to split it there.
use drawcraft_doc::NodeId;
use drawcraft_geom::Point;
use serde_json::json;
use super::{hit_anchor, hit_segment};
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Clone, Copy, Debug)]
enum State {
Idle,
Convert { id: NodeId, si: usize, ai: usize, start: Point, began: bool },
Handle { id: NodeId, si: usize, ai: usize, out: bool, began: bool },
Reshape { id: NodeId, si: usize, seg: usize, t: f64, start: Point, began: bool },
}
pub struct AnchorTool {
id: &'static str,
state: State,
}
impl AnchorTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"deleteAnchor" => "deleteAnchor",
"anchorPoint" => "anchorPoint",
"scissors" => "scissors",
_ => "addAnchor",
};
Self { id, state: State::Idle }
}
fn add(cx: &ToolContext, p: Point) -> Vec<Action> {
match hit_segment(cx, p, cx.tol(4.0)) {
Some((id, si, seg, t)) => vec![Action::Exec("path.insertAnchor".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))],
None => vec![],
}
}
fn delete(cx: &ToolContext, p: Point) -> Vec<Action> {
match hit_anchor(cx, p, cx.tol(4.0)) {
Some((id, si, ai)) => vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))],
None => vec![],
}
}
}
/// A direction handle of a selected path under `p`: (id, subpath, anchor, is_out).
fn hit_handle(cx: &ToolContext, p: Point, tol: f64) -> Option<(NodeId, usize, usize, bool)> {
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
for (si, ai, a) in pd.anchors() {
if a.has_out() && a.h_out.distance(p) <= tol {
return Some((*id, si, ai, true));
}
if a.has_in() && a.h_in.distance(p) <= tol {
return Some((*id, si, ai, false));
}
}
}
None
}
impl Tool for AnchorTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
!matches!(self.state, State::Idle)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let tol = cx.tol(4.0);
match (self.id, ev.kind) {
("addAnchor", PointerKind::Down) => {
if ev.mods.alt {
Self::delete(cx, p)
} else {
Self::add(cx, p)
}
}
("deleteAnchor", PointerKind::Down) => {
if ev.mods.alt {
Self::add(cx, p)
} else {
Self::delete(cx, p)
}
}
("scissors", PointerKind::Down) => {
if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
return vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "anchor": ai}))];
}
match hit_segment(cx, p, tol) {
Some((id, si, seg, t)) => vec![Action::Exec("path.split".into(), json!({"id": id.0, "subpath": si, "segment": seg, "t": t}))],
None => vec![],
}
}
("anchorPoint", PointerKind::Down) => {
if let Some((id, si, ai, out)) = hit_handle(cx, p, tol) {
self.state = State::Handle { id, si, ai, out, began: false };
} else if let Some((id, si, ai)) = hit_anchor(cx, p, tol) {
self.state = State::Convert { id, si, ai, start: p, began: false };
} else if let Some((id, si, seg, t)) = hit_segment(cx, p, tol) {
self.state = State::Reshape { id, si, seg, t, start: p, began: false };
}
vec![]
}
("anchorPoint", PointerKind::Drag) => {
let mut out = vec![];
let (began, far) = match self.state {
State::Idle => return out,
State::Convert { began, start, .. } | State::Reshape { began, start, .. } => (began, p.distance(start) >= cx.tol(2.0)),
State::Handle { began, .. } => (began, true),
};
if !began {
if !far {
return out;
}
out.push(Action::Begin(if matches!(self.state, State::Convert { .. }) { "Convert Anchor Point" } else { "Reshape" }.into()));
if let State::Convert { began, .. } | State::Handle { began, .. } | State::Reshape { began, .. } = &mut self.state {
*began = true;
}
}
out.push(match self.state {
State::Convert { id, si, ai, .. } => {
Action::Preview("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "smooth", "x": p.x, "y": p.y}))
}
State::Handle { id, si, ai, out, .. } => Action::Preview(
"path.setHandle".into(),
json!({"id": id.0, "subpath": si, "anchor": ai, "which": if out { "out" } else { "in" }, "x": p.x, "y": p.y, "independent": true}),
),
State::Reshape { id, si, seg, t, start, .. } => Action::Preview(
"path.reshapeSegment".into(),
json!({"id": id.0, "subpath": si, "segment": seg, "t": t, "dx": p.x - start.x, "dy": p.y - start.y}),
),
State::Idle => unreachable!(),
});
out
}
("anchorPoint", PointerKind::Up) => {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => {
vec![Action::Commit]
}
State::Convert { id, si, ai, began: false, .. } => {
let has_handles = cx
.doc
.node(id)
.and_then(|n| n.path_data())
.and_then(|pd| pd.subpaths.get(si)?.anchors.get(ai))
.is_some_and(|a| a.has_in() || a.has_out());
if has_handles {
vec![Action::Exec("path.convertAnchor".into(), json!({"id": id.0, "subpath": si, "anchor": ai, "to": "corner"}))]
} else {
vec![]
}
}
_ => vec![],
}
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let st = std::mem::replace(&mut self.state, State::Idle);
match st {
State::Convert { began: true, .. } | State::Handle { began: true, .. } | State::Reshape { began: true, .. } => vec![Action::Commit],
_ => vec![],
}
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
match self.id {
"addAnchor" if !m.alt => Cursor::PenAdd,
"addAnchor" => Cursor::PenDelete,
"deleteAnchor" if !m.alt => Cursor::PenDelete,
"deleteAnchor" => Cursor::PenAdd,
"scissors" => {
if hit_segment(cx, p, cx.tol(4.0)).is_some() {
Cursor::Crosshair
} else {
Cursor::NotAllowed
}
}
_ => Cursor::Pen,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn add_and_delete_anchor_clicks() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = AnchorTool::new("addAnchor");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 101.0));
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.insertAnchor" && v["id"] == id.0 && v["segment"] == 0));
assert!((t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 20.0, 20.0))).is_empty());
let mut t = AnchorTool::new("deleteAnchor");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 101.0));
assert_eq!(a, vec![Action::Exec("path.removeAnchor".into(), json!({"id": id.0, "subpath": 0, "anchor": 1}))]);
}
#[test]
fn scissors_splits_at_segment() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = AnchorTool::new("scissors");
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 200.0, 150.0));
assert!(
matches!(&a[0], Action::Exec(c, v) if c == "path.split" && v["id"] == id.0 && v["segment"] == 1 && (v["t"].as_f64().unwrap() - 0.5).abs() < 1e-6)
);
}
#[test]
fn anchor_point_drag_pulls_handles_click_converts() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = AnchorTool::new("anchorPoint");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 90.0));
assert_eq!(a[0], Action::Begin("Convert Anchor Point".into()));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.convertAnchor" && v["to"] == "smooth" && v["id"] == id.0));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 90.0)), vec![Action::Commit]);
// Clicking a corner without handles does nothing.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0));
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 100.0, 100.0)).is_empty());
// Dragging a segment reshapes it.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 200.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 230.0));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "path.reshapeSegment" && v["dy"] == 30.0));
}
}

View File

@ -0,0 +1,219 @@
//! The Curvature tool (Shift+~).
//!
//! Each click adds a point and the path curves smoothly through all points. Alt-click or
//! double-click a point toggles it between smooth and corner; drag a point to move it; click the
//! first point to close; Backspace/Delete removes the last touched point; Esc/Enter ends the path.
//! A rubber band shows the curve to the cursor.
use drawcraft_doc::NodeId;
use drawcraft_geom::Point;
use serde_json::{Value, json};
use super::{FEEDBACK, catmull_rom};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Default)]
pub struct CurvatureTool {
pts: Vec<(Point, bool)>,
closed: bool,
id: Option<NodeId>,
/// Point being dragged: (index, began).
drag: Option<(usize, Point, bool)>,
/// Last touched point (Backspace removes it).
current: Option<usize>,
hover: Option<Point>,
}
impl CurvatureTool {
/// The path being drawn, if it is still the single selected object and matches our points.
fn active(&self, cx: &ToolContext) -> Option<NodeId> {
let id = self.id?;
if cx.selection.objects != [id] {
return None;
}
let pd = cx.doc.node(id)?.path_data()?;
(pd.subpaths.len() == 1 && pd.anchor_count() == self.pts.len()).then_some(id)
}
fn params(&self) -> Value {
let pts: Vec<Value> = self.pts.iter().map(|(p, c)| json!({"x": p.x, "y": p.y, "corner": c})).collect();
let mut v = json!({"points": pts, "closed": self.closed});
if let Some(id) = self.id {
v["id"] = json!(id.0);
}
v
}
fn hit_point(&self, cx: &ToolContext, p: Point) -> Option<usize> {
let tol = cx.tol(5.0);
self.pts.iter().position(|(q, _)| q.distance(p) <= tol)
}
fn reset(&mut self) {
self.pts.clear();
self.closed = false;
self.id = None;
self.drag = None;
self.current = None;
}
}
impl Tool for CurvatureTool {
fn id(&self) -> &'static str {
"curvature"
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let active = self.active(cx);
if active.is_none() && self.id.is_some() && matches!(ev.kind, PointerKind::Down) {
self.reset();
}
match ev.kind {
PointerKind::Move => {
self.hover = Some(p);
vec![]
}
PointerKind::Down => {
if active.is_some() {
if let Some(i) = self.hit_point(cx, p) {
self.current = Some(i);
if ev.mods.alt {
self.pts[i].1 = !self.pts[i].1;
return vec![Action::Exec("path.curvature".into(), self.params())];
}
if i == 0 && !self.closed && self.pts.len() >= 3 {
self.closed = true;
return vec![Action::Exec("path.curvature".into(), self.params())];
}
self.drag = Some((i, p, false));
return vec![];
}
if !self.closed {
self.pts.push((p, ev.mods.alt));
self.current = Some(self.pts.len() - 1);
return vec![Action::Exec("path.curvature".into(), self.params())];
}
}
// Start a new path.
self.reset();
self.pts.push((p, false));
self.current = Some(0);
vec![Action::Exec("path.curvature".into(), self.params()), Action::Notify("created".into())]
}
PointerKind::Drag => {
let Some((i, start, began)) = self.drag else { return vec![] };
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Curvature".into()));
self.drag = Some((i, start, true));
}
if let Some(pt) = self.pts.get_mut(i) {
pt.0 = p;
}
out.push(Action::Preview("path.curvature".into(), self.params()));
out
}
PointerKind::Up => match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
},
PointerKind::DoubleClick => {
if active.is_none() {
return vec![];
}
let Some(i) = self.hit_point(cx, p) else { return vec![] };
self.pts[i].1 = !self.pts[i].1;
vec![Action::Exec("path.curvature".into(), self.params())]
}
}
}
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "created" && cx.selection.objects.len() == 1 {
self.id = Some(cx.selection.objects[0]);
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape | ToolKey::Enter => {
let dragging = self.drag.is_some_and(|d| d.2);
self.reset();
if dragging { vec![Action::Cancel] } else { vec![] }
}
ToolKey::Backspace | ToolKey::Delete if self.active(cx).is_some() && self.pts.len() > 1 && self.drag.is_none() => {
let i = self.current.unwrap_or(self.pts.len() - 1).min(self.pts.len() - 1);
self.pts.remove(i);
if self.pts.len() < 3 {
self.closed = false;
}
self.current = None;
vec![Action::Exec("path.curvature".into(), self.params())]
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let dragging = self.drag.is_some_and(|d| d.2);
self.reset();
if dragging { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if self.active(cx).is_none() {
return vec![];
}
let mut o = vec![];
if !self.closed
&& self.drag.is_none()
&& let Some(h) = self.hover
{
let mut pts = self.pts.clone();
pts.push((h, false));
let sp = catmull_rom(&pts, false);
let mut bp = drawcraft_geom::BezPath::new();
sp.to_bezpath_into(&mut bp);
o.push(Overlay::Path { path: bp, color: FEEDBACK, width: 1.0, dashed: false });
}
for (i, (p, _)) in self.pts.iter().enumerate() {
o.push(Overlay::Anchor { p: *p, color: FEEDBACK, filled: Some(i) == self.current, size: 6.0 });
}
o
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Pen
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn first_click_creates_then_notify_tracks_path() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = CurvatureTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.curvature" && v.get("id").is_none() && v["points"].as_array().unwrap().len() == 1));
assert_eq!(a[1], Action::Notify("created".into()));
// Pretend the engine created `id` with one anchor: it is a rect (4 anchors) so it doesn't match.
let mut s2 = Selection::default();
s2.set([id]);
let cx2 = crate::testutil::cx(&d, &s2, &p);
t.notify(&cx2, "created");
assert_eq!(t.id, Some(id));
assert!(t.active(&cx2).is_none());
// With four points it matches, so a click appends a fifth.
t.pts = vec![(Point::new(100.0, 100.0), false); 4];
let a = t.pointer(&cx2, &PointerEvent::new(PointerKind::Down, 400.0, 400.0));
assert!(matches!(&a[0], Action::Exec(_, v) if v["id"] == id.0 && v["points"].as_array().unwrap().len() == 5));
}
}

View File

@ -0,0 +1,262 @@
//! Line Segment family drag tools: Arc, Spiral, Rectangular Grid and Polar Grid.
//!
//! Drag draws (Shift = equal axes / square, Alt = from the centre for arc and grids); a click
//! without dragging asks the UI for the options dialog. While dragging, ↑/↓ change the spiral's
//! segments, the grid rows or the concentric dividers; ←/→ change grid columns / radial dividers.
use drawcraft_geom::{Point, Rect, Vec2};
use serde_json::{Value, json};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct FamilyTool {
id: &'static str,
start: Option<Point>,
last: Point,
mods: Mods,
began: bool,
pub closed: bool,
pub decay: f64,
pub segments: u32,
pub clockwise: bool,
/// Rows / concentric dividers.
pub rows: u32,
/// Columns / radial dividers.
pub columns: u32,
}
impl FamilyTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"spiral" => "spiral",
"rectangularGrid" => "rectangularGrid",
"polarGrid" => "polarGrid",
_ => "arc",
};
Self {
id,
start: None,
last: Point::ZERO,
mods: Mods::default(),
began: false,
closed: false,
decay: 80.0,
segments: 10,
clockwise: true,
rows: 5,
columns: 5,
}
}
fn label(&self) -> &'static str {
match self.id {
"spiral" => "Spiral",
"rectangularGrid" => "Rectangular Grid",
"polarGrid" => "Polar Grid",
_ => "Arc",
}
}
fn rect(start: Point, p: Point, m: Mods) -> Rect {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
if m.alt { Rect::from_points(start - d, start + d) } else { Rect::from_points(start, start + d) }
}
/// The command for a drag from `start` to `p`.
pub fn command(&self, start: Point, p: Point, m: Mods) -> (String, Value) {
match self.id {
"spiral" => {
let r = p.distance(start).max(0.01);
(
"shape.spiral".into(),
json!({"cx": start.x, "cy": start.y, "radius": r, "decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
)
}
"rectangularGrid" => {
let r = Self::rect(start, p, m);
(
"shape.rectangularGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "rows": self.rows, "columns": self.columns}),
)
}
"polarGrid" => {
let r = Self::rect(start, p, m);
(
"shape.polarGrid".into(),
json!({"x": r.x0, "y": r.y0, "width": r.width(), "height": r.height(), "concentric": self.rows, "radial": self.columns}),
)
}
_ => {
let mut d = p - start;
if m.shift {
let s = d.x.abs().max(d.y.abs());
d = Vec2::new(s * d.x.signum(), s * d.y.signum());
}
let (a, b) = if m.alt { (start - d, start + d) } else { (start, start + d) };
("shape.arc".into(), json!({"x1": a.x, "y1": a.y, "x2": b.x, "y2": b.y, "closed": self.closed}))
}
}
}
}
impl Tool for FamilyTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
self.start.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.start = Some(p);
self.last = p;
self.began = false;
vec![]
}
PointerKind::Drag => {
let Some(s) = self.start else { return vec![] };
self.last = ev.pos;
self.mods = ev.mods;
let mut out = vec![];
if !self.began {
if ev.pos.distance(s) < cx.tol(2.0) {
return out;
}
self.began = true;
out.push(Action::Begin(self.label().into()));
}
let (c, v) = self.command(s, ev.pos, ev.mods);
out.push(Action::Preview(c, v));
out
}
PointerKind::Up => {
let Some(s) = self.start.take() else { return vec![] };
if std::mem::take(&mut self.began) { vec![Action::Commit] } else { vec![Action::Dialog(self.id.into(), json!({"x": s.x, "y": s.y}))] }
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
let Some(s) = self.start.filter(|_| self.began) else { return vec![] };
let changed = match (self.id, key) {
("spiral", ToolKey::Up) => {
self.segments = (self.segments + 1).min(1000);
true
}
("spiral", ToolKey::Down) => {
self.segments = self.segments.saturating_sub(1).max(2);
true
}
("rectangularGrid" | "polarGrid", ToolKey::Up) => {
self.rows = (self.rows + 1).min(999);
true
}
("rectangularGrid" | "polarGrid", ToolKey::Down) => {
self.rows = self.rows.saturating_sub(1);
true
}
("rectangularGrid" | "polarGrid", ToolKey::Right) => {
self.columns = (self.columns + 1).min(999);
true
}
("rectangularGrid" | "polarGrid", ToolKey::Left) => {
self.columns = self.columns.saturating_sub(1);
true
}
_ => false,
};
if changed {
let (c, v) = self.command(s, self.last, self.mods);
vec![Action::Preview(c, v)]
} else if key == ToolKey::Escape {
self.start = None;
self.began = false;
vec![Action::Cancel]
} else {
vec![]
}
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
match self.start {
Some(s) if self.began => {
let d = self.last - s;
vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }]
}
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
match self.id {
"spiral" => json!({"decay": self.decay, "segments": self.segments, "clockwise": self.clockwise}),
"rectangularGrid" => json!({"rows": self.rows, "columns": self.columns}),
"polarGrid" => json!({"concentric": self.rows, "radial": self.columns}),
_ => json!({"closed": self.closed}),
}
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"closed" => self.closed = v.as_bool().unwrap_or(self.closed),
"decay" => self.decay = v.as_f64().unwrap_or(self.decay).clamp(5.0, 150.0),
"segments" => self.segments = v.as_u64().unwrap_or(10).clamp(2, 1000) as u32,
"clockwise" => self.clockwise = v.as_bool().unwrap_or(self.clockwise),
"rows" | "concentric" => self.rows = v.as_u64().unwrap_or(5).min(999) as u32,
"columns" | "radial" => self.columns = v.as_u64().unwrap_or(5).min(999) as u32,
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn grid_drag_and_arrows() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = FamilyTool::new("rectangularGrid");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 60.0, 40.0));
assert_eq!(a[0], Action::Begin("Rectangular Grid".into()));
assert_eq!(
a[1],
Action::Preview("shape.rectangularGrid".into(), json!({"x": 10.0, "y": 10.0, "width": 50.0, "height": 30.0, "rows": 5, "columns": 5}))
);
let a = t.key(&cx, ToolKey::Right, Mods::default());
assert!(matches!(&a[0], Action::Preview(_, v) if v["columns"] == 6));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 60.0, 40.0)), vec![Action::Commit]);
}
#[test]
fn click_opens_dialog_and_arc_shift() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = FamilyTool::new("spiral");
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0));
assert_eq!(
t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 10.0, 10.0)),
vec![Action::Dialog("spiral".into(), json!({"x": 10.0, "y": 10.0}))]
);
let t = FamilyTool::new("arc");
let (c, v) = t.command(Point::new(0.0, 0.0), Point::new(10.0, 4.0), Mods { shift: true, ..Default::default() });
assert_eq!(c, "shape.arc");
assert_eq!(v, json!({"x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 10.0, "closed": false}));
}
}

View File

@ -0,0 +1,337 @@
//! Freehand gesture tools: Pencil (N), Paintbrush (B), Blob Brush (Shift+B), Eraser (Shift+E),
//! Knife, Smooth, Path Eraser and Join.
//!
//! Pencil and Paintbrush preview the fitted path live (`path.freehand` re-applied on the snapshot)
//! and commit on release; Alt on release (or ending near the start) closes the path, and starting
//! near an end of a selected open path continues it. The other tools collect the drag polyline
//! (shown as an overlay) and run one command on release.
use drawcraft_doc::NodeId;
use drawcraft_geom::Point;
use serde_json::{Value, json};
use super::{FEEDBACK, points_json, polyline};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
pub struct GestureTool {
id: &'static str,
points: Vec<Point>,
active: bool,
began: bool,
/// Pencil/Paintbrush continuing a selected open path: (path, continue from its start).
extend: Option<(NodeId, bool)>,
/// Fit tolerance in points (Pencil/Paintbrush/Smooth "Fidelity").
pub fidelity: f64,
/// Brush / eraser diameter in points.
pub size: f64,
/// Fill new pencil/brush strokes.
pub fill: bool,
/// "Edit selected paths within N pixels".
pub edit_within: f64,
/// "Close paths when ends are within N pixels".
pub close_within: f64,
}
impl GestureTool {
pub fn new(id: &str) -> Self {
let id: &'static str = match id {
"paintbrush" => "paintbrush",
"blobBrush" => "blobBrush",
"eraser" => "eraser",
"knife" => "knife",
"smooth" => "smooth",
"pathEraser" => "pathEraser",
"join" => "join",
_ => "pencil",
};
let fidelity = if id == "smooth" { 2.5 } else { 1.5 };
Self {
id,
points: vec![],
active: false,
began: false,
extend: None,
fidelity,
size: 10.0,
fill: false,
edit_within: 12.0,
close_within: 15.0,
}
}
fn draws_path(&self) -> bool {
matches!(self.id, "pencil" | "paintbrush")
}
fn label(&self) -> &'static str {
match self.id {
"paintbrush" => "Paintbrush",
"blobBrush" => "Blob Brush",
"eraser" => "Eraser",
"knife" => "Knife",
"smooth" => "Smooth",
"pathEraser" => "Path Eraser",
"join" => "Join",
_ => "Pencil",
}
}
/// The selected open path whose end is near `p`.
fn find_extend(&self, cx: &ToolContext, p: Point) -> Option<(NodeId, bool)> {
let tol = cx.tol(self.edit_within);
for id in &cx.selection.objects {
let Some(pd) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
if !cx.doc.is_editable(*id) {
continue;
}
let Some(sp) = pd.subpaths.iter().rev().find(|s| !s.closed && !s.anchors.is_empty()) else { continue };
let (first, last) = (sp.anchors[0].p, sp.anchors[sp.anchors.len() - 1].p);
if p.distance(last) <= tol {
return Some((*id, false));
}
if p.distance(first) <= tol {
return Some((*id, true));
}
}
None
}
/// Parameters of `path.freehand` for the current stroke.
pub fn freehand_params(&self, closed: bool) -> Value {
let mut v = json!({
"points": points_json(&self.points),
"fidelity": self.fidelity,
"closed": closed,
"style": if self.id == "paintbrush" { "brush" } else { "pencil" },
"fill": self.fill,
});
if let Some((id, start)) = self.extend {
v["extend"] = json!({"id": id.0, "end": if start { "start" } else { "end" }});
}
v
}
fn finish(&mut self, cx: &ToolContext, m: Mods) -> Vec<Action> {
let pts = &self.points;
let has_sel = !cx.selection.is_empty();
let tol = cx.tol(self.edit_within);
match self.id {
"pencil" | "paintbrush" => {
if !self.began {
return vec![];
}
let near = pts.len() > 2 && pts[0].distance(pts[pts.len() - 1]) <= cx.tol(self.close_within);
let closed = m.alt || (self.extend.is_none() && near);
vec![Action::Preview("path.freehand".into(), self.freehand_params(closed)), Action::Commit]
}
"blobBrush" => vec![Action::Exec("path.blob".into(), json!({"points": points_json(pts), "size": self.size}))],
"eraser" => vec![Action::Exec("path.eraseRegion".into(), json!({"points": points_json(pts), "size": self.size}))],
"knife" if pts.len() >= 2 => vec![Action::Exec("path.knife".into(), json!({"points": points_json(pts)}))],
"smooth" if has_sel && pts.len() >= 2 => {
vec![Action::Exec("path.smoothRegion".into(), json!({"points": points_json(pts), "radius": tol, "fidelity": self.fidelity}))]
}
"pathEraser" if has_sel => vec![Action::Exec("path.eraseSegments".into(), json!({"points": points_json(pts), "width": cx.tol(8.0)}))],
"join" if pts.len() >= 2 => vec![Action::Exec("path.joinScrub".into(), json!({"points": points_json(pts), "tolerance": tol}))],
_ => vec![],
}
}
}
impl Tool for GestureTool {
fn id(&self) -> &'static str {
self.id
}
fn busy(&self) -> bool {
self.active
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
self.points = vec![p];
self.active = true;
self.began = false;
self.extend = if self.draws_path() { self.find_extend(cx, p) } else { None };
vec![]
}
PointerKind::Drag => {
if !self.active {
return vec![];
}
if self.points.last().is_some_and(|l| l.distance(p) < cx.tol(1.0)) {
return vec![];
}
self.points.push(p);
if !self.draws_path() {
return vec![];
}
let mut out = vec![];
if !self.began {
self.began = true;
out.push(Action::Begin(self.label().into()));
}
out.push(Action::Preview("path.freehand".into(), self.freehand_params(false)));
out
}
PointerKind::Up => {
if !self.active {
return vec![];
}
if self.points.last().is_some_and(|l| l.distance(p) >= cx.tol(1.0)) {
self.points.push(p);
}
let out = self.finish(cx, ev.mods);
self.active = false;
self.began = false;
self.points.clear();
self.extend = None;
out
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _m: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.active => {
let began = self.began;
self.active = false;
self.began = false;
self.points.clear();
if began { vec![Action::Cancel] } else { vec![] }
}
ToolKey::BracketLeft if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size - 1.0).max(1.0);
vec![]
}
ToolKey::BracketRight if matches!(self.id, "blobBrush" | "eraser") => {
self.size = (self.size + 1.0).min(1000.0);
vec![]
}
_ => vec![],
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let began = self.began;
self.active = false;
self.began = false;
self.points.clear();
if began { vec![Action::Commit] } else { vec![] }
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
if !self.active || self.points.len() < 2 || self.began {
return vec![];
}
let width = match self.id {
"blobBrush" | "eraser" => (self.size * cx.zoom) as f32,
_ => 1.0,
};
let color = if self.id == "eraser" { [0x80, 0x80, 0x80] } else { FEEDBACK };
vec![Overlay::Path { path: polyline(&self.points), color, width, dashed: self.id == "knife" }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
match self.id {
"pencil" | "paintbrush" => {
json!({"fidelity": self.fidelity, "fill": self.fill, "editWithin": self.edit_within, "closeWithin": self.close_within})
}
"smooth" => json!({"fidelity": self.fidelity}),
"blobBrush" | "eraser" => json!({"size": self.size}),
_ => Value::Null,
}
}
fn set_option(&mut self, key: &str, v: &Value) {
match key {
"fidelity" => self.fidelity = v.as_f64().unwrap_or(self.fidelity).clamp(0.05, 100.0),
"size" => self.size = v.as_f64().unwrap_or(self.size).clamp(0.1, 1000.0),
"fill" => self.fill = v.as_bool().unwrap_or(self.fill),
"editWithin" => self.edit_within = v.as_f64().unwrap_or(self.edit_within).clamp(0.0, 100.0),
"closeWithin" => self.close_within = v.as_f64().unwrap_or(self.close_within).clamp(0.0, 100.0),
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn drag(t: &mut GestureTool, cx: &ToolContext, pts: &[(f64, f64)], m: Mods) -> Vec<Vec<Action>> {
let mut v = vec![t.pointer(cx, &PointerEvent::new(PointerKind::Down, pts[0].0, pts[0].1))];
for &(x, y) in &pts[1..] {
v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Drag, x, y)));
}
let l = pts[pts.len() - 1];
v.push(t.pointer(cx, &PointerEvent::new(PointerKind::Up, l.0, l.1).with_mods(m)));
v
}
#[test]
fn pencil_previews_and_commits() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("pencil");
let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods::default());
assert!(acts[0].is_empty());
assert_eq!(acts[1][0], Action::Begin("Pencil".into()));
assert!(matches!(&acts[1][1], Action::Preview(c, v) if c == "path.freehand" && v["points"].as_array().unwrap().len() == 2));
let last = acts.last().unwrap();
assert!(matches!(&last[0], Action::Preview(_, v) if v["closed"] == false && v["style"] == "pencil"));
assert_eq!(last[1], Action::Commit);
assert!(!t.busy());
}
#[test]
fn pencil_alt_closes_and_continues_selected_path() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("paintbrush");
let acts = drag(&mut t, &cx, &[(0.0, 0.0), (10.0, 5.0), (20.0, 0.0)], Mods { alt: true, ..Default::default() });
assert!(matches!(&acts.last().unwrap()[0], Action::Preview(_, v) if v["closed"] == true && v["style"] == "brush"));
// A selected closed rect is not continued; an open path would be (see engine tests).
let mut s2 = Selection::default();
s2.set([id]);
let cx2 = crate::testutil::cx(&d, &s2, &p);
let acts = drag(&mut t, &cx2, &[(100.0, 100.0), (90.0, 90.0)], Mods::default());
assert!(matches!(&acts[1][1], Action::Preview(_, v) if v.get("extend").is_none()));
}
#[test]
fn eraser_and_knife_exec_on_release() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("eraser");
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
assert!(acts[1].is_empty());
assert!(matches!(&acts[2][0], Action::Exec(c, v) if c == "path.eraseRegion" && v["size"] == 10.0));
let mut k = GestureTool::new("knife");
k.pointer(&cx, &PointerEvent::new(PointerKind::Down, 50.0, 150.0));
k.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 150.0, 150.0));
assert_eq!(k.overlays(&cx).len(), 1);
let a = k.pointer(&cx, &PointerEvent::new(PointerKind::Up, 250.0, 150.0));
assert!(matches!(&a[0], Action::Exec(c, v) if c == "path.knife" && v["points"].as_array().unwrap().len() == 3));
}
#[test]
fn smooth_needs_selection_and_brackets_resize() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GestureTool::new("smooth");
let acts = drag(&mut t, &cx, &[(50.0, 150.0), (250.0, 150.0)], Mods::default());
assert!(acts.iter().all(|a| a.is_empty()));
let mut b = GestureTool::new("blobBrush");
b.key(&cx, ToolKey::BracketRight, Mods::default());
assert_eq!(b.options()["size"], 11.0);
}
}

View File

@ -82,11 +82,15 @@ impl Targets {
}
let mut out = p;
let mut ov = vec![];
if let Some((x, from, _)) = self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs())) {
if let Some((x, from, _)) =
self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs()))
{
out.x = *x;
ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false });
}
if let Some((y, from, _)) = self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs())) {
if let Some((y, from, _)) =
self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs()))
{
out.y = *y;
ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false });
}
@ -101,8 +105,16 @@ impl Targets {
let c = r.center();
let xs = [r.x0, c.x, r.x1];
let ys = [r.y0, c.y, r.y1];
let best_x = xs.iter().flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
let best_y = ys.iter().flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
let best_x = xs
.iter()
.flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x)))
.filter(|(d, _, _)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
let best_y = ys
.iter()
.flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y)))
.filter(|(d, _, _)| d.abs() <= tol)
.min_by(|a, b| a.0.abs().total_cmp(&b.0.abs()));
let mut d = Vec2::ZERO;
let mut ov = vec![];
if let Some((dx, from, x)) = best_x {

View File

@ -11,12 +11,12 @@ pub mod bbox;
pub mod catalog;
pub mod direct;
pub mod draw2;
pub mod xform;
pub mod guides;
pub mod pen;
pub mod select;
pub mod shape;
pub mod text;
pub mod xform;
use drawcraft_color::Paint;
use drawcraft_doc::{Document, NodeId, Selection};
@ -243,7 +243,9 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"pen" => Box::new(pen::PenTool::default()),
"type" => Box::new(text::TypeTool::default()),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
other => draw2::create(other).or_else(|| xform::create(other)).unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
other => draw2::create(other)
.or_else(|| xform::create(other))
.unwrap_or_else(|| Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection")))),
}
}

View File

@ -33,7 +33,18 @@ impl ShapeTool {
"lineSegment" => "lineSegment",
_ => "rectangle",
};
Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5, guides: vec![] }
Self {
id,
start: None,
last: Point::ZERO,
mods: Mods::default(),
began: false,
sides: 6,
points: 5,
corner_radius: 12.0,
star_ratio: 0.5,
guides: vec![],
}
}
fn label(&self) -> &'static str {

View File

@ -1,9 +1,91 @@
//! Transform and utility tools (rotate/reflect/scale/shear, free transform, eyedropper, gradient,
//! artboard, magic wand, lasso, measure).
//!
//! Like every tool they only emit actions: transforms preview `object.transform` / `object.distort`
//! on the interaction snapshot, the utility tools execute selection / paint / artboard commands.
use crate::Tool;
mod artboard;
mod eyedropper;
mod free;
mod gradient;
mod measure;
mod transform;
mod wand;
use drawcraft_doc::{Document, NodeId, NodeKind};
use drawcraft_geom::{BezPath, Point, Rect, Shape};
use crate::{Overlay, Tool};
pub use artboard::ArtboardTool;
pub use eyedropper::EyedropperTool;
pub use free::{DistortMode, FreeTransformTool};
pub use gradient::GradientTool;
pub use measure::MeasureTool;
pub use transform::{TransformKind, TransformTool};
pub use wand::{LassoTool, MagicWandTool};
/// Create a transform/utility tool by id (None = not one of ours).
pub fn create(_id: &str) -> Option<Box<dyn Tool>> {
None
pub fn create(id: &str) -> Option<Box<dyn Tool>> {
Some(match id {
"rotate" => Box::new(TransformTool::new(TransformKind::Rotate)),
"reflect" => Box::new(TransformTool::new(TransformKind::Reflect)),
"scale" => Box::new(TransformTool::new(TransformKind::Scale)),
"shear" => Box::new(TransformTool::new(TransformKind::Shear)),
"freeTransform" => Box::new(FreeTransformTool::default()),
"eyedropper" => Box::new(EyedropperTool),
"gradient" => Box::new(GradientTool::default()),
"artboard" => Box::new(ArtboardTool::default()),
"magicWand" => Box::new(MagicWandTool::default()),
"lasso" => Box::new(LassoTool::default()),
"measure" => Box::new(MeasureTool::default()),
_ => return None,
})
}
/// Cyan used for the reference point target and tool annotations.
pub const CYAN: [u8; 3] = [0x00, 0xa8, 0xff];
/// Illustrator's selection blue for bounding boxes.
pub const BLUE: [u8; 3] = [0x4a, 0x7c, 0xff];
/// The reference-point target (circle + crosshair) drawn at `p`, `r` in document units.
pub(crate) fn target_overlays(p: Point, r: f64) -> Vec<Overlay> {
let circle = drawcraft_geom::kurbo::Circle::new(p, r * 0.6).to_path(0.05);
vec![
Overlay::Path { path: circle, color: CYAN, width: 1.0, dashed: false },
Overlay::Line { a: Point::new(p.x - r, p.y), b: Point::new(p.x + r, p.y), color: CYAN, dashed: false },
Overlay::Line { a: Point::new(p.x, p.y - r), b: Point::new(p.x, p.y + r), color: CYAN, dashed: false },
]
}
/// Closed polygon path through `pts`.
pub(crate) fn polygon(pts: &[Point], closed: bool) -> BezPath {
let mut bp = BezPath::new();
for (i, p) in pts.iter().enumerate() {
if i == 0 {
bp.move_to(*p);
} else {
bp.line_to(*p);
}
}
if closed && pts.len() > 2 {
bp.close_path();
}
bp
}
pub(crate) fn rect_corners(r: Rect) -> [Point; 4] {
[Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)]
}
/// The node that owns the appearance for a hit leaf (a compound path's child → the compound).
pub(crate) fn paint_owner(doc: &Document, leaf: NodeId) -> NodeId {
match doc.parent_of(leaf) {
Some(p) if doc.node(p).is_some_and(|n| matches!(n.kind, NodeKind::Compound { .. })) => p,
_ => leaf,
}
}
pub(crate) fn fmt_pt(v: f64) -> String {
format!("{v:.2} pt")
}

View File

@ -0,0 +1,276 @@
//! Artboard tool (Shift+O).
//!
//! Click an artboard to make it active (dashed bounds, 8 handles and its name). Drag inside moves
//! it (with its artwork when the `moveArt` option is on, Shift constrains), drag a handle resizes it
//! (Shift proportional, Alt from centre), drag on the pasteboard draws a new artboard, Delete removes
//! the active one and Escape returns to the Selection tool.
use drawcraft_geom::{Point, Rect, Vec2};
use serde_json::{Value, json};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, move_delta, scale_for_drag};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug)]
enum Drag {
Move { index: usize, start: Point, began: bool },
Resize { index: usize, handle: Handle, rect: Rect, began: bool },
Create { start: Point, cur: Point },
}
pub struct ArtboardTool {
/// Index of the active artboard.
pub active: usize,
/// Move artwork with the artboard.
pub move_art: bool,
drag: Option<Drag>,
preview: Option<Rect>,
}
impl Default for ArtboardTool {
fn default() -> Self {
Self { active: 0, move_art: true, drag: None, preview: None }
}
}
fn rect_json(index: usize, r: Rect) -> Value {
json!({ "index": index, "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })
}
impl ArtboardTool {
fn active_rect(&self, cx: &ToolContext) -> Option<Rect> {
cx.doc.artboards.get(self.active).map(|a| a.rect)
}
}
impl Tool for ArtboardTool {
fn id(&self) -> &'static str {
"artboard"
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
let m = ev.mods;
match (ev.kind, self.drag) {
(PointerKind::Down, _) => {
if let Some(r) = self.active_rect(cx)
&& let Some(handle) = hit_handle(r, p, cx.tol(5.0))
{
self.drag = Some(Drag::Resize { index: self.active, handle, rect: r, began: false });
return vec![];
}
if let Some(i) = (0..cx.doc.artboards.len()).rev().find(|i| cx.doc.artboards[*i].rect.contains(p)) {
self.active = i;
self.drag = Some(Drag::Move { index: i, start: p, began: false });
} else {
let (p, _) = crate::guides::snap_draw(cx, p, &[]);
self.drag = Some(Drag::Create { start: p, cur: p });
}
vec![]
}
(PointerKind::Drag, Some(Drag::Move { index, start, began })) => {
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Move Artboard".into()));
}
self.drag = Some(Drag::Move { index, start, began: true });
let d = move_delta(start, p, m.shift);
self.preview = cx.doc.artboards.get(index).map(|a| a.rect + d);
out.push(Action::Preview("artboard.move".into(), json!({ "index": index, "dx": d.x, "dy": d.y, "moveArt": self.move_art })));
out
}
(PointerKind::Drag, Some(Drag::Resize { index, handle, rect, began })) => {
let mut out = vec![];
if !began {
out.push(Action::Begin("Resize Artboard".into()));
}
self.drag = Some(Drag::Resize { index, handle, rect, began: true });
let nr = scale_for_drag(rect, handle, p, m.shift, m.alt).transform_rect_bbox(rect);
self.preview = Some(nr);
out.push(Action::Preview("artboard.setProps".into(), rect_json(index, nr)));
out
}
(PointerKind::Drag, Some(Drag::Create { start, .. })) => {
let (mut cur, _) = crate::guides::snap_draw(cx, p, &[]);
if m.shift {
let d = cur - start;
let s = d.x.abs().max(d.y.abs());
cur = start + Vec2::new(s.copysign(d.x), s.copysign(d.y));
}
self.drag = Some(Drag::Create { start, cur });
vec![]
}
(PointerKind::Up, Some(d)) => {
self.drag = None;
self.preview = None;
match d {
Drag::Move { began: true, .. } | Drag::Resize { began: true, .. } => vec![Action::Commit],
Drag::Create { start, cur } => {
let r = Rect::from_points(start, cur);
if r.width() < cx.tol(3.0) || r.height() < cx.tol(3.0) {
return vec![];
}
vec![
Action::Exec("artboard.new".into(), json!({ "x": r.x0, "y": r.y0, "width": r.width(), "height": r.height() })),
Action::Notify("created".into()),
]
}
_ => vec![],
}
}
(PointerKind::DoubleClick, _) => {
self.drag = None;
match cx.doc.artboards.get(self.active) {
Some(a) => {
let mut v = rect_json(self.active, a.rect);
v["name"] = json!(a.name);
vec![Action::Dialog("artboardOptions".into(), v)]
}
None => vec![],
}
}
_ => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Delete | ToolKey::Backspace if self.drag.is_none() => {
if cx.doc.artboards.len() <= 1 || self.active >= cx.doc.artboards.len() {
return vec![];
}
let i = self.active;
self.active = i.saturating_sub(1);
vec![Action::Exec("artboard.delete".into(), json!({ "index": i }))]
}
ToolKey::Escape => {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
self.drag = None;
self.preview = None;
if busy { vec![Action::Cancel] } else { vec![Action::SwitchTool("selection".into())] }
}
_ => vec![],
}
}
fn notify(&mut self, cx: &ToolContext, what: &str) {
if what == "created" {
self.active = cx.doc.artboards.len().saturating_sub(1);
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
if let Some(Drag::Create { start, cur }) = self.drag {
let r = Rect::from_points(start, cur);
o.push(Overlay::Marquee(r));
o.push(Overlay::Measure {
p: cur + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()),
});
return o;
}
let Some(ab) = cx.doc.artboards.get(self.active) else { return o };
let r = self.preview.unwrap_or(ab.rect);
o.push(Overlay::Path { path: polygon(&rect_corners(r), true), color: BLUE, width: 1.0, dashed: true });
for h in Handle::ALL {
o.push(Overlay::Anchor { p: h.pos(r), color: BLUE, filled: false, size: 7.0 });
}
o.push(Overlay::Label { p: Point::new(r.x0, r.y0 - cx.tol(14.0)), text: format!("{:02} - {}", self.active + 1, ab.name), color: BLUE });
if let Some(Drag::Resize { began: true, .. }) = self.drag {
o.push(Overlay::Measure {
p: Point::new(r.x1, r.y1) + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
text: format!("W: {:.2} pt\nH: {:.2} pt", r.width(), r.height()),
});
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor {
if let Some(r) = self.active_rect(cx)
&& let Some(h) = hit_handle(r, p, cx.tol(5.0))
{
return match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
};
}
if cx.doc.artboards.iter().any(|a| a.rect.contains(p)) { Cursor::Move } else { Cursor::Crosshair }
}
fn options(&self) -> Value {
json!({ "active": self.active, "moveArt": self.move_art })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"active" => self.active = value.as_u64().unwrap_or(0) as usize,
"moveArt" => self.move_art = value.as_bool().unwrap_or(true),
_ => {}
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
let busy = matches!(self.drag, Some(Drag::Move { began: true, .. } | Drag::Resize { began: true, .. }));
self.drag = None;
self.preview = None;
if busy { vec![Action::Cancel] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn move_resize_create_delete() {
let (mut d, _) = doc_with_rect();
let mut ab = d.artboards[0].clone();
ab.rect = Rect::new(600.0, 0.0, 800.0, 200.0);
ab.name = "Artboard 2".into();
d.artboards.push(ab);
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = ArtboardTool::default();
// Click the second artboard → active; drag moves it.
t.pointer(&cx, &ev(PointerKind::Down, 700.0, 100.0));
assert_eq!(t.active, 1);
let a = t.pointer(&cx, &ev(PointerKind::Drag, 710.0, 120.0));
assert_eq!(a[0], Action::Begin("Move Artboard".into()));
assert_eq!(a[1], Action::Preview("artboard.move".into(), json!({"index": 1, "dx": 10.0, "dy": 20.0, "moveArt": true})));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 710.0, 120.0)), vec![Action::Commit]);
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Label { text, .. } if text == "02 - Artboard 2")));
// Handle drag resizes.
t.pointer(&cx, &ev(PointerKind::Down, 800.0, 100.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 900.0, 100.0));
assert_eq!(a[1], Action::Preview("artboard.setProps".into(), json!({"index": 1, "x": 600.0, "y": 0.0, "width": 300.0, "height": 200.0})));
t.pointer(&cx, &ev(PointerKind::Up, 900.0, 100.0));
// Drag on the pasteboard creates one.
t.pointer(&cx, &ev(PointerKind::Down, 1000.0, 1000.0));
t.pointer(&cx, &ev(PointerKind::Drag, 1100.0, 1050.0));
assert!(matches!(t.overlays(&cx)[0], Overlay::Marquee(_)));
let a = t.pointer(&cx, &ev(PointerKind::Up, 1100.0, 1050.0));
assert_eq!(a[0], Action::Exec("artboard.new".into(), json!({"x": 1000.0, "y": 1000.0, "width": 100.0, "height": 50.0})));
assert_eq!(a[1], Action::Notify("created".into()));
// Delete removes the active artboard.
assert_eq!(t.key(&cx, ToolKey::Delete, Mods::default()), vec![Action::Exec("artboard.delete".into(), json!({"index": 1}))]);
assert_eq!(t.key(&cx, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("selection".into())]);
}
}

View File

@ -0,0 +1,101 @@
//! Eyedropper tool (I).
//!
//! Click an object: copy its appearance (fill, stroke, weight, opacity) to the selection and the
//! paint defaults (`appearance.copyFrom`). Shift-click: sample only the colour under the cursor into
//! the active fill/stroke (`paint.sampleColor`).
use drawcraft_color::{Color, Paint};
use drawcraft_doc::hit::{HitKind, hit_test};
use drawcraft_geom::Point;
use serde_json::json;
use super::paint_owner;
use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext};
pub struct EyedropperTool;
/// The colour of `paint` at `p` (gradients are sampled along their vector).
pub fn paint_color_at(paint: &Paint, bounds: Option<drawcraft_geom::Rect>, p: Point) -> Option<Color> {
match paint {
Paint::Solid { color, .. } => Some(*color),
Paint::Gradient(g) => {
let geom = g.resolve(bounds?);
let v = geom.end - geom.start;
let t = match g.gradient.kind {
drawcraft_color::GradientKind::Radial => (p - geom.start).hypot() / v.hypot().max(1e-9),
_ => (p - geom.start).dot(v) / v.hypot2().max(1e-9),
};
Some(g.gradient.sample(t.clamp(0.0, 1.0) as f32).0)
}
_ => None,
}
}
impl Tool for EyedropperTool {
fn id(&self) -> &'static str {
"eyedropper"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Down {
return vec![];
}
let opts = drawcraft_doc::hit::HitOptions { path_only: false, ..cx.hit_options() };
let Some(h) = hit_test(cx.doc, ev.pos, opts) else { return vec![] };
let src = paint_owner(cx.doc, h.leaf);
let Some(n) = cx.doc.node(src) else { return vec![] };
if ev.mods.shift {
let (fill, stroke) = match &n.kind {
drawcraft_doc::NodeKind::Text(t) => {
let st = t.runs.first().map(|r| r.style.clone());
(st.as_ref().map(|s| s.fill.clone()).unwrap_or_default(), st.map(|s| s.stroke).unwrap_or_default())
}
_ => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
};
let paint = if h.kind == HitKind::Stroke || fill.is_none() { stroke } else { fill };
return match paint_color_at(&paint, n.geometric_bounds(), ev.pos) {
Some(c) => vec![Action::Exec("paint.sampleColor".into(), json!({ "color": c.to_hex() }))],
None => vec![],
};
}
vec![Action::Exec("appearance.copyFrom".into(), json!({ "source": src.0 }))]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Eyedropper
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn click_copies_appearance_shift_samples_color() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = EyedropperTool;
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("appearance.copyFrom".into(), json!({"source": id.0}))]);
let shift = Mods { shift: true, ..Default::default() };
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#ffffff"}))]);
// On the stroke edge → the stroke colour.
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 150.0).with_mods(shift));
assert_eq!(a, vec![Action::Exec("paint.sampleColor".into(), json!({"color": "#000000"}))]);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 400.0, 400.0)).is_empty());
}
#[test]
fn gradient_sampled_along_vector() {
let g = drawcraft_color::GradientPaint::new(drawcraft_color::Gradient::default());
let paint = Paint::Gradient(Box::new(g));
let r = drawcraft_geom::Rect::new(0.0, 0.0, 100.0, 10.0);
assert_eq!(paint_color_at(&paint, Some(r), Point::new(0.0, 5.0)).unwrap().to_hex(), "#ffffff");
assert_eq!(paint_color_at(&paint, Some(r), Point::new(100.0, 5.0)).unwrap().to_hex(), "#000000");
}
}

View File

@ -0,0 +1,382 @@
//! Free Transform tool (E).
//!
//! Shows the bounding box with handles. In the default mode handles scale (Shift proportional,
//! Alt from centre), outside the corners rotates, inside moves, and Cmd-dragging a side handle
//! shears along that side. The Perspective Distort and Free Distort modes (tool option `mode`, or
//! Cmd / Cmd+Alt+Shift while dragging a corner like classic Illustrator) move the corners of the
//! box and preview `object.distort`.
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::{BLUE, polygon, rect_corners};
use crate::bbox::{Handle, hit_handle, in_rotate_zone, move_delta, rotate_for_drag, scale_for_drag};
use crate::select::{matrix_json, selection_bounds};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DistortMode {
/// Scale / rotate / shear / move.
#[default]
Free,
/// Corners move symmetrically along their side.
Perspective,
/// Corners and sides move independently.
Distort,
}
impl DistortMode {
fn name(self) -> &'static str {
match self {
DistortMode::Free => "free",
DistortMode::Perspective => "perspective",
DistortMode::Distort => "distort",
}
}
}
#[derive(Clone, Copy, Debug)]
enum Op {
Move,
Scale(Handle),
Shear(Handle),
Rotate,
Corners(Handle, DistortMode),
}
#[derive(Clone, Copy, Debug)]
struct Drag {
op: Op,
rect: Rect,
start: Point,
began: bool,
}
#[derive(Default)]
pub struct FreeTransformTool {
pub mode: DistortMode,
/// The on-canvas "Constrain" toggle (acts like holding Shift).
pub constrain: bool,
drag: Option<Drag>,
/// The current (distorted) quad while dragging corners.
quad: Option<[Point; 4]>,
measure: Option<(Point, String)>,
}
/// Shear for dragging side `h` of `r` by `d` (about the opposite side, or the centre with Alt).
pub fn shear_for_side(r: Rect, h: Handle, d: Vec2, from_center: bool) -> Affine {
let o = if from_center { r.center() } else { h.opposite().pos(r) };
let hp = h.pos(r);
let m = match h {
Handle::Top | Handle::Bottom => {
let lever = hp.y - o.y;
let k = if lever.abs() > 1e-9 { d.x / lever } else { 0.0 };
Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0])
}
_ => {
let lever = hp.x - o.x;
let k = if lever.abs() > 1e-9 { d.y / lever } else { 0.0 };
Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0])
}
};
Affine::translate(o.to_vec2()) * m * Affine::translate(-o.to_vec2())
}
/// Corner index (TL, TR, BR, BL) for a corner handle.
fn corner_index(h: Handle) -> Option<usize> {
match h {
Handle::TopLeft => Some(0),
Handle::TopRight => Some(1),
Handle::BottomRight => Some(2),
Handle::BottomLeft => Some(3),
_ => None,
}
}
/// The distorted quad for dragging handle `h` of `r` by `d`.
pub fn distort_quad(r: Rect, h: Handle, d: Vec2, mode: DistortMode) -> [Point; 4] {
let mut q = rect_corners(r);
match (corner_index(h), mode) {
(Some(i), DistortMode::Perspective) => {
// Move along the dominant axis; the partner corner on that side mirrors the move.
if d.x.abs() >= d.y.abs() {
let partner = [1, 0, 3, 2][i];
q[i].x += d.x;
q[partner].x -= d.x;
} else {
let partner = [3, 2, 1, 0][i];
q[i].y += d.y;
q[partner].y -= d.y;
}
}
(Some(i), _) => q[i] += d,
(None, _) => {
let (a, b) = match h {
Handle::Top => (0, 1),
Handle::Right => (1, 2),
Handle::Bottom => (2, 3),
_ => (3, 0),
};
q[a] += d;
q[b] += d;
}
}
q
}
impl FreeTransformTool {
fn classify(&self, cx: &ToolContext, r: Rect, p: Point, m: Mods) -> Option<Op> {
let tol = cx.tol(5.0);
if let Some(h) = hit_handle(r, p, tol) {
let mode = if h.is_corner() && m.cmd && m.alt && m.shift {
DistortMode::Perspective
} else if h.is_corner() && m.cmd {
DistortMode::Distort
} else {
self.mode
};
return Some(match mode {
DistortMode::Free if !h.is_corner() && m.cmd => Op::Shear(h),
DistortMode::Free => Op::Scale(h),
DistortMode::Perspective if !h.is_corner() => Op::Scale(h),
mode => Op::Corners(h, mode),
});
}
if self.mode == DistortMode::Free && in_rotate_zone(r, p, tol, cx.tol(18.0)).is_some() {
return Some(Op::Rotate);
}
r.contains(p).then_some(Op::Move)
}
fn preview(&mut self, d: Drag, p: Point, m: Mods) -> Action {
let shift = m.shift || self.constrain;
let delta = p - d.start;
let xf = match d.op {
Op::Corners(h, mode) => {
let q = distort_quad(d.rect, h, delta, mode);
self.quad = Some(q);
self.measure = None;
let corners: Vec<Value> = q.iter().map(|c| json!([c.x, c.y])).collect();
return Action::Preview("object.distort".into(), json!({ "corners": corners, "from": [d.rect.x0, d.rect.y0, d.rect.x1, d.rect.y1] }));
}
Op::Move => {
let v = move_delta(d.start, p, shift);
self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", v.x, v.y)));
Affine::translate(v)
}
Op::Scale(h) => {
let a = scale_for_drag(d.rect, h, p, shift, m.alt);
let nr = a.transform_rect_bbox(d.rect);
self.measure = Some((p, format!("W: {:.2} pt\nH: {:.2} pt", nr.width(), nr.height())));
a
}
Op::Shear(h) => shear_for_side(d.rect, h, delta, m.alt),
Op::Rotate => {
let (a, deg) = rotate_for_drag(d.rect.center(), d.start, p, shift);
self.measure = Some((p, format!("{:.1}°", -deg)));
a
}
};
self.quad = Some(rect_corners(d.rect).map(|c| xf * c));
Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(xf), "copy": false }))
}
}
impl Tool for FreeTransformTool {
fn id(&self) -> &'static str {
"freeTransform"
}
fn busy(&self) -> bool {
self.drag.is_some_and(|d| d.began)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
self.drag = None;
let Some(r) = selection_bounds(cx) else { return vec![] };
if let Some(op) = self.classify(cx, r, p, ev.mods) {
self.drag = Some(Drag { op, rect: r, start: p, began: false });
}
vec![]
}
PointerKind::Drag => {
let Some(mut d) = self.drag else { return vec![] };
let mut out = vec![];
if !d.began {
if p.distance(d.start) < cx.tol(2.0) {
return out;
}
d.began = true;
let label = match d.op {
Op::Move => "Move",
Op::Scale(_) => "Scale",
Op::Shear(_) => "Shear",
Op::Rotate => "Rotate",
Op::Corners(..) => "Distort",
};
out.push(Action::Begin(label.into()));
}
self.drag = Some(d);
out.push(self.preview(d, p, ev.mods));
out
}
PointerKind::Up => {
let d = self.drag.take();
self.quad = None;
self.measure = None;
if d.is_some_and(|d| d.began) { vec![Action::Commit] } else { vec![] }
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.drag = None;
self.quad = None;
self.measure = None;
return vec![Action::Cancel];
}
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
let quad = match (self.quad, selection_bounds(cx)) {
(Some(q), _) => q,
(None, Some(r)) => rect_corners(r),
_ => return o,
};
o.push(Overlay::Path { path: polygon(&quad, true), color: BLUE, width: 1.0, dashed: false });
for i in 0..4 {
let (a, b) = (quad[i], quad[(i + 1) % 4]);
o.push(Overlay::Anchor { p: a, color: BLUE, filled: false, size: 6.0 });
if self.mode != DistortMode::Perspective {
o.push(Overlay::Anchor { p: a.midpoint(b), color: BLUE, filled: false, size: 6.0 });
}
}
if let Some((p, t)) = &self.measure {
o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() });
}
o
}
fn cursor(&self, cx: &ToolContext, p: Point, m: Mods) -> Cursor {
let Some(r) = selection_bounds(cx) else { return Cursor::Arrow };
match self.drag.map(|d| d.op).or_else(|| self.classify(cx, r, p, m)) {
Some(Op::Rotate) => Cursor::Rotate,
Some(Op::Move) => Cursor::Move,
Some(Op::Scale(h) | Op::Shear(h)) => match h {
Handle::Top | Handle::Bottom => Cursor::ResizeV,
Handle::Left | Handle::Right => Cursor::ResizeH,
Handle::TopLeft | Handle::BottomRight => Cursor::ResizeNwSe,
Handle::TopRight | Handle::BottomLeft => Cursor::ResizeNeSw,
},
Some(Op::Corners(..)) => Cursor::Crosshair,
None => Cursor::Arrow,
}
}
fn options(&self) -> Value {
json!({ "mode": self.mode.name(), "constrain": self.constrain })
}
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"mode" => {
self.mode = match value.as_str() {
Some("perspective") => DistortMode::Perspective,
Some("distort") | Some("freeDistort") => DistortMode::Distort,
_ => DistortMode::Free,
}
}
"constrain" => self.constrain = value.as_bool().unwrap_or(false),
_ => {}
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.quad = None;
if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn corner_scales_and_inside_moves() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FreeTransformTool::default();
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 300.0));
assert_eq!(a[0], Action::Begin("Scale".into()));
assert!(matches!(&a[1], Action::Preview(c, v) if c == "object.transform" && v["matrix"][0] == 2.0));
assert_eq!(t.pointer(&cx, &ev(PointerKind::Up, 300.0, 300.0)), vec![Action::Commit]);
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 160.0, 170.0));
assert_eq!(a[0], Action::Begin("Move".into()));
assert!(matches!(&a[1], Action::Preview(_, v) if v["matrix"][4] == 10.0 && v["matrix"][5] == 20.0));
}
#[test]
fn cmd_side_handle_shears() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FreeTransformTool::default();
let cmd = Mods { cmd: true, ..Default::default() };
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 100.0).with_mods(cmd));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 170.0, 100.0).with_mods(cmd));
assert_eq!(a[0], Action::Begin("Shear".into()));
// Bottom edge fixed, top edge moved 20 right.
let Action::Preview(_, v) = &a[1] else { panic!() };
let c: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
let m = Affine::new([c[0], c[1], c[2], c[3], c[4], c[5]]);
assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9);
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(120.0, 100.0)) < 1e-9);
}
#[test]
fn distort_modes_emit_corners() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = FreeTransformTool::default();
t.set_option("mode", &json!("distort"));
assert_eq!(t.options()["mode"], "distort");
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 220.0, 230.0));
assert_eq!(a[0], Action::Begin("Distort".into()));
assert_eq!(
a[1],
Action::Preview(
"object.distort".into(),
json!({"corners": [[100.0, 100.0], [200.0, 100.0], [220.0, 230.0], [100.0, 200.0]], "from": [100.0, 100.0, 200.0, 200.0]})
)
);
t.pointer(&cx, &ev(PointerKind::Up, 220.0, 230.0));
let q = distort_quad(Rect::new(0.0, 0.0, 10.0, 10.0), Handle::TopLeft, Vec2::new(2.0, 0.0), DistortMode::Perspective);
assert_eq!(q[0], Point::new(2.0, 0.0));
assert_eq!(q[1], Point::new(8.0, 0.0));
assert_eq!(q[2], Point::new(10.0, 10.0));
}
}

View File

@ -0,0 +1,159 @@
//! Gradient tool (G).
//!
//! Drag across selected objects to set the gradient vector of their fill (a solid fill becomes the
//! default gradient). Shift constrains the angle to 45°. With nothing selected, the drag targets
//! the object under the pointer. The overlay is the gradient annotator: a bar from a round start
//! handle to a square end handle, with a tick for each colour stop.
use drawcraft_color::Paint;
use drawcraft_doc::hit::hit_test;
use drawcraft_geom::{Point, Vec2};
use serde_json::{Value, json};
use super::paint_owner;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
const BAR: [u8; 3] = [0x20, 0x20, 0x20];
const LIGHT: [u8; 3] = [0xf0, 0xf0, 0xf0];
#[derive(Default)]
pub struct GradientTool {
drag: Option<(Point, Point, bool)>,
}
/// Start/end/stop offsets of the gradient annotator for the first selected object with a gradient fill.
pub fn annotator(cx: &ToolContext) -> Option<(Point, Point, Vec<f32>)> {
cx.selection.objects.iter().find_map(|id| {
let n = cx.doc.node(*id)?;
let Paint::Gradient(g) = n.appearance.fill_paint() else { return None };
let geom = g.resolve(n.geometric_bounds()?);
Some((geom.start, geom.end, g.gradient.stops.iter().map(|s| s.offset).collect()))
})
}
fn annotator_overlays(cx: &ToolContext, a: Point, b: Point, stops: &[f32]) -> Vec<Overlay> {
let mut o = vec![
Overlay::Line { a, b, color: BAR, dashed: false },
Overlay::Line { a: a + Vec2::new(0.0, cx.tol(1.0)), b: b + Vec2::new(0.0, cx.tol(1.0)), color: LIGHT, dashed: false },
Overlay::Handle { p: a, color: BAR },
Overlay::Anchor { p: b, color: BAR, filled: true, size: 7.0 },
];
let v = b - a;
let len = v.hypot();
if len > 1e-9 {
let n = Vec2::new(-v.y, v.x) / len * cx.tol(6.0);
for s in stops {
let p = a + v * *s as f64;
o.push(Overlay::Line { a: p, b: p + n, color: BAR, dashed: false });
}
}
o
}
impl Tool for GradientTool {
fn id(&self) -> &'static str {
"gradient"
}
fn busy(&self) -> bool {
self.drag.is_some_and(|d| d.2)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
let mut out = vec![];
if cx.selection.is_empty() {
let Some(h) = hit_test(cx.doc, p, cx.hit_options()) else { return out };
let id = paint_owner(cx.doc, h.leaf);
out.push(Action::Exec("select.set".into(), json!({ "ids": [id.0] })));
}
self.drag = Some((p, p, false));
out
}
PointerKind::Drag => {
let Some((start, _, began)) = self.drag else { return vec![] };
let mut out = vec![];
if !began {
if p.distance(start) < cx.tol(3.0) {
return out;
}
out.push(Action::Begin("Gradient".into()));
}
let end = if ev.mods.shift { start + drawcraft_geom::constrain_angle(p - start, 45.0) } else { p };
self.drag = Some((start, end, true));
out.push(Action::Preview("paint.setGradientGeom".into(), json!({ "start": [start.x, start.y], "end": [end.x, end.y] })));
out
}
PointerKind::Up => match self.drag.take() {
Some((_, _, true)) => vec![Action::Commit],
_ => vec![],
},
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape && self.busy() {
self.drag = None;
return vec![Action::Cancel];
}
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
match (self.drag, annotator(cx)) {
(_, Some((a, b, stops))) => annotator_overlays(cx, a, b, &stops),
(Some((a, b, true)), None) => annotator_overlays(cx, a, b, &[0.0, 1.0]),
_ => vec![],
}
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
Value::Null
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn drag_sets_gradient_vector_and_constrains() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 110.0, 150.0)).is_empty());
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 190.0, 152.0).with_mods(Mods { shift: true, ..Default::default() }));
assert_eq!(a[0], Action::Begin("Gradient".into()));
let Action::Preview(c, v) = &a[1] else { panic!() };
assert_eq!(c, "paint.setGradientGeom");
assert_eq!(v["start"], json!([110.0, 150.0]));
assert!((v["end"][1].as_f64().unwrap() - 150.0).abs() < 1e-9);
// While dragging a solid-filled object the annotator follows the drag.
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Handle { p, .. } if p.x == 110.0)));
assert_eq!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 190.0, 150.0)), vec![Action::Commit]);
}
#[test]
fn empty_selection_targets_hit_object() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = GradientTool::default();
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(a, vec![Action::Exec("select.set".into(), json!({"ids": [id.0]}))]);
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 450.0, 450.0)).is_empty());
}
}

View File

@ -0,0 +1,108 @@
//! Measure tool: drag between two points to read distance, angle, dX and dY (Shift constrains to
//! 45°). The result stays on screen (and in the tool options, for agents) until the next drag; the
//! document is never changed.
use drawcraft_geom::{Point, Vec2};
use serde_json::{Value, json};
use super::{CYAN, fmt_pt};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext};
#[derive(Default)]
pub struct MeasureTool {
line: Option<(Point, Point)>,
dragging: bool,
}
/// (distance, angle in degrees counter-clockwise, dx, dy)
pub fn measure(a: Point, b: Point) -> (f64, f64, f64, f64) {
let d = b - a;
(d.hypot(), (-d.y).atan2(d.x).to_degrees(), d.x, d.y)
}
impl Tool for MeasureTool {
fn id(&self) -> &'static str {
"measure"
}
fn busy(&self) -> bool {
self.dragging
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down => {
let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]);
self.line = Some((p, p));
self.dragging = true;
}
PointerKind::Drag | PointerKind::Up if self.dragging => {
if let Some((a, _)) = self.line {
let b = if ev.mods.shift {
a + drawcraft_geom::constrain_angle(ev.pos - a, 45.0)
} else {
crate::guides::snap_draw(cx, ev.pos, &[]).0
};
self.line = Some((a, b));
}
self.dragging = ev.kind == PointerKind::Drag;
}
_ => {}
}
vec![]
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((a, b)) = self.line else { return vec![] };
if a == b {
return vec![];
}
let (dist, ang, dx, dy) = measure(a, b);
vec![
Overlay::Line { a, b, color: CYAN, dashed: false },
Overlay::Measure {
p: b + Vec2::new(cx.tol(12.0), cx.tol(12.0)),
text: format!("D: {}\nAngle: {ang:.1}°\ndX: {}\ndY: {}", fmt_pt(dist), fmt_pt(dx), fmt_pt(dy)),
},
]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
match self.line {
Some((a, b)) => {
let (d, ang, dx, dy) = measure(a, b);
json!({ "x": a.x, "y": a.y, "distance": d, "angle": ang, "dx": dx, "dy": dy })
}
None => Value::Null,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
#[test]
fn drag_reports_distance_and_angle() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let mut cx = cx(&d, &s, &p);
cx.smart_guides = false;
let mut t = MeasureTool::default();
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 10.0, 10.0)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 40.0, -30.0)).is_empty());
assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 40.0, -30.0)).is_empty());
let o = t.options();
assert!((o["distance"].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert!((o["angle"].as_f64().unwrap() - 53.130102).abs() < 1e-4);
let ov = t.overlays(&cx);
assert!(matches!(&ov[1], Overlay::Measure { text, .. } if text.starts_with("D: 50.00 pt")));
}
}

View File

@ -0,0 +1,391 @@
//! Rotate (R), Reflect (O), Scale (S) and Shear tools.
//!
//! The reference point defaults to the centre of the selection. A click sets it (shown as a cyan
//! target); a drag transforms about it with a live preview (Shift constrains to 45° / uniform, Alt
//! at release makes a copy). Alt-click sets the point and opens the tool's dialog; double-click
//! or Return opens the dialog too.
use drawcraft_doc::NodeId;
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::target_overlays;
use crate::bbox::rotate_for_drag;
use crate::select::{matrix_json, selection_bounds};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TransformKind {
Rotate,
Reflect,
Scale,
Shear,
}
impl TransformKind {
pub fn id(self) -> &'static str {
match self {
TransformKind::Rotate => "rotate",
TransformKind::Reflect => "reflect",
TransformKind::Scale => "scale",
TransformKind::Shear => "shear",
}
}
fn label(self) -> &'static str {
match self {
TransformKind::Rotate => "Rotate",
TransformKind::Reflect => "Reflect",
TransformKind::Scale => "Scale",
TransformKind::Shear => "Shear",
}
}
/// Dialog fields (kinds/keys match the UI dialogs).
fn dialog_fields(self, origin: Point) -> Value {
let o = json!([origin.x, origin.y]);
match self {
TransformKind::Rotate => json!({ "angle": 0, "origin": o }),
TransformKind::Reflect => json!({ "axis": "vertical", "origin": o }),
TransformKind::Scale => json!({ "sx": 100, "sy": 100, "origin": o }),
TransformKind::Shear => json!({ "angle": 0, "axis": "horizontal", "origin": o }),
}
}
}
#[derive(Clone, Copy, Debug)]
struct Drag {
start: Point,
origin: Point,
began: bool,
bounds: Rect,
last: Affine,
}
pub struct TransformTool {
kind: TransformKind,
/// Custom reference point and the selection it was set for.
origin: Option<(Point, Vec<NodeId>)>,
drag: Option<Drag>,
measure: Option<(Point, String)>,
}
fn about(o: Point, a: Affine) -> Affine {
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
}
/// Reflection across the line through the origin at angle `theta` (radians, screen space).
pub fn reflect_matrix(o: Point, theta: f64) -> Affine {
about(o, Affine::rotate(theta) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-theta))
}
impl TransformTool {
pub fn new(kind: TransformKind) -> Self {
Self { kind, origin: None, drag: None, measure: None }
}
/// The effective reference point: the custom one if it belongs to the current selection,
/// otherwise the selection centre.
pub fn reference_point(&self, cx: &ToolContext) -> Option<Point> {
if let Some((p, ids)) = &self.origin
&& *ids == cx.selection.objects
&& !ids.is_empty()
{
return Some(*p);
}
selection_bounds(cx).map(|b| b.center())
}
/// Transform for dragging from `start` to `p` about `o`; returns the matrix and a readout.
pub fn matrix_for(&self, o: Point, start: Point, p: Point, shift: bool, bounds: Rect) -> (Affine, String) {
match self.kind {
TransformKind::Rotate => {
let (a, deg) = rotate_for_drag(o, start, p, shift);
(a, format!("{:.1}°", -deg))
}
TransformKind::Reflect => {
let mut v = p - o;
if shift {
v = drawcraft_geom::constrain_angle(v, 45.0);
}
let theta = if v.hypot() < 1e-9 { std::f64::consts::FRAC_PI_2 } else { v.y.atan2(v.x) };
(reflect_matrix(o, theta), format!("{:.1}°", drawcraft_geom::normalize_deg(-theta.to_degrees())))
}
TransformKind::Scale => {
let d0 = start - o;
let d1 = p - o;
let ratio = |a: f64, b: f64| if a.abs() > 1e-6 { b / a } else { 1.0 };
let (mut sx, mut sy) = (ratio(d0.x, d1.x), ratio(d0.y, d1.y));
if shift {
let l2 = d0.hypot2();
let k = if l2 > 1e-12 { d1.dot(d0) / l2 } else { 1.0 };
sx = k;
sy = k;
}
let clamp = |s: f64| if s.abs() < 1e-4 { 1e-4f64.copysign(s) } else { s };
let (sx, sy) = (clamp(sx), clamp(sy));
(about(o, Affine::scale_non_uniform(sx, sy)), format!("W: {:.1}%\nH: {:.1}%", sx * 100.0, sy * 100.0))
}
TransformKind::Shear => {
// The shear axis follows the dominant drag direction (Shift keeps it strictly on
// that axis, which this always does).
let d = p - start;
let horizontal = d.x.abs() >= d.y.abs();
let lever = |a: f64, fallback: f64| if a.abs() > 1e-3 { a } else { fallback.max(1.0) };
if horizontal {
let k = d.x / lever(start.y - o.y, bounds.height() / 2.0);
(about(o, Affine::new([1.0, 0.0, k, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees()))
} else {
let k = d.y / lever(start.x - o.x, bounds.width() / 2.0);
(about(o, Affine::new([1.0, k, 0.0, 1.0, 0.0, 0.0])), format!("{:.1}°", (-k.atan()).to_degrees()))
}
}
}
}
fn dialog(&self, o: Point) -> Action {
Action::Dialog(self.kind.id().into(), self.kind.dialog_fields(o))
}
}
impl Tool for TransformTool {
fn id(&self) -> &'static str {
self.kind.id()
}
fn busy(&self) -> bool {
self.drag.is_some_and(|d| d.began)
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
let Some(bounds) = selection_bounds(cx) else { return vec![] };
if ev.mods.alt {
self.origin = Some((p, cx.selection.objects.clone()));
self.drag = None;
return vec![self.dialog(p)];
}
let origin = self.reference_point(cx).unwrap_or(bounds.center());
self.drag = Some(Drag { start: p, origin, began: false, bounds, last: Affine::IDENTITY });
vec![]
}
PointerKind::Drag => {
let Some(mut d) = self.drag else { return vec![] };
let mut out = vec![];
if !d.began {
if p.distance(d.start) < cx.tol(3.0) {
return out;
}
d.began = true;
out.push(Action::Begin(self.kind.label().into()));
}
let (m, text) = self.matrix_for(d.origin, d.start, p, ev.mods.shift, d.bounds);
d.last = m;
self.drag = Some(d);
self.measure = Some((p, text));
out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(m), "copy": ev.mods.alt })));
out
}
PointerKind::Up => {
let Some(d) = self.drag.take() else { return vec![] };
self.measure = None;
if d.began {
// The Alt state at release decides whether the result is a copy.
vec![Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(d.last), "copy": ev.mods.alt })), Action::Commit]
} else {
// A click sets the reference point.
self.origin = Some((d.start, cx.selection.objects.clone()));
vec![]
}
}
PointerKind::DoubleClick => {
self.drag = None;
match self.reference_point(cx) {
Some(o) => vec![self.dialog(o)],
None => vec![],
}
}
PointerKind::Move => vec![],
}
}
fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.busy() => {
self.drag = None;
self.measure = None;
vec![Action::Cancel]
}
ToolKey::Enter => self.reference_point(cx).map(|o| vec![self.dialog(o)]).unwrap_or_default(),
_ => vec![],
}
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let mut o = vec![];
let reference = match self.drag {
Some(d) => Some(d.origin),
None => self.reference_point(cx),
};
if let Some(r) = reference {
o.extend(target_overlays(r, cx.tol(8.0)));
}
if let (Some(d), TransformKind::Reflect | TransformKind::Rotate) = (self.drag, self.kind)
&& d.began
&& let Some((p, _)) = &self.measure
{
o.push(Overlay::Line { a: d.origin, b: *p, color: super::CYAN, dashed: true });
}
if let Some((p, t)) = &self.measure {
o.push(Overlay::Measure { p: *p + Vec2::new(cx.tol(12.0), cx.tol(12.0)), text: t.clone() });
}
o
}
fn cursor(&self, cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
if cx.selection.is_empty() {
return Cursor::NotAllowed;
}
match self.kind {
TransformKind::Rotate if self.busy() => Cursor::Rotate,
_ => Cursor::Crosshair,
}
}
fn options(&self) -> Value {
match &self.origin {
Some((p, _)) => json!({ "origin": [p.x, p.y] }),
None => json!({ "origin": null }),
}
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "origin" {
self.origin = value.as_array().and_then(|a| Some((Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?), vec![])));
}
}
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
if self.drag.take().is_some_and(|d| d.began) { vec![Action::Cancel] } else { vec![] }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_doc::Selection;
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
fn alt() -> Mods {
Mods { alt: true, ..Default::default() }
}
fn matrix(a: &Action) -> Affine {
let Action::Preview(c, v) = a else { panic!("not a preview: {a:?}") };
assert_eq!(c, "object.transform");
let m: Vec<f64> = v["matrix"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect();
Affine::new([m[0], m[1], m[2], m[3], m[4], m[5]])
}
#[test]
fn rotate_drag_about_center_single_undo() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TransformTool::new(TransformKind::Rotate);
assert!(t.pointer(&cx, &ev(PointerKind::Down, 250.0, 150.0)).is_empty());
let a = t.pointer(&cx, &ev(PointerKind::Drag, 150.0, 250.0));
assert_eq!(a[0], Action::Begin("Rotate".into()));
// Centre (150,150) is fixed; (250,150) → (150,250).
let m = matrix(&a[1]);
assert!((m * Point::new(150.0, 150.0)).distance(Point::new(150.0, 150.0)) < 1e-9);
assert!((m * Point::new(250.0, 150.0)).distance(Point::new(150.0, 250.0)) < 1e-9);
let up = t.pointer(&cx, &ev(PointerKind::Up, 150.0, 250.0));
assert_eq!(up.last(), Some(&Action::Commit));
}
#[test]
fn click_sets_reference_point_then_drag_uses_it() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TransformTool::new(TransformKind::Scale);
t.pointer(&cx, &ev(PointerKind::Down, 100.0, 100.0));
assert!(t.pointer(&cx, &ev(PointerKind::Up, 100.0, 100.0)).is_empty());
assert_eq!(t.reference_point(&cx), Some(Point::new(100.0, 100.0)));
assert!(t.overlays(&cx).iter().any(|o| matches!(o, Overlay::Line { a, .. } if a.y == 100.0)));
t.pointer(&cx, &ev(PointerKind::Down, 200.0, 200.0));
let a = t.pointer(&cx, &ev(PointerKind::Drag, 300.0, 250.0));
let m = matrix(&a[1]);
assert!((m * Point::new(100.0, 100.0)).distance(Point::new(100.0, 100.0)) < 1e-9);
assert!((m * Point::new(200.0, 200.0)).distance(Point::new(300.0, 250.0)) < 1e-9);
}
#[test]
fn scale_shift_is_uniform() {
let t = TransformTool::new(TransformKind::Scale);
let (m, _) = t.matrix_for(Point::new(150.0, 150.0), Point::new(200.0, 200.0), Point::new(250.0, 240.0), true, Rect::ZERO);
let c = m.as_coeffs();
assert!((c[0] - c[3]).abs() < 1e-9 && c[0] > 1.0);
}
#[test]
fn alt_click_opens_dialog_and_alt_release_copies() {
let (d, id) = doc_with_rect();
let mut s = Selection::default();
s.add(id);
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TransformTool::new(TransformKind::Reflect);
let a = t.pointer(&cx, &ev(PointerKind::Down, 120.0, 130.0).with_mods(alt()));
assert_eq!(a, vec![Action::Dialog("reflect".into(), json!({"axis": "vertical", "origin": [120.0, 130.0]}))]);
t.pointer(&cx, &ev(PointerKind::Up, 120.0, 130.0).with_mods(alt()));
t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0));
t.pointer(&cx, &ev(PointerKind::Drag, 120.0, 200.0));
let up = t.pointer(&cx, &ev(PointerKind::Up, 120.0, 200.0).with_mods(alt()));
assert!(matches!(&up[0], Action::Preview(_, v) if v["copy"] == true));
assert_eq!(up[1], Action::Commit);
let mut t = TransformTool::new(TransformKind::Shear);
assert!(
matches!(&t.pointer(&cx, &ev(PointerKind::DoubleClick, 0.0, 0.0))[0], Action::Dialog(k, v) if k == "shear" && v["axis"] == "horizontal")
);
let mut t = TransformTool::new(TransformKind::Rotate);
assert!(matches!(&t.key(&cx, ToolKey::Enter, Mods::default())[0], Action::Dialog(k, v) if k == "rotate" && v["angle"] == 0));
}
#[test]
fn reflect_vertical_axis_mirrors_x() {
let t = TransformTool::new(TransformKind::Reflect);
let o = Point::new(150.0, 150.0);
let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(150.0, 50.0), false, Rect::ZERO);
assert!((m * Point::new(100.0, 120.0)).distance(Point::new(200.0, 120.0)) < 1e-9);
}
#[test]
fn shear_horizontal_keeps_origin_row() {
let t = TransformTool::new(TransformKind::Shear);
let o = Point::new(150.0, 200.0);
let (m, _) = t.matrix_for(o, Point::new(150.0, 100.0), Point::new(200.0, 100.0), false, Rect::new(100.0, 100.0, 200.0, 200.0));
assert!((m * Point::new(100.0, 200.0)).distance(Point::new(100.0, 200.0)) < 1e-9);
assert!((m * Point::new(150.0, 100.0)).distance(Point::new(200.0, 100.0)) < 1e-9);
}
#[test]
fn nothing_selected_does_nothing() {
let (d, _) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = TransformTool::new(TransformKind::Rotate);
assert!(t.pointer(&cx, &ev(PointerKind::Down, 150.0, 150.0)).is_empty());
assert!(t.pointer(&cx, &ev(PointerKind::Drag, 250.0, 150.0)).is_empty());
assert_eq!(t.cursor(&cx, Point::ZERO, Mods::default()), Cursor::NotAllowed);
}
}

View File

@ -0,0 +1,280 @@
//! Magic Wand (Y) and Lasso (Q).
//!
//! Magic Wand: click an object to select every object whose fill colour is within `tolerance`
//! (0..255 RGB distance); Shift adds, Alt subtracts. Lasso: freehand loop selecting anchor points
//! inside it (direct-selection style); Shift adds, Alt subtracts.
use std::collections::BTreeSet;
use drawcraft_color::Paint;
use drawcraft_doc::hit::hit_test;
use drawcraft_doc::{AnchorRef, Document, NodeId, NodeKind};
use drawcraft_geom::Point;
use serde_json::{Value, json};
use super::paint_owner;
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, json_ids};
pub struct MagicWandTool {
/// RGB distance tolerance, 0..255.
pub tolerance: f64,
}
impl Default for MagicWandTool {
fn default() -> Self {
Self { tolerance: 32.0 }
}
}
fn fill_of(n: &drawcraft_doc::Node) -> Paint {
match &n.kind {
NodeKind::Text(t) => t.runs.first().map(|r| r.style.fill.clone()).unwrap_or_default(),
_ => n.appearance.fill_paint(),
}
}
/// Are two fills "similar" within `tol` (0..255 Euclidean RGB distance)? Non-solid paints must match exactly.
pub fn similar_fill(a: &Paint, b: &Paint, tol: f64) -> bool {
match (a.color(), b.color()) {
(Some(x), Some(y)) => {
let (x, y) = (x.to_rgb(), y.to_rgb());
let d: f64 = (0..3).map(|i| ((x[i] - y[i]) as f64 * 255.0).powi(2)).sum::<f64>().sqrt();
d <= tol + 1e-6
}
_ => a == b,
}
}
/// Paintable leaves (paths, compounds, text, images) that are visible and editable.
fn paintable(doc: &Document) -> Vec<NodeId> {
let mut out = vec![];
let mut compounds: Vec<NodeId> = vec![];
doc.walk(|n| {
if matches!(n.kind, NodeKind::Compound { .. }) {
compounds.push(n.id);
out.push(n.id);
} else if !n.is_container() {
out.push(n.id);
}
});
out.retain(|id| {
!doc.parent_of(*id).is_some_and(|p| compounds.contains(&p))
&& doc.is_editable(*id)
&& doc.is_visible(*id)
&& doc.node(*id).is_some_and(|n| n.visible)
});
out
}
impl Tool for MagicWandTool {
fn id(&self) -> &'static str {
"magicWand"
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
if ev.kind != PointerKind::Down {
return vec![];
}
let Some(h) = hit_test(cx.doc, ev.pos, cx.hit_options()) else {
return if ev.mods.shift || ev.mods.alt { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
};
let src = paint_owner(cx.doc, h.leaf);
let Some(reference) = cx.doc.node(src).map(fill_of) else { return vec![] };
let hits: Vec<NodeId> = paintable(cx.doc)
.into_iter()
.filter(|id| cx.doc.node(*id).is_some_and(|n| similar_fill(&fill_of(n), &reference, self.tolerance)))
.collect();
if ev.mods.alt {
let keep: Vec<NodeId> = cx.selection.objects.iter().copied().filter(|id| !hits.contains(id)).collect();
return vec![Action::Exec("select.set".into(), json!({ "ids": json_ids(&keep) }))];
}
let cmd = if ev.mods.shift { "select.add" } else { "select.set" };
vec![Action::Exec(cmd.into(), json!({ "ids": json_ids(&hits) }))]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
fn options(&self) -> Value {
json!({ "tolerance": self.tolerance })
}
fn set_option(&mut self, key: &str, value: &Value) {
if key == "tolerance"
&& let Some(v) = value.as_f64()
{
self.tolerance = v.clamp(0.0, 255.0);
}
}
}
/// Even-odd point-in-polygon.
pub fn point_in_polygon(poly: &[Point], p: Point) -> bool {
let mut inside = false;
let n = poly.len();
if n < 3 {
return false;
}
let mut j = n - 1;
for i in 0..n {
let (a, b) = (poly[i], poly[j]);
if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x {
inside = !inside;
}
j = i;
}
inside
}
#[derive(Default)]
pub struct LassoTool {
points: Vec<Point>,
active: bool,
}
fn anchors_json(v: &BTreeSet<AnchorRef>) -> Value {
Value::Array(v.iter().map(|(s, a)| json!([s, a])).collect())
}
impl Tool for LassoTool {
fn id(&self) -> &'static str {
"lasso"
}
fn busy(&self) -> bool {
self.active
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let p = ev.pos;
match ev.kind {
PointerKind::Down => {
self.points = vec![p];
self.active = true;
vec![]
}
PointerKind::Drag if self.active => {
if self.points.last().is_none_or(|q| q.distance(p) >= cx.tol(1.0)) {
self.points.push(p);
}
vec![]
}
PointerKind::Up if self.active => {
self.active = false;
let poly = std::mem::take(&mut self.points);
let (add, sub) = (ev.mods.shift, ev.mods.alt);
if poly.len() < 3 {
return if add || sub { vec![] } else { vec![Action::Exec("select.none".into(), json!({}))] };
}
// Anchors inside the loop for every editable path.
let mut hits: Vec<(NodeId, BTreeSet<AnchorRef>)> = vec![];
cx.doc.walk(|n| {
if let NodeKind::Path { path, .. } = &n.kind {
let v: BTreeSet<AnchorRef> =
path.anchors().filter(|(_, _, a)| point_in_polygon(&poly, a.p)).map(|(s, i, _)| (s, i)).collect();
if !v.is_empty() {
hits.push((n.id, v));
}
}
});
hits.retain(|(id, _)| cx.doc.is_editable(*id) && cx.doc.is_visible(*id));
if sub {
// Current anchor selection minus the lassoed anchors.
let mut items = vec![];
for id in &cx.selection.objects {
let Some(path) = cx.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let cur: BTreeSet<AnchorRef> = match cx.selection.partial(*id) {
Some(s) => s.clone(),
None => path.anchors().map(|(s, i, _)| (s, i)).collect(),
};
let remove = hits.iter().find(|(h, _)| h == id).map(|(_, v)| v.clone()).unwrap_or_default();
let rest: BTreeSet<AnchorRef> = cur.difference(&remove).copied().collect();
if !rest.is_empty() {
items.push(json!({ "id": id.0, "anchors": anchors_json(&rest) }));
}
}
return vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": false }))];
}
let items: Vec<Value> = hits.iter().map(|(id, v)| json!({ "id": id.0, "anchors": anchors_json(v) })).collect();
vec![Action::Exec("select.anchorsMany".into(), json!({ "items": items, "add": add }))]
}
_ => vec![],
}
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
if key == ToolKey::Escape {
self.active = false;
self.points.clear();
}
vec![]
}
fn overlays(&self, _cx: &ToolContext) -> Vec<Overlay> {
if !self.active || self.points.len() < 2 {
return vec![];
}
vec![Overlay::Path { path: super::polygon(&self.points, false), color: [0x40, 0x40, 0x40], width: 1.0, dashed: true }]
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor {
Cursor::Crosshair
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::*;
use drawcraft_color::Color;
use drawcraft_doc::{Appearance, Node, Selection};
use drawcraft_geom::{Rect, shapes};
fn add_rect(d: &mut Document, r: Rect, fill: Color) -> NodeId {
let l = d.layers[0].id;
let id = d.alloc_id();
d.insert(Some(l), usize::MAX, Node::path(id, shapes::rectangle(r), Appearance::basic(Paint::solid(fill), Paint::None, 1.0))).unwrap();
id
}
#[test]
fn magic_wand_selects_similar_fills() {
let (mut d, a) = doc_with_rect(); // white fill
let b = add_rect(&mut d, Rect::new(300.0, 300.0, 350.0, 350.0), Color::rgb8(250, 250, 250));
let c = add_rect(&mut d, Rect::new(400.0, 400.0, 450.0, 450.0), Color::rgb8(255, 0, 0));
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = MagicWandTool::default();
let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0, b.0]}))]);
t.set_option("tolerance", &json!(0));
let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 150.0, 150.0));
assert_eq!(r, vec![Action::Exec("select.set".into(), json!({"ids": [a.0]}))]);
let shift = Mods { shift: true, ..Default::default() };
let r = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 420.0, 420.0).with_mods(shift));
assert_eq!(r, vec![Action::Exec("select.add".into(), json!({"ids": [c.0]}))]);
}
#[test]
fn lasso_selects_anchors_inside() {
let (d, id) = doc_with_rect();
let s = Selection::default();
let p = paint();
let cx = cx(&d, &s, &p);
let mut t = LassoTool::default();
// A triangle around the top-left corner (100,100) only.
t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 80.0, 80.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 140.0, 80.0));
t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 80.0, 140.0));
assert_eq!(t.overlays(&cx).len(), 1);
let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 80.0, 140.0));
let Action::Exec(c, v) = &a[0] else { panic!() };
assert_eq!(c, "select.anchorsMany");
assert_eq!(v["items"][0]["id"], id.0);
assert_eq!(v["items"][0]["anchors"].as_array().unwrap().len(), 1);
assert_eq!(v["add"], false);
assert!(point_in_polygon(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0), Point::new(0.0, 10.0)], Point::new(2.0, 2.0)));
}
}

View File

@ -43,8 +43,13 @@ impl Xf {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Drag {
Tool,
Pan { start: Pos2, center: Point },
ZoomBox { start: Pos2 },
Pan {
start: Pos2,
center: Point,
},
ZoomBox {
start: Pos2,
},
/// Cmd held: temporary selection tool; restore this tool on release.
TempSelect,
}
@ -70,7 +75,9 @@ pub fn fit(app: &mut DrawcraftApp, how: &str) {
};
let Some(st) = app.session.active() else { return };
let target = match how {
"view.fitAll" => st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect)),
"view.fitAll" => {
st.doc.art_bounds().map(|a| st.doc.artboards.iter().fold(a, |r, ab| r.union(ab.rect))).or(st.doc.artboards.first().map(|a| a.rect))
}
_ => st.doc.artboards.first().map(|a| a.rect),
};
let Some(target) = target else { return };
@ -112,8 +119,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
if app.ui.view.artboards && !app.ui.view.outline {
for ab in &doc.artboards {
let r = xf.rect_to_screen(ab.rect);
painter.rect_filled(r.translate(vec2(2.0, 2.0)).expand(1.0), 1.0, Color32::from_black_alpha(90));
painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(110));
// Hard 2 pt drop shadow, right and bottom (measured: #4d4d4d then #565656 on #606060).
painter.rect_filled(r.translate(vec2(2.0, 2.0)), 0.0, Color32::from_black_alpha(26));
painter.rect_filled(r.translate(vec2(1.0, 1.0)), 0.0, Color32::from_black_alpha(52));
if app.ui.view.transparency_grid {
checker(&painter, r);
} else {
@ -132,7 +140,18 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
// Artwork raster.
let ppp = ui.ctx().pixels_per_point();
let (w, h) = ((rect.width() * ppp).round().max(1.0) as u32, (rect.height() * ppp).round().max(1.0) as u32);
let key = CacheKey { doc: app.session.active_index().unwrap_or(0), revision: st.revision, zoom: v.zoom, cx: v.center.x, cy: v.center.y, w, h, outline: app.ui.view.outline, ppp, hidden: vec![] };
let key = CacheKey {
doc: app.session.active_index().unwrap_or(0),
revision: st.revision,
zoom: v.zoom,
cx: v.center.x,
cy: v.center.y,
w,
h,
outline: app.ui.view.outline,
ppp,
hidden: vec![],
};
if !app.canvas.worker_started {
app.canvas.worker_started = true;
if std::env::var_os("DRAWCRAFT_SYNC_RENDER").is_none() {
@ -177,7 +196,13 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let c = if i == active_ab { Color32::from_gray(0) } else { Color32::from_gray(120) };
painter.rect_stroke(r, 0.0, Stroke::new(if i == active_ab { 1.0 } else { 0.6 }, c), StrokeKind::Outside);
if app.session.tool_id() == "artboard" || doc.artboards.len() > 1 {
painter.text(r.left_top() - vec2(0.0, 4.0), egui::Align2::LEFT_BOTTOM, format!("{:02} - {}", i + 1, ab.name), egui::FontId::proportional(11.0), t.text_dim);
painter.text(
r.left_top() - vec2(0.0, 4.0),
egui::Align2::LEFT_BOTTOM,
format!("{:02} - {}", i + 1, ab.name),
egui::FontId::proportional(11.0),
t.text_dim,
);
}
}
if app.ui.view.guides {
@ -205,6 +230,9 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, &t);
}
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
task_bar(app, ui, &xf);
}
// Cursor.
if resp.hovered() {
let m = ui.input(|i| i.modifiers);
@ -272,7 +300,8 @@ fn cursor_icon(c: Cursor) -> egui::CursorIcon {
}
fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: egui::Rect) {
let (pointer, m, space, scroll, zoom_delta) = ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta()));
let (pointer, m, space, scroll, zoom_delta) =
ui.input(|i| (i.pointer.clone(), i.modifiers, i.key_down(egui::Key::Space), i.smooth_scroll_delta, i.zoom_delta()));
let v = *app.view().unwrap_or(&View::default());
let xf = Xf { rect, zoom: v.zoom, center: v.center };
let hover = pointer.hover_pos().filter(|p| rect.contains(*p));
@ -480,7 +509,13 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, t: &Tokens)
let sx = xf.to_screen(Point::new(x, 0.0)).x;
let is_major = ((x / step).round() * step - x).abs() < minor * 0.01;
let is_mid = ((x / (step / 2.0)).round() * (step / 2.0) - x).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_top.line_segment([pos2(sx, top.bottom() - len), pos2(sx, top.bottom())], Stroke::new(1.0, t.ruler_tick));
if is_major {
clip_top.text(pos2(sx + 2.0, top.top() + 1.0), egui::Align2::LEFT_TOP, format!("{}", x.round() as i64), font.clone(), t.ruler_tick);
@ -495,13 +530,25 @@ fn rulers(ui: &Ui, full: egui::Rect, xf: &Xf, hover: Option<Point>, t: &Tokens)
let sy = xf.to_screen(Point::new(0.0, y)).y;
let is_major = ((y / step).round() * step - y).abs() < minor * 0.01;
let is_mid = ((y / (step / 2.0)).round() * (step / 2.0) - y).abs() < minor * 0.01;
let len = if is_major { RULER } else if is_mid { 7.0 } else { 4.0 };
let len = if is_major {
RULER
} else if is_mid {
7.0
} else {
4.0
};
clip_left.line_segment([pos2(left.right() - len, sy), pos2(left.right(), sy)], Stroke::new(1.0, t.ruler_tick));
if is_major {
// Vertical labels read top-to-bottom, one digit per line like Illustrator.
let s = format!("{}", y.round() as i64);
for (k, ch) in s.chars().enumerate() {
clip_left.text(pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5), egui::Align2::LEFT_TOP, ch.to_string(), font.clone(), t.ruler_tick);
clip_left.text(
pos2(left.left() + 4.0, sy + 2.0 + k as f32 * 8.5),
egui::Align2::LEFT_TOP,
ch.to_string(),
font.clone(),
t.ruler_tick,
);
}
}
y += minor;
@ -585,6 +632,11 @@ fn hover_highlight(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
}
}
/// Selected anchors are drawn slightly deeper than the layer colour (#4f80ff → #3d82ff for Layer 1).
fn selected_anchor(c: Color32) -> Color32 {
if c == Color32::from_rgb(0x4f, 0x80, 0xff) { Color32::from_rgb(0x3d, 0x82, 0xff) } else { c }
}
fn anchor_square(p: &egui::Painter, c: Pos2, color: Color32, filled: bool, size: f32) {
let r = egui::Rect::from_center_size(c, vec2(size, size));
if filled {
@ -619,11 +671,17 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
if h.distance(a.p) > 1e-6 {
let hp = xf.to_screen(h);
p.line_segment([sp, hp], Stroke::new(1.0, color));
p.circle_filled(hp, 2.8, color);
p.circle_filled(hp, 2.75, color);
}
}
}
anchor_square(p, sp, color, sel, if partial.is_some() || direct { 6.0 } else { 4.5 });
anchor_square(
p,
sp,
if sel && partial.is_some() { selected_anchor(color) } else { color },
sel,
if partial.is_some() || direct { 5.0 } else { 4.0 },
);
}
});
// Text: baseline marker.
@ -643,16 +701,14 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
p.rect_stroke(r, 0.0, Stroke::new(1.0, color), StrokeKind::Middle);
for h in drawcraft_tools::bbox::Handle::ALL {
let c = xf.to_screen(h.pos(b));
let hr = egui::Rect::from_center_size(c, vec2(7.0, 7.0));
let hr = egui::Rect::from_center_size(c, vec2(6.0, 6.0));
p.rect_filled(hr, 0.0, Color32::WHITE);
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
}
// Centre point.
let c = r.center();
p.rect_filled(egui::Rect::from_center_size(c, vec2(3.0, 3.0)), 0.0, color);
// Live corner widgets on single live rectangles.
if st.selection.len() == 1
&& let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) = st.doc.node(st.selection.objects[0]).map(|n| &n.kind)
&& let Some(NodeKind::Path { live: Some(drawcraft_doc::LiveShape::Rectangle { w, h, radii, xf: lxf }), .. }) =
st.doc.node(st.selection.objects[0]).map(|n| &n.kind)
&& r.width() > 40.0
&& r.height() > 40.0
{
@ -763,7 +819,6 @@ fn home(app: &mut DrawcraftApp, ui: &mut Ui, rect: egui::Rect) {
});
}
fn kurbo_flatten(p: &BezPath, tol: f64, f: &mut impl FnMut(PathEl)) {
drawcraft_geom::kurbo::flatten(p.elements().iter().copied(), tol, f);
}
@ -775,3 +830,86 @@ fn upload(app: &mut DrawcraftApp, ctx: &egui::Context, img: &drawcraft_render::R
None => app.canvas.texture = Some(ctx.load_texture("canvas", color, egui::TextureOptions::LINEAR)),
}
}
/// The Contextual Task Bar: a floating pill under the selection with the most likely next actions.
fn task_bar(app: &mut DrawcraftApp, ui: &mut Ui, xf: &Xf) {
let t = Tokens::get(ui.ctx());
let Some(st) = app.session.active() else { return };
if st.selection.is_empty() || !matches!(app.session.tool_id(), "selection" | "directSelection" | "groupSelection") {
return;
}
let Some(b) = st.doc.bounds_of(&st.selection.objects, true) else { return };
let n = st.selection.len();
let first = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned();
let is_group = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Group { .. }));
let is_text = first.as_ref().is_some_and(|f| matches!(f.kind, NodeKind::Text(_)));
let mut items: Vec<(&str, &str, &str)> = vec![]; // (label, icon, command)
if n > 1 {
items.push(("Group", "group", "object.group"));
items.push(("Unite", "squares-unite", "object.pathfinder.unite"));
} else if is_group {
items.push(("Ungroup", "ungroup", "object.ungroup"));
items.push(("Isolate", "square-dashed", "object.isolate"));
} else if is_text {
items.push(("Create Outlines", "type", "type.createOutlines"));
} else {
items.push(("Offset Path", "square-dashed", "object.path.offsetPath"));
items.push(("Simplify", "spline", "object.path.simplify"));
}
items.push(("Duplicate", "copy", "edit.duplicate"));
let fill = first.as_ref().map(|f| f.appearance.fill_paint()).unwrap_or_default();
let anchor = xf.to_screen(Point::new(b.center().x, b.y1));
let est_w = 118.0 + items.iter().map(|(l, _, _)| l.len() as f32 * 7.2 + 44.0).sum::<f32>();
let x = (anchor.x - est_w / 2.0).clamp(xf.rect.left() + 8.0, (xf.rect.right() - est_w - 8.0).max(xf.rect.left() + 8.0));
let y = (anchor.y + 28.0).min(xf.rect.bottom() - 56.0);
let mut run: Option<String> = None;
egui::Area::new(egui::Id::new("task-bar")).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ui.ctx(), |ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.tool_active))
.corner_radius(CornerRadius::same(5))
.inner_margin(egui::Margin::symmetric(8, 6))
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(70) })
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
let (g, _) = ui.allocate_exact_size(vec2(4.0, 28.0), Sense::hover());
ui.painter().rect_filled(g.shrink2(vec2(0.5, 4.0)), CornerRadius::same(2), t.button_border);
for (label, icon, cmd) in &items {
let galley = ui.painter().layout_no_wrap(label.to_string(), egui::FontId::proportional(13.0), t.text_strong);
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 38.0, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(3), t.hover);
}
ui.painter().rect_stroke(r, CornerRadius::same(3), Stroke::new(1.0, t.button_border), StrokeKind::Inside);
crate::icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(8.0, 7.0), vec2(16.0, 16.0)), t.icon);
ui.painter().galley(pos2(r.left() + 30.0, r.center().y - galley.size().y / 2.0), galley, t.text_strong);
if resp.clicked() {
run = Some(cmd.to_string());
}
}
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 30.0), Sense::click());
widgets::paint_chip(ui, egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)), &fill);
ui.painter().rect_stroke(
egui::Rect::from_center_size(r.center(), vec2(16.0, 16.0)),
0.0,
Stroke::new(1.0, t.button_border),
StrokeKind::Outside,
);
if resp.on_hover_text("Fill").clicked() {
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if widgets::icon_button(ui, "lock", "Lock (⌘2)", false, 30.0).clicked() {
run = Some("object.lock".into());
}
if widgets::icon_button(ui, "ellipsis", "Hide Contextual Task Bar", false, 30.0).clicked() {
run = Some("window.taskBar".into());
}
});
});
});
if let Some(c) = run {
crate::menus::invoke(app, &c, json!({}));
}
}

View File

@ -13,7 +13,7 @@ use crate::{DrawcraftApp, icons, menus};
pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let left = if app.integrated_titlebar { 78 } else { 8 };
egui::Panel::top("app_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 })).show(ui, |ui| {
egui::Panel::top("app_bar").exact_size(44.0).frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: 14, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Brand mark: a small rounded square with "Dc".
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
@ -65,110 +65,117 @@ fn chip_button(ui: &mut Ui, paint: &Paint, stroke_style: bool, tip: &str) -> egu
/// The Control bar (Window → Control), context-sensitive like Illustrator's.
pub fn control_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::top("control_bar").exact_size(34.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Gripper
let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover());
for i in 0..5 {
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
}
let Some(st) = app.session.active() else {
ui.label(egui::RichText::new("No Document").color(t.text_dim));
return;
};
let sel = st.selection.objects.clone();
let units = st.doc.units;
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match (&first, sel.len()) {
(None, _) => "No Selection".to_string(),
(_, n) if n > 1 => "Mixed Objects".to_string(),
(Some(_), _) if anchor_mode => "Anchor Point".to_string(),
(Some(n), _) => match &n.kind {
NodeKind::Text(_) => "Type".into(),
NodeKind::Image(_) => "Embedded".into(),
NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(),
_ => n.kind_label().to_string(),
},
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);
let (fill, stroke, weight, opacity) = match &first {
Some(n) => (n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity),
None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0),
};
if chip_button(ui, &fill, false, "Fill").clicked() {
app.run("paint.toggleActive", json!({})).ok();
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
if chip_button(ui, &stroke, true, "Stroke").clicked() {
app.session.fill_active = false;
app.ui.open_panel = Some("swatches".into());
}
if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("stroke".into());
}
if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& !sel.is_empty()
{
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
}
ui.separator();
if sel.is_empty() {
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
egui::Panel::top("control_bar")
.exact_size(34.0)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
// Gripper
let (g, _) = ui.allocate_exact_size(vec2(6.0, 20.0), Sense::hover());
for i in 0..5 {
ui.painter().circle_filled(g.center_top() + vec2(0.0, 2.0 + i as f32 * 4.0), 0.9, t.text_disabled);
}
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
app.run("edit.preferences", json!({})).ok();
}
return;
}
// Align buttons.
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
let Some(st) = app.session.active() else {
ui.label(egui::RichText::new("No Document").color(t.text_dim));
return;
};
let sel = st.selection.objects.clone();
let units = st.doc.units;
let first = sel.first().and_then(|id| st.doc.node(*id)).cloned();
let anchor_mode = !st.selection.anchors.is_empty();
let label = match (&first, sel.len()) {
(None, _) => "No Selection".to_string(),
(_, n) if n > 1 => "Mixed Objects".to_string(),
(Some(_), _) if anchor_mode => "Anchor Point".to_string(),
(Some(n), _) => match &n.kind {
NodeKind::Text(_) => "Type".into(),
NodeKind::Image(_) => "Embedded".into(),
NodeKind::Path { live: Some(_), .. } => n.kind_label().to_string(),
_ => n.kind_label().to_string(),
},
};
ui.label(egui::RichText::new(label).font(theme::semibold(12.0)).color(t.text));
ui.add_space(6.0);
let (fill, stroke, weight, opacity) = match &first {
Some(n) => {
(n.appearance.fill_paint(), n.appearance.stroke_paint(), n.appearance.stroke().map(|s| s.width).unwrap_or(0.0), n.opacity)
}
app.run("object.align", p).ok();
None => (app.session.paint.fill.clone(), app.session.paint.stroke.clone(), app.session.paint.stroke_width, 1.0),
};
if chip_button(ui, &fill, false, "Fill").clicked() {
app.run("paint.toggleActive", json!({})).ok();
app.session.fill_active = true;
app.ui.open_panel = Some("swatches".into());
}
}
ui.separator();
// Transform fields.
let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false));
if let Some(b) = b {
for (k, lbl, v) in [("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())] {
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4})).ok();
if chip_button(ui, &stroke, true, "Stroke").clicked() {
app.session.fill_active = false;
app.ui.open_panel = Some("swatches".into());
}
if ui.link(egui::RichText::new("Stroke:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("stroke".into());
}
if let Some(w) = widgets::num_field(ui, "cb-stroke", Some(weight), drawcraft_doc::Unit::Points, 64.0) {
app.run("stroke.set", json!({"weight": w})).ok();
}
ui.add_space(4.0);
ui.separator();
if ui.link(egui::RichText::new("Opacity:").size(12.0).color(t.text).underline()).clicked() {
app.ui.open_panel = Some("transparency".into());
}
if let Some(o) = widgets::plain_field(ui, "cb-opacity", opacity as f64 * 100.0, "%", 0, 56.0)
&& !sel.is_empty()
{
app.run("object.setProps", json!({"opacity": o.clamp(0.0, 100.0) / 100.0})).ok();
}
ui.separator();
if sel.is_empty() {
if widgets::flat_button(ui, "Document Setup", 112.0).clicked() {
app.run("file.documentSetup", json!({})).ok();
}
if widgets::flat_button(ui, "Preferences", 90.0).clicked() {
app.run("edit.preferences", json!({})).ok();
}
return;
}
// Align buttons.
for (icon, tip, p) in [
("align-start-vertical", "Horizontal Align Left", json!({"horizontal": "left"})),
("align-center-vertical", "Horizontal Align Center", json!({"horizontal": "center"})),
("align-end-vertical", "Horizontal Align Right", json!({"horizontal": "right"})),
("align-start-horizontal", "Vertical Align Top", json!({"vertical": "top"})),
("align-center-horizontal", "Vertical Align Center", json!({"vertical": "center"})),
("align-end-horizontal", "Vertical Align Bottom", json!({"vertical": "bottom"})),
] {
if widgets::icon_button(ui, icon, tip, false, 24.0).clicked() {
let mut p = p;
if sel.len() == 1 {
p["to"] = json!("artboard");
}
app.run("object.align", p).ok();
}
}
}
ui.separator();
// Transform fields.
let b = app.session.active().and_then(|s| s.doc.bounds_of(&s.selection.objects, false));
if let Some(b) = b {
for (k, lbl, v) in
[("x", "X:", b.center().x), ("y", "Y:", b.center().y), ("width", "W:", b.width()), ("height", "H:", b.height())]
{
ui.label(egui::RichText::new(lbl).size(12.0).color(t.text_dim));
if let Some(nv) = widgets::num_field(ui, ("cb", k), Some(v), units, 74.0) {
app.run("object.setBounds", json!({k: nv, "reference": 4})).ok();
}
}
}
});
});
});
}
/// Document tab strip: "Name* @ 66.67% (RGB/Preview)".
pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 27.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 35.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
let mut activate = None;
@ -178,23 +185,23 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
let zoom = app.views.get(i).map(|v| v.zoom).unwrap_or(1.0);
let mode = if d.doc.color_mode == drawcraft_doc::ColorMode::Cmyk { "CMYK" } else { "RGB" };
let vm = if app.ui.view.outline { "Outline" } else { "Preview" };
let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom));
let galley = ui.painter().layout_no_wrap(title, egui::FontId::proportional(12.0), if Some(i) == active { t.text } else { t.text_dim });
let w = galley.size().x + 44.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height()));
let title = format!("{}{} @ {} ({mode}/{vm})", d.title(), if d.is_dirty() { "*" } else { "" }, zoom_label(zoom).replace('%', " %"));
let is_active = Some(i) == active;
let galley = ui.painter().layout_no_wrap(title, theme::semibold(12.5), if is_active { t.text_strong } else { t.text_dim });
let w = galley.size().x + 50.0;
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(w, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("tab", i)), Sense::click());
if Some(i) == active {
if is_active {
ui.painter().rect_filled(r, 0.0, t.panel);
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.5));
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
let xr = egui::Rect::from_center_size(egui::pos2(r.right() - 15.0, r.center().y), vec2(14.0, 14.0));
let xresp = ui.interact(xr, ui.id().with(("tabx", i)), Sense::click());
if xresp.hovered() || Some(i) == active || resp.hovered() {
icons::paint(ui, "x", xr.shrink(1.5), if xresp.hovered() { t.text } else { t.text_dim });
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.4));
}
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.5, t.border));
// × at the left like Illustrator.
let xr = egui::Rect::from_center_size(egui::pos2(r.left() + 16.0, r.center().y), vec2(12.0, 12.0));
let xresp = ui.interact(xr.expand(3.0), ui.id().with(("tabx", i)), Sense::click());
icons::paint(ui, "x", xr, if xresp.hovered() { t.text_strong } else { t.text });
ui.painter().galley(egui::pos2(r.left() + 32.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if xresp.clicked() {
close = Some(i);
} else if resp.clicked() {
@ -204,7 +211,9 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
}
if let Some(i) = close {
app.session.close_document(i);
app.views.remove(i.min(app.views.len().saturating_sub(1)));
if i < app.views.len() {
app.views.remove(i);
}
app.sync_views();
} else if let Some(i) = activate {
app.session.set_active(i);
@ -213,13 +222,20 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
if let Some(st) = app.session.active()
&& let Some(iso) = st.isolation
{
let crumbs: Vec<String> = st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect();
let crumbs: Vec<String> =
st.doc.ancestry(iso).unwrap_or_default().iter().filter_map(|id| st.doc.node(*id)).map(|n| n.display_name()).collect();
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover());
ui.painter().rect_filled(bar, 0.0, t.panel);
let back = egui::Rect::from_min_size(bar.min + vec2(6.0, 3.0), vec2(18.0, 18.0));
let bresp = ui.interact(back, ui.id().with("iso-back"), Sense::click());
icons::paint(ui, "chevron-left", back, if bresp.hovered() { t.text } else { t.icon });
ui.painter().text(bar.left_center() + vec2(30.0, 0.0), egui::Align2::LEFT_CENTER, crumbs.join(" › "), egui::FontId::proportional(12.0), t.text);
ui.painter().text(
bar.left_center() + vec2(30.0, 0.0),
egui::Align2::LEFT_CENTER,
crumbs.join(" › "),
egui::FontId::proportional(12.0),
t.text,
);
if bresp.clicked() {
app.run("object.exitIsolation", json!({})).ok();
}
@ -228,54 +244,171 @@ pub fn doc_tabs(app: &mut DrawcraftApp, ui: &mut Ui) {
pub fn status_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("status_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.horizontal_centered(|ui| {
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(ui, |ui| {
for z in ZOOM_STOPS.iter().rev() {
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
app.run("view.setZoom", json!({"zoom": z})).ok();
}
hint_bar(app, ui);
egui::Panel::bottom("status_bar")
.exact_size(24.0)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
egui::ComboBox::from_id_salt("zoom-combo").selected_text(egui::RichText::new(zoom_label(zoom)).size(11.5)).width(76.0).show_ui(
ui,
|ui| {
for z in ZOOM_STOPS.iter().rev() {
if ui.selectable_label((z / 100.0 - zoom).abs() < 1e-4, zoom_label(z / 100.0)).clicked() {
app.run("view.setZoom", json!({"zoom": z})).ok();
}
}
ui.separator();
if ui.selectable_label(false, "Fit on Screen").clicked() {
app.run("view.fitArtboard", json!({})).ok();
}
if ui.selectable_label(false, "Fit All").clicked() {
app.run("view.fitAll", json!({})).ok();
}
},
);
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(
ui,
|ui| {
for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] {
if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked()
&& let Some(v) = app.view_mut()
{
v.rotation = a;
}
}
},
);
ui.separator();
let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
for icon in ["chevrons-left", "chevron-left"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5));
for icon in ["chevron-right", "chevrons-right"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.separator();
if ui.selectable_label(false, "Fit on Screen").clicked() {
app.run("view.fitArtboard", json!({})).ok();
}
if ui.selectable_label(false, "Fit All").clicked() {
app.run("view.fitAll", json!({})).ok();
}
});
let rot = app.view().map(|v| v.rotation).unwrap_or(0.0);
egui::ComboBox::from_id_salt("rot-combo").selected_text(egui::RichText::new(format!("{rot:.0}°")).size(11.5)).width(52.0).show_ui(ui, |ui| {
for a in [0.0, 15.0, 30.0, 45.0, 60.0, 90.0, 180.0, -15.0, -30.0, -45.0, -60.0, -90.0] {
if ui.selectable_label(rot == a, format!("{a:.0}°")).clicked()
&& let Some(v) = app.view_mut()
{
v.rotation = a;
let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
egui::RichText::new(format!(
"render {:.1} ms · ui {:.1} ms · {:.0} fps",
app.perf.render_ms, app.perf.frame_ms, app.perf.fps
))
.size(10.5)
.color(t.text_disabled),
);
if let Some(p) = app.hover_doc {
ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim));
}
}
});
ui.separator();
let nab = app.session.active().map(|d| d.doc.artboards.len()).unwrap_or(0);
for icon in ["chevrons-left", "chevron-left"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.label(egui::RichText::new(if nab > 0 { "1" } else { "–" }).size(11.5));
for icon in ["chevron-right", "chevrons-right"] {
widgets::icon_button(ui, icon, "", false, 18.0);
}
ui.separator();
let tool = drawcraft_tools::tool_info(app.session.tool_id()).map(|t| t.label.trim_end_matches(" Tool")).unwrap_or("");
ui.label(egui::RichText::new(tool).size(11.5).color(t.text_dim));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(format!("render {:.1} ms · ui {:.1} ms · {:.0} fps", app.perf.render_ms, app.perf.frame_ms, app.perf.fps)).size(10.5).color(t.text_disabled));
if let Some(p) = app.hover_doc {
ui.label(egui::RichText::new(format!("X: {:.2} Y: {:.2}", p.x, p.y)).font(theme::mono(10.5)).color(t.text_dim));
}
if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
}
if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
}
});
});
});
}
/// Contextual hint for the active tool: segments of (text, bold).
fn hint_for(tool: &str) -> &'static [(&'static str, bool)] {
match tool {
"selection" => &[
("Click", true),
(" the object to select | ", false),
("Shift+Click", true),
(" to select multiple objects | ", false),
("Option+Drag", true),
(" the object to duplicate", false),
],
"directSelection" => &[
("Click", true),
(" an anchor or path segment to select it | ", false),
("Drag", true),
(" to move | ", false),
("Shift+Click", true),
(" to add", false),
],
"pen" => &[
("Click", true),
(" to add a corner point | ", false),
("Drag", true),
(" to add a smooth point | ", false),
("Click the first point", true),
(" to close | ", false),
("Enter", true),
(" to finish", false),
],
"curvature" => &[
("Click", true),
(" to add smooth points | ", false),
("Double-click", true),
(" to toggle corner | ", false),
("Esc", true),
(" to finish", false),
],
"type" => &[
("Click", true),
(" to create point type | ", false),
("Drag", true),
(" to create area type | ", false),
("Esc", true),
(" to exit editing", false),
],
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" => &[
("Drag", true),
(" to draw | ", false),
("Shift+Drag", true),
(" to constrain proportions | ", false),
("Option+Drag", true),
(" from center | ", false),
("Click", true),
(" for exact size", false),
],
"rotate" | "reflect" | "scale" | "shear" => &[
("Click", true),
(" to set the reference point | ", false),
("Drag", true),
(" to transform | ", false),
("Option+Click", true),
(" for exact values", false),
],
"hand" => &[("Drag", true), (" to pan the view", false)],
"zoom" => &[
("Click", true),
(" to zoom in | ", false),
("Option+Click", true),
(" to zoom out | ", false),
("Drag", true),
(" to zoom into an area", false),
],
"eyedropper" => &[("Click", true), (" an object to copy its appearance | ", false), ("Shift+Click", true), (" to sample a color", false)],
"gradient" => &[("Drag", true), (" across a selected object to set the gradient direction", false)],
"artboard" => &[("Click", true), (" to select an artboard | ", false), ("Drag", true), (" on the canvas to create one", false)],
_ => &[("Press ", false), ("Cmd+Shift+/", true), (" to search every command", false)],
}
}
pub fn hint_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
egui::Panel::bottom("hint_bar")
.exact_size(28.0)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(10, 0)).stroke(Stroke::new(1.0, t.border)))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover());
ui.painter().circle_stroke(r.center(), 7.0, Stroke::new(1.2, t.text));
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text);
ui.add_space(6.0);
let mut job = egui::text::LayoutJob::default();
for (txt, bold) in hint_for(app.session.tool_id()) {
let font = if *bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) };
job.append(txt, 0.0, egui::TextFormat { font_id: font, color: if *bold { t.text_strong } else { t.text }, ..Default::default() });
}
ui.label(job);
});
});
});
}

View File

@ -144,7 +144,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest)
"ui.key" => {
let Some(k) = s("key").and_then(key_from) else { return err("unknown or missing `key`") };
let b = |n: &str| p.get(n).and_then(Value::as_bool).unwrap_or(false);
let m = egui::Modifiers { alt: b("alt"), ctrl: b("ctrl"), shift: b("shift"), mac_cmd: b("cmd") && cfg!(target_os = "macos"), command: b("cmd") };
let m = egui::Modifiers {
alt: b("alt"),
ctrl: b("ctrl"),
shift: b("shift"),
mac_cmd: b("cmd") && cfg!(target_os = "macos"),
command: b("cmd"),
};
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
if let Some(t) = s("text") {
@ -166,7 +172,13 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest)
if let Some(pn) = s("panel") {
r = app.run("window.panel", json!({"panel": pn}));
}
for (k, flag) in [("rulers", "view.rulers"), ("outline", "view.outline"), ("grid", "view.grid"), ("smartGuides", "view.smartGuides"), ("boundingBox", "view.boundingBox")] {
for (k, flag) in [
("rulers", "view.rulers"),
("outline", "view.outline"),
("grid", "view.grid"),
("smartGuides", "view.smartGuides"),
("boundingBox", "view.boundingBox"),
] {
if let Some(want) = p.get(k).and_then(Value::as_bool) {
let cur = match k {
"rulers" => app.ui.view.rulers,
@ -236,7 +248,10 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context, req: &ControlRequest)
"app.save" => wrap(app.run("file.save", json!({"path": s("path")}))),
"app.export" => {
let f = s("format").unwrap_or("png");
wrap(crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})))
wrap(
crate::io::export(app, f, s("path").map(str::to_string), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0))
.map(|p| json!({"path": p})),
)
}
"app.quit" => {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);

View File

@ -17,11 +17,34 @@ pub fn open_tool_dialog(app: &mut DrawcraftApp, kind: &str, p: Value) {
"polygon" => Dialog::new(kind, json!({"x": x, "y": y, "radius": "50 pt", "sides": 6})),
"star" => Dialog::new(kind, json!({"x": x, "y": y, "radius1": "50 pt", "radius2": "25 pt", "points": 5})),
"lineSegment" => Dialog::new(kind, json!({"x": x, "y": y, "length": "100 pt", "angle": 0})),
"rotate" | "reflect" | "scale" | "shear" | "artboardOptions" => {
let mut base = match kind {
"rotate" => json!({"angle": 0}),
"reflect" => json!({"axis": "vertical"}),
"scale" => json!({"sx": 100, "sy": 100, "uniform": true}),
"shear" => json!({"angle": 0, "axis": "horizontal"}),
_ => json!({}),
};
if let (Some(b), Some(o)) = (base.as_object_mut(), p.as_object()) {
for (k, v) in o {
b.insert(k.clone(), v.clone());
}
}
Dialog::new(kind, base)
}
_ => return,
};
app.ui.dialog = Some(d);
}
/// Pass a tool-chosen reference point (origin) through to transform commands.
fn origin_params(d: &Dialog, mut p: Value) -> Value {
if let Some(o) = d.fields.get("origin") {
p["origin"] = o.clone();
}
p
}
fn title(kind: &str) -> &'static str {
match kind {
"newDocument" => "New Document",
@ -42,6 +65,7 @@ fn title(kind: &str) -> &'static str {
"splitIntoGrid" => "Split Into Grid",
"documentSetup" => "Document Setup",
"preferences" => "Preferences",
"artboardOptions" => "Artboard Options",
"allTools" => "All Tools",
_ => "Dialog",
}
@ -68,14 +92,23 @@ pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
app.run("shape.line", json!({"x1": x, "y1": y, "x2": x + l * a.cos(), "y2": y - l * a.sin()}))
}
"move" => app.run("object.move", json!({"dx": d.f64("dx", 0.0), "dy": d.f64("dy", 0.0), "copy": copy})),
"rotate" => app.run("object.rotate", json!({"angle": d.f64("angle", 0.0), "copy": copy})),
"rotate" => app.run("object.rotate", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "copy": copy}))),
"scale" => {
let sx = d.f64("sx", 100.0);
let sy = if d.bool("uniform") { sx } else { d.f64("sy", 100.0) };
app.run("object.scale", json!({"sx": sx, "sy": sy, "copy": copy}))
app.run("object.scale", origin_params(&d, json!({"sx": sx, "sy": sy, "copy": copy})))
}
"reflect" => app.run("object.reflect", origin_params(&d, json!({"axis": d.fields.get("axis").cloned().unwrap_or(json!("vertical")), "copy": copy}))),
"shear" => app.run("object.shear", origin_params(&d, json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy}))),
"artboardOptions" => {
let mut p = serde_json::Value::Object(d.fields.clone());
for k in ["x", "y", "width", "height"] {
if d.fields.contains_key(k) {
p[k] = json!(d.f64(k, 0.0));
}
}
app.run("artboard.setProps", p)
}
"reflect" => app.run("object.reflect", json!({"axis": d.str("axis"), "copy": copy})),
"shear" => app.run("object.shear", json!({"angle": d.f64("angle", 0.0), "axis": d.str("axis"), "copy": copy})),
"average" => app.run("path.average", json!({"axis": d.str("axis")})),
"offsetPath" => app.run("object.path.offsetPath", json!({"offset": d.f64("offset", 10.0), "joins": d.str("joins"), "miterLimit": d.f64("miterLimit", 4.0)})),
"simplify" => app.run("object.path.simplify", json!({"tolerance": d.f64("tolerance", 1.0)})),
@ -96,7 +129,12 @@ fn field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(label).color(t.text_dim));
let mut s = d.str(key);
let r = egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).corner_radius(egui::CornerRadius::same(3)).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
let r = egui::Frame::NONE
.fill(t.input)
.stroke(egui::Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut s).frame(egui::Frame::NONE).desired_width(120.0)));
if r.inner.changed() {
d.fields.insert(key.into(), Value::String(s));
}
@ -113,11 +151,16 @@ fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) {
pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
if app.ui.about {
let mut open = true;
egui::Window::new("About DrawCraft").collapsible(false).resizable(false).open(&mut open).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| {
ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft.");
});
egui::Window::new("About DrawCraft")
.collapsible(false)
.resizable(false)
.open(&mut open)
.anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
.show(ctx, |ui| {
ui.label(egui::RichText::new("DrawCraft").font(theme::semibold(22.0)));
ui.label(format!("Version {} — open-source vector illustration in pure Rust.", env!("CARGO_PKG_VERSION")));
ui.label("MIT OR Apache-2.0. Fonts: Source Sans 3, Inter, JetBrains Mono (OFL). Icons: Lucide (ISC) + DrawCraft.");
});
app.ui.about = open;
}
let Some(mut d) = app.ui.dialog.clone() else { return };
@ -158,7 +201,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
egui::Grid::new("dlg").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {
let keys: Vec<(String, Value)> = d.fields.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
for (k, v) in keys {
if k == "x" || k == "y" || v.is_boolean() {
if k == "x" || k == "y" || k == "origin" || k == "index" || v.is_boolean() {
continue;
}
field(ui, &mut d, &k, &humanize(&k));

View File

@ -11,52 +11,66 @@ const ICON_COL: f32 = 38.0;
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
// Collapsed icon column (rightmost).
egui::Panel::right("icon_column").resizable(false).exact_size(ICON_COL).frame(egui::Frame::NONE.fill(t.panel_darker).inner_margin(egui::Margin::symmetric(3, 6)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
// Main tabbed group.
egui::Panel::right("dock")
.resizable(true)
.default_size(300.0)
.size_range(230.0..=520.0)
.frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
// Tab strip.
let (hdr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::hover());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(ui, "chevrons-right", egui::Rect::from_min_size(hdr.right_top() + vec2(-14.0, 2.0), vec2(10.0, 10.0)), t.text);
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.tab_strip);
ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));
let mut x = strip.left();
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
let active = app.ui.dock_tab == tab;
let galley = ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.5), if active { t.text_strong } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height() - 1.0));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
let open = app.ui.open_panel.as_deref() == Some(*id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim);
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
});
// Collapsed icon-panel strip, left of the expanded panel group (like Illustrator's dock).
egui::Panel::right("icon_column")
.resizable(false)
.exact_size(ICON_COL)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(4, 6)).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
for (gi, group) in ICON_PANEL_GROUPS.iter().enumerate() {
if gi > 0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 7.0), Sense::hover());
ui.painter().line_segment([r.left_center() + vec2(4.0, 0.0), r.right_center() - vec2(4.0, 0.0)], Stroke::new(1.0, t.divider));
}
for id in group.iter() {
let Some((_, label, icon)) = ICON_PANELS.iter().find(|p| p.0 == *id) else { continue };
let open = app.ui.open_panel.as_deref() == Some(*id);
if widgets::icon_button(ui, icon, label, open, 30.0).clicked() {
app.ui.open_panel = if open { None } else { Some(id.to_string()) };
}
}
}
}
});
// Main tabbed group.
egui::Panel::right("dock").resizable(true).default_size(286.0).size_range(230.0..=520.0).frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
// Tab strip.
let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
ui.painter().rect_filled(strip, 0.0, t.panel_darker);
let mut x = strip.left();
for (tab, label) in [(DockTab::Properties, "Properties"), (DockTab::Layers, "Layers"), (DockTab::Libraries, "Libraries")] {
let active = app.ui.dock_tab == tab;
let galley = ui.painter().layout_no_wrap(label.to_string(), if active { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) }, if active { t.text } else { t.text_dim });
let r = egui::Rect::from_min_size(egui::pos2(x, strip.top()), vec2(galley.size().x + 24.0, strip.height()));
let resp = ui.interact(r, ui.id().with(("docktab", label)), Sense::click());
if active {
ui.painter().rect_filled(r, 0.0, t.panel);
}
ui.painter().galley(egui::pos2(r.left() + 12.0, r.center().y - galley.size().y / 2.0), galley, t.text);
if resp.clicked() {
app.ui.dock_tab = tab;
}
x = r.right();
}
icons::paint(ui, "menu", egui::Rect::from_center_size(strip.right_center() - vec2(14.0, 0.0), vec2(14.0, 14.0)), t.text_dim);
egui::Frame::NONE.inner_margin(egui::Margin { left: 12, right: 10, top: 10, bottom: 8 }).show(ui, |ui| match app.ui.dock_tab {
DockTab::Properties => {
egui::ScrollArea::vertical().id_salt("props").auto_shrink([false, false]).show(ui, |ui| panels::properties::show(app, ui));
}
DockTab::Layers => panels::layers::show(app, ui),
DockTab::Libraries => panels::libraries(app, ui),
});
});
}
/// An icon panel popped out next to the icon column.
@ -65,14 +79,17 @@ pub fn floating_panel(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some((_, label, _)) = ICON_PANELS.iter().find(|p| p.0 == id) else { return };
let t = Tokens::get(ctx);
let screen = ctx.content_rect();
let x = screen.right() - ICON_COL - 286.0 - 262.0;
let x = screen.right() - ICON_COL - 300.0 - 262.0;
let mut open = true;
egui::Area::new(egui::Id::new("icon-panel")).order(egui::Order::Foreground).fixed_pos(egui::pos2(x, 110.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::ZERO).show(ui, |ui| {
ui.set_width(256.0);
let (strip, _) = ui.allocate_exact_size(vec2(256.0, 26.0), Sense::hover());
ui.painter().rect_filled(strip, CornerRadius { nw: 4, ne: 4, sw: 0, se: 0 }, t.panel_darker);
let tab = egui::Rect::from_min_size(strip.min, vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0));
let tab = egui::Rect::from_min_size(
strip.min,
vec2(ui.painter().layout_no_wrap(label.to_string(), theme::semibold(12.0), t.text).size().x + 24.0, 26.0),
);
ui.painter().rect_filled(tab, CornerRadius { nw: 4, ne: 0, sw: 0, se: 0 }, t.panel);
ui.painter().text(tab.left_center() + vec2(12.0, 0.0), egui::Align2::LEFT_CENTER, *label, theme::semibold(12.0), t.text);
let close = egui::Rect::from_center_size(strip.right_center() - vec2(13.0, 0.0), vec2(14.0, 14.0));

View File

@ -97,7 +97,13 @@ pub fn tool_icon(name: &str) -> &'static str {
"tool-rotate-view" => "dc-rotate-view",
"tool-print-tiling" => "printer",
"tool-zoom" => "zoom-in",
other => if exists(other) { table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed") } else { "square-dashed" },
other => {
if exists(other) {
table().get_key_value(other).map(|(k, _)| *k).unwrap_or("square-dashed")
} else {
"square-dashed"
}
}
}
}
@ -117,4 +123,3 @@ pub fn icon(ui: &mut Ui, name: &str, size: f32, tint: Color32) -> egui::Response
paint(ui, name, rect, tint);
resp
}

View File

@ -15,8 +15,8 @@ pub mod icons;
pub mod io;
pub mod menus;
pub mod palette;
pub mod render_worker;
pub mod panels;
pub mod render_worker;
pub mod shortcuts;
pub mod state;
pub mod theme;
@ -119,7 +119,14 @@ impl DrawcraftApp {
ui: UiState::default(),
views,
services,
canvas: CanvasCache { renderer: drawcraft_render::Renderer::new(), texture: None, key: None, last_ms: 0.0, worker: None, worker_started: false },
canvas: CanvasCache {
renderer: drawcraft_render::Renderer::new(),
texture: None,
key: None,
last_ms: 0.0,
worker: None,
worker_started: false,
},
perf: Perf::default(),
integrated_titlebar: false,
control_rx: None,
@ -198,6 +205,7 @@ impl DrawcraftApp {
{
*slot = id.to_string();
}
toolbar::remember(self, id);
self.ui.flyout = None;
}
@ -268,7 +276,8 @@ impl DrawcraftApp {
}
fn drain_inbox(&mut self) {
let arrived: Vec<(String, Vec<u8>)> = self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
let arrived: Vec<(String, Vec<u8>)> =
self.services.inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
for (name, bytes) in arrived {
if let Err(e) = io::open_bytes(self, &name, &bytes, None) {
self.status(format!("Couldn't open {name}: {e}"));
@ -337,7 +346,11 @@ impl DrawcraftApp {
/// Inject synthetic events (one press/release step per frame).
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
if !self.synthetic.is_empty() {
let n = self.synthetic.iter().position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1);
let n = self
.synthetic
.iter()
.position(|e| matches!(e, egui::Event::PointerButton { pressed: false, .. } | egui::Event::Key { pressed: false, .. }))
.map_or(self.synthetic.len(), |i| i + 1);
raw.events.extend(self.synthetic.drain(..n));
}
}
@ -379,4 +392,3 @@ impl DrawcraftApp {
let _ = json!(null);
}
}

View File

@ -79,6 +79,8 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.control", "Control", "", "{}"),
("window.toolbar", "Tools", "", "{}"),
("window.toolbarColumns", "Toolbar: Single/Double Column", "", "{}"),
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.brightness", "UI Brightness", "", "{brightness: dark|mediumDark|mediumLight|light}"),
@ -100,7 +102,10 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
};
let r = match id {
"file.newDialog" => {
app.ui.dialog = Some(crate::state::Dialog::new("newDocument", json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"})));
app.ui.dialog = Some(crate::state::Dialog::new(
"newDocument",
json!({"preset": "Letter", "width": "612 pt", "height": "792 pt", "units": "Points", "artboards": 1, "colorMode": "RGB", "name": "Untitled-1"}),
));
Ok(Value::Null)
}
"file.open" => match s("path") {
@ -135,14 +140,19 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
}
}
"file.export.svg" => io::export(app, "svg", s("path"), 1.0).map(|p| json!({"path": p})),
"file.export.png" | "file.exportForScreens" => io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p})),
"file.export.png" | "file.exportForScreens" => {
io::export(app, "png", s("path"), p.get("scale").and_then(Value::as_f64).unwrap_or(1.0)).map(|p| json!({"path": p}))
}
"file.documentSetup" => {
let units = app.session.active().map(|d| d.doc.units.label()).unwrap_or("Points");
app.ui.dialog = Some(crate::state::Dialog::new("documentSetup", json!({"units": units})));
Ok(Value::Null)
}
"edit.preferences" => {
app.ui.dialog = Some(crate::state::Dialog::new("preferences", json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes})));
app.ui.dialog = Some(crate::state::Dialog::new(
"preferences",
json!({"keyboardIncrement": app.session.prefs.keyboard_increment, "scaleStrokes": app.session.prefs.scale_strokes}),
));
Ok(Value::Null)
}
"view.outline" => flag(&mut app.ui.view.outline),
@ -169,7 +179,8 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
}
"view.setZoom" => {
let z = p.get("zoom").and_then(Value::as_f64).unwrap_or(100.0) / 100.0;
let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
let center =
p.get("center").and_then(Value::as_array).and_then(|a| Some(drawcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
match app.view_mut() {
Some(v) => {
v.zoom = z.clamp(0.0313, 640.0);
@ -206,6 +217,8 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
"window.control" => flag(&mut app.ui.control_bar),
"window.toolbar" => flag(&mut app.ui.toolbar),
"window.toolbarColumns" => flag(&mut app.ui.toolbar_double),
"window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
"window.taskBar" => flag(&mut app.ui.task_bar),
"window.dock" => {
let on = !(app.ui.dock && app.ui.toolbar);
app.ui.dock = on;
@ -294,6 +307,8 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option<bool> {
"view.rulers" => v.rulers,
"window.control" => app.ui.control_bar,
"window.toolbar" => app.ui.toolbar,
"window.toolbarAdvanced" => app.ui.toolbar_advanced,
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match panel {
@ -339,7 +354,19 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool {
return (c.enabled)(&app.session).is_ok();
}
match id {
"file.save" | "file.saveAs" | "file.saveCopy" | "file.place" | "file.export.svg" | "file.export.png" | "file.exportForScreens" | "file.documentSetup" | "view.zoomIn" | "view.zoomOut" | "view.fitArtboard" | "view.fitAll" | "view.actualSize" => app.session.active().is_some(),
"file.save"
| "file.saveAs"
| "file.saveCopy"
| "file.place"
| "file.export.svg"
| "file.export.png"
| "file.exportForScreens"
| "file.documentSetup"
| "view.zoomIn"
| "view.zoomOut"
| "view.fitArtboard"
| "view.fitAll"
| "view.actualSize" => app.session.active().is_some(),
"file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()),
_ => true,
}
@ -350,7 +377,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
vec![
(
"DrawCraft",
vec![c("About DrawCraft", "help.about"), Sep, c("Settings…", "edit.preferences"), Sep, sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()), Sep, c("Quit DrawCraft", "app.quit")],
vec![
c("About DrawCraft", "help.about"),
Sep,
c("Settings…", "edit.preferences"),
Sep,
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
Sep,
c("Quit DrawCraft", "app.quit"),
],
),
(
"File",
@ -370,7 +405,15 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
c("Place…", "file.place"),
Sep,
sub("Export", vec![c("Export for Screens…", "file.exportForScreens"), c("Export As SVG…", "file.export.svg"), c("Export As PNG…", "file.export.png"), todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S")]),
sub(
"Export",
vec![
c("Export for Screens…", "file.exportForScreens"),
c("Export As SVG…", "file.export.svg"),
c("Export As PNG…", "file.export.png"),
todos("Save for Web (Legacy)…", "Cmd+Alt+Shift+S"),
],
),
todo("Export Selection…"),
Sep,
todos("Package…", "Cmd+Alt+Shift+P"),
@ -517,8 +560,33 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
sub("Shape", vec![todo("Convert to Shape"), c("Expand Shape", "object.expandShape")]),
sub("Pattern", vec![todo("Make"), todo("Edit Pattern"), todo("Tile Edge Color…")]),
sub("Repeat", vec![todo("Radial"), todo("Grid"), todo("Mirror"), Sep, todo("Release"), todo("Options…")]),
sub("Blend", vec![todos("Make", "Cmd+Alt+B"), todos("Release", "Cmd+Alt+Shift+B"), Sep, todo("Blend Options…"), Sep, todo("Expand"), Sep, todo("Replace Spine"), todo("Reverse Spine"), todo("Reverse Front to Back")]),
sub("Envelope Distort", vec![todos("Make with Warp…", "Cmd+Alt+Shift+W"), todos("Make with Mesh…", "Cmd+Alt+M"), todos("Make with Top Object", "Cmd+Alt+C"), Sep, todo("Release"), todo("Envelope Options…"), todo("Expand")]),
sub(
"Blend",
vec![
todos("Make", "Cmd+Alt+B"),
todos("Release", "Cmd+Alt+Shift+B"),
Sep,
todo("Blend Options…"),
Sep,
todo("Expand"),
Sep,
todo("Replace Spine"),
todo("Reverse Spine"),
todo("Reverse Front to Back"),
],
),
sub(
"Envelope Distort",
vec![
todos("Make with Warp…", "Cmd+Alt+Shift+W"),
todos("Make with Mesh…", "Cmd+Alt+M"),
todos("Make with Top Object", "Cmd+Alt+C"),
Sep,
todo("Release"),
todo("Envelope Options…"),
todo("Expand"),
],
),
sub("Perspective", vec![todo("Attach to Active Plane"), todo("Release with Perspective")]),
sub("Live Paint", vec![todos("Make", "Cmd+Alt+X"), todo("Merge"), todo("Release"), Sep, todo("Gap Options…"), Sep, todo("Expand")]),
sub("Image Trace", vec![todo("Make"), todo("Make and Expand"), todo("Release"), todo("Expand")]),
@ -526,22 +594,51 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
Sep,
sub("Clipping Mask", vec![c("Make", "object.clippingMask.make"), c("Release", "object.clippingMask.release"), todo("Edit Contents")]),
sub("Compound Path", vec![c("Make", "object.compoundPath.make"), c("Release", "object.compoundPath.release")]),
sub("Artboards", vec![todo("Convert to Artboards"), todo("Rearrange All Artboards…"), Sep, c("Fit to Artwork Bounds", "artboard.fitToArt"), c("Fit to Selected Art", "artboard.fitToSelection")]),
sub(
"Artboards",
vec![
todo("Convert to Artboards"),
todo("Rearrange All Artboards…"),
Sep,
c("Fit to Artwork Bounds", "artboard.fitToArt"),
c("Fit to Selected Art", "artboard.fitToSelection"),
],
),
sub("Graph", vec![todo("Type…"), todo("Data…"), todo("Design…"), todo("Column…"), todo("Marker…")]),
],
),
(
"Type",
vec![
sub("Font", drawcraft_text::FontDb::global().families().into_iter().take(0).map(|_| todo("")).chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")]).collect()),
sub(
"Font",
drawcraft_text::FontDb::global()
.families()
.into_iter()
.take(0)
.map(|_| todo(""))
.chain([todo("Source Sans 3"), todo("Source Serif 4"), todo("Inter"), todo("JetBrains Mono")])
.collect(),
),
todo("Recent Fonts"),
sub("Size", [6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72].iter().map(|_| todo("pt")).take(0).chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")]).collect()),
sub(
"Size",
[6, 8, 9, 10, 11, 12, 14, 18, 24, 30, 36, 48, 60, 72]
.iter()
.map(|_| todo("pt"))
.take(0)
.chain([todo("6 pt"), todo("8 pt"), todo("12 pt"), todo("24 pt"), todo("36 pt"), todo("72 pt")])
.collect(),
),
Sep,
todo("Glyphs"),
Sep,
todo("Convert To Area Type"),
todo("Area Type Options…"),
sub("Type on a Path", vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")]),
sub(
"Type on a Path",
vec![todo("Rainbow"), todo("Skew"), todo("3D Ribbon"), todo("Stair Step"), todo("Gravity"), Sep, todo("Type on a Path Options…")],
),
sub("Threaded Text", vec![todo("Create"), todo("Release Selection"), todo("Remove Threading")]),
todo("Fit Headline"),
Sep,
@ -623,11 +720,40 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
sub("3D and Materials", vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]),
sub("Convert to Shape", vec![todo("Rectangle…"), todo("Rounded Rectangle…"), todo("Ellipse…")]),
todo("Crop Marks"),
sub("Distort & Transform", vec![todo("Free Distort…"), todo("Pucker & Bloat…"), todo("Roughen…"), todo("Transform…"), todo("Tweak…"), todo("Twist…"), todo("Zig Zag…")]),
sub(
"Distort & Transform",
vec![
todo("Free Distort…"),
todo("Pucker & Bloat…"),
todo("Roughen…"),
todo("Transform…"),
todo("Tweak…"),
todo("Twist…"),
todo("Zig Zag…"),
],
),
sub("Path", vec![todo("Offset Path…"), todo("Outline Object"), todo("Outline Stroke")]),
sub("Pathfinder", vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract"), todo("Minus Back"), Sep, todo("Divide"), todo("Trim"), todo("Merge"), todo("Crop"), todo("Outline")]),
sub(
"Pathfinder",
vec![
todo("Add"),
todo("Intersect"),
todo("Exclude"),
todo("Subtract"),
todo("Minus Back"),
Sep,
todo("Divide"),
todo("Trim"),
todo("Merge"),
todo("Crop"),
todo("Outline"),
],
),
todo("Rasterize…"),
sub("Stylize", vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")]),
sub(
"Stylize",
vec![todo("Drop Shadow…"), todo("Feather…"), todo("Inner Glow…"), todo("Outer Glow…"), todo("Round Corners…"), todo("Scribble…")],
),
sub("SVG Filters", vec![todo("Apply SVG Filter…"), todo("Import SVG Filter…")]),
sub(
"Warp",
@ -665,9 +791,24 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
c("Pixel Preview", "view.pixelPreview"),
todo("Trim View"),
c("Presentation Mode", "view.presentation"),
sub("Screen Mode", vec![cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})), cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})), cp("Full Screen Mode", "view.screenMode", json!({"mode": 2}))]),
sub(
"Screen Mode",
vec![
cp("Normal Screen Mode", "view.screenMode", json!({"mode": 0})),
cp("Full Screen Mode with Menu Bar", "view.screenMode", json!({"mode": 1})),
cp("Full Screen Mode", "view.screenMode", json!({"mode": 2})),
],
),
Sep,
sub("Proof Setup", vec![todo("Working CMYK"), todo("Internet Standard RGB (sRGB)"), todo("Color blindness – Protanopia-type"), todo("Color blindness – Deuteranopia-type")]),
sub(
"Proof Setup",
vec![
todo("Working CMYK"),
todo("Internet Standard RGB (sRGB)"),
todo("Color blindness – Protanopia-type"),
todo("Color blindness – Deuteranopia-type"),
],
),
todo("Proof Colors"),
Sep,
c("Zoom In", "view.zoomIn"),
@ -689,7 +830,16 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
todos("Hide Gradient Annotator", "Cmd+Alt+G"),
todo("Hide Corner Widget"),
Sep,
sub("Guides", vec![c("Hide Guides", "view.guides"), todos("Lock Guides", "Cmd+Alt+;"), todos("Make Guides", "Cmd+5"), todos("Release Guides", "Cmd+Alt+5"), todo("Clear Guides")]),
sub(
"Guides",
vec![
c("Hide Guides", "view.guides"),
todos("Lock Guides", "Cmd+Alt+;"),
todos("Make Guides", "Cmd+5"),
todos("Release Guides", "Cmd+Alt+5"),
todo("Clear Guides"),
],
),
c("Smart Guides", "view.smartGuides"),
sub("Perspective Grid", vec![todos("Show Grid", "Cmd+Shift+I"), todo("Define Grid…")]),
c("Show Grid", "view.grid"),
@ -707,11 +857,22 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
vec![
c("New Window", "window.newWindow"),
sub("Arrange", vec![todo("Cascade"), todo("Tile"), todo("Float in Window"), todo("Consolidate All Windows")]),
sub("Workspace", vec![todo("Essentials"), todo("Essentials Classic"), todo("Painting"), todo("Typography"), Sep, c("Reset Essentials", "window.workspace.reset")]),
sub(
"Workspace",
vec![
todo("Essentials"),
todo("Essentials Classic"),
todo("Painting"),
todo("Typography"),
Sep,
c("Reset Essentials", "window.workspace.reset"),
],
),
Sep,
c("Control", "window.control"),
c("Contextual Task Bar", "window.taskBar"),
c("Tools", "window.toolbar"),
sub("Toolbars", vec![c("Single / Double Column", "window.toolbarColumns")]),
sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Single / Double Column", "window.toolbarColumns")]),
Sep,
todo("Actions"),
panel("Align", "align"),
@ -743,7 +904,18 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
panel("Symbols", "symbols"),
panel("Transform", "transform"),
panel("Transparency", "transparency"),
sub("Type", vec![panel("Character", "character"), todo("Character Styles"), todo("Glyphs"), todo("OpenType"), panel("Paragraph", "paragraph"), todo("Paragraph Styles"), todo("Tabs")]),
sub(
"Type",
vec![
panel("Character", "character"),
todo("Character Styles"),
todo("Glyphs"),
todo("OpenType"),
panel("Paragraph", "paragraph"),
todo("Paragraph Styles"),
todo("Tabs"),
],
),
todo("Variables"),
Sep,
sub("Brush Libraries", vec![todo("Arrows"), todo("Artistic"), todo("Borders"), todo("Decorative")]),
@ -763,7 +935,11 @@ pub fn menu_bar(app: &mut DrawcraftApp, ui: &mut egui::Ui) {
let tree = menu_tree();
egui::MenuBar::new().ui(ui, |ui| {
for (i, (title, items)) in tree.iter().enumerate() {
let text = if i == 0 { egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text) } else { egui::RichText::new(*title).size(13.0).color(t.text) };
let text = if i == 0 {
egui::RichText::new(*title).font(theme::semibold(13.0)).color(t.text)
} else {
egui::RichText::new(*title).size(13.0).color(t.text)
};
ui.menu_button(text, |ui| {
ui.set_min_width(230.0);
render_items(app, ui, items, &mut clicked);
@ -884,8 +1060,22 @@ pub fn menu_entries(app: &DrawcraftApp) -> Vec<MenuEntry> {
fn walk(app: &DrawcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
for it in items {
match it {
Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), command: Some(id.to_string()), params: p.clone(), enabled: enabled(app, id), shortcut: shortcut_of(id).unwrap_or("").to_string() }),
Item::Todo(l, sc) => out.push(MenuEntry { path: path.clone(), label: l.to_string(), command: None, params: Value::Null, enabled: false, shortcut: sc.to_string() }),
Item::Cmd(l, id, p) => out.push(MenuEntry {
path: path.clone(),
label: dynamic_label(app, id, l),
command: Some(id.to_string()),
params: p.clone(),
enabled: enabled(app, id),
shortcut: shortcut_of(id).unwrap_or("").to_string(),
}),
Item::Todo(l, sc) => out.push(MenuEntry {
path: path.clone(),
label: l.to_string(),
command: None,
params: Value::Null,
enabled: false,
shortcut: sc.to_string(),
}),
Item::Sub(l, ch) => {
let mut p = path.clone();
p.push(l.to_string());
@ -912,7 +1102,11 @@ mod tests {
for it in items {
match it {
Item::Cmd(_, id, _) => {
if drawcraft_engine::find_command(id).is_none() && !UI_COMMANDS.iter().any(|c| c.0 == *id) && !id.starts_with("object.path.") && *id != "type.createOutlines" {
if drawcraft_engine::find_command(id).is_none()
&& !UI_COMMANDS.iter().any(|c| c.0 == *id)
&& !id.starts_with("object.path.")
&& *id != "type.createOutlines"
{
bad.push(id.to_string());
}
}

View File

@ -23,7 +23,11 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
for tool in drawcraft_tools::catalog::all_tools() {
items.push((tool.label.to_string(), format!("tool:{}", tool.id), tool.shortcut.unwrap_or("").to_string()));
}
let matches: Vec<&(String, String, String)> = items.iter().filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w))).take(14).collect();
let matches: Vec<&(String, String, String)> = items
.iter()
.filter(|(l, id, _)| q.is_empty() || q.split_whitespace().all(|w| l.to_lowercase().contains(w) || id.to_lowercase().contains(w)))
.take(14)
.collect();
let mut run: Option<String> = None;
egui::Area::new(egui::Id::new("palette")).order(egui::Order::Foreground).anchor(egui::Align2::CENTER_TOP, [0.0, 90.0]).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(10)).show(ui, |ui| {
@ -32,7 +36,9 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
r.request_focus();
ui.add_space(6.0);
for (i, (label, id, sc)) in matches.iter().enumerate() {
let resp = ui.add(egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0));
let resp = ui.add(
egui::Button::new(label.as_str()).shortcut_text(menus::pretty_shortcut(sc)).min_size(egui::vec2(500.0, 24.0)).selected(i == 0),
);
if resp.clicked() {
run = Some(id.clone());
}

View File

@ -24,6 +24,24 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let current = st.active_layer;
let mut expanded: HashSet<u64> = ui.data(|d| d.get_temp(expanded_id())).unwrap_or_else(|| doc.layers.iter().map(|l| l.id.0).collect());
let mut actions: Vec<(String, serde_json::Value)> = vec![];
// Search field ("Search All").
let search_id = egui::Id::new("layers-search");
let mut query: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(egui::CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(8, 5))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut query)
.frame(egui::Frame::NONE)
.hint_text(egui::RichText::new("Search All").italics())
.desired_width(ui.available_width()),
);
});
ui.data_mut(|d| d.insert_temp(search_id, query.clone()));
ui.add_space(6.0);
let h = ui.available_height() - 34.0;
egui::ScrollArea::vertical().max_height(h).auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 0.0;
@ -39,7 +57,13 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover());
ui.painter().line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.divider));
let n = doc.layers.len();
ui.painter().text(bar.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, format!("{n} Layer{}", if n == 1 { "" } else { "s" }), egui::FontId::proportional(11.5), t.text_dim);
ui.painter().text(
bar.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{n} Layer{}", if n == 1 { "" } else { "s" }),
egui::FontId::proportional(11.5),
t.text_dim,
);
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
if widgets::icon_button(&mut child, "trash-2", "Delete Selection", false, 24.0).clicked() {
if app.session.active().is_some_and(|d| !d.selection.is_empty()) {
@ -75,14 +99,25 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
}
#[allow(clippy::too_many_arguments)]
fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel: &HashSet<NodeId>, current: Option<NodeId>, expanded: &mut HashSet<u64>, actions: &mut Vec<(String, serde_json::Value)>, t: &Tokens) {
fn row(
ui: &mut Ui,
doc: &drawcraft_doc::Document,
n: &Node,
depth: usize,
sel: &HashSet<NodeId>,
current: Option<NodeId>,
expanded: &mut HashSet<u64>,
actions: &mut Vec<(String, serde_json::Value)>,
t: &Tokens,
) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click_and_drag());
let is_sel = sel.contains(&n.id);
let child_sel = !is_sel && n.children().is_some_and(|_| {
let mut any = false;
n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id));
any
});
let child_sel = !is_sel
&& n.children().is_some_and(|_| {
let mut any = false;
n.walk(&mut |c| any |= c.id != n.id && sel.contains(&c.id));
any
});
let color = {
let c = doc.layer_color(n.id);
Color32::from_rgb(c[0], c[1], c[2])
@ -92,12 +127,17 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
} else if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover.gamma_multiply(0.6));
}
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border));
ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.input_border));
if n.is_layer() && Some(n.id) == current {
// Current-layer marker: small triangle in the top-right corner.
let c = r.right_top();
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-6.0, 0.0), c + vec2(0.0, 6.0)], Color32::from_gray(0xcc), Stroke::NONE));
}
// Eye and lock columns.
let eye = egui::Rect::from_min_size(r.min, vec2(24.0, ROW));
let lock = egui::Rect::from_min_size(r.min + vec2(24.0, 0.0), vec2(22.0, ROW));
ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.border));
ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.border));
let eye = egui::Rect::from_min_size(r.min, vec2(25.0, ROW));
let lock = egui::Rect::from_min_size(r.min + vec2(25.0, 0.0), vec2(25.0, ROW));
ui.painter().line_segment([eye.right_top(), eye.right_bottom()], Stroke::new(1.0, t.input_border));
ui.painter().line_segment([lock.right_top(), lock.right_bottom()], Stroke::new(1.0, t.input_border));
let er = ui.interact(eye, ui.id().with(("eye", n.id.0)), Sense::click());
if n.visible {
icons::paint(ui, "eye", egui::Rect::from_center_size(eye.center(), vec2(14.0, 14.0)), t.icon);
@ -123,7 +163,7 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
// Layer colour bar.
let mut x = lock.right() + 2.0;
if n.is_layer() {
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(3.0, ROW - 4.0)), 0.0, color);
ui.painter().rect_filled(egui::Rect::from_min_size(egui::pos2(x - 1.0, r.top()), vec2(4.0, ROW)), 0.0, color);
}
x += 6.0 + depth as f32 * 14.0;
// Disclosure.
@ -143,14 +183,14 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
}
x += 16.0;
// Thumbnail.
let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 3.0), vec2(20.0, 20.0));
let th = egui::Rect::from_min_size(egui::pos2(x, r.top() + 2.0), vec2(22.0, 22.0));
ui.painter().rect_filled(th, 0.0, Color32::WHITE);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
ui.painter().rect_stroke(th, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Outside);
thumb(ui, n, th);
x += 26.0;
// Name.
let name = n.display_name();
let font = if n.is_layer() { egui::FontId::proportional(12.5) } else { egui::FontId::proportional(12.0) };
let font = egui::FontId::proportional(13.0);
let rename_id = egui::Id::new("layers-rename");
let renaming: Option<(u64, String)> = ui.data(|d| d.get_temp(rename_id));
let name_rect = egui::Rect::from_min_max(egui::pos2(x - 2.0, r.top() + 3.0), egui::pos2(r.right() - 44.0, r.bottom() - 3.0));
@ -176,18 +216,22 @@ fn row(ui: &mut Ui, doc: &drawcraft_doc::Document, n: &Node, depth: usize, sel:
}
}
// Target circle and selection square.
let tc = egui::pos2(r.right() - 30.0, r.center().y);
let col_x = r.right() - 43.5;
ui.painter().line_segment([egui::pos2(col_x, r.top()), egui::pos2(col_x, r.bottom())], Stroke::new(1.0, t.input_border));
let tc = egui::pos2(r.right() - 28.0, r.center().y);
let styled = !n.appearance.is_basic() || n.opacity < 1.0;
ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.text_dim));
ui.painter().circle_stroke(tc, 5.0, Stroke::new(1.0, t.icon));
if is_sel {
ui.painter().circle_stroke(tc, 3.0, Stroke::new(1.0, t.text_dim));
ui.painter().circle_stroke(tc, 2.8, Stroke::new(1.0, t.icon));
}
if styled {
ui.painter().circle_filled(tc, 3.5, t.text_dim);
ui.painter().circle_filled(tc, 3.2, t.icon);
}
let sq = egui::pos2(r.right() - 12.0, r.center().y);
let sq = egui::pos2(r.right() - 11.0, r.center().y);
if is_sel {
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(8.0, 8.0)), 0.0, color);
let q = egui::Rect::from_center_size(sq, vec2(7.0, 7.0));
ui.painter().rect_filled(q, 0.0, color);
ui.painter().rect_stroke(q, 0.0, Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside);
} else if child_sel {
ui.painter().rect_filled(egui::Rect::from_center_size(sq, vec2(4.0, 4.0)), 0.0, color);
}

View File

@ -49,7 +49,10 @@ pub fn libraries(_app: &mut DrawcraftApp, ui: &mut Ui) {
icons::icon(ui, "library", 40.0, t.text_dim);
ui.add_space(8.0);
ui.label(egui::RichText::new("Local Libraries").size(14.0).color(t.text));
dim_label(ui, "Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.");
dim_label(
ui,
"Drag art, colors and text styles here to reuse them across documents. Libraries are stored on this machine — no account required.",
);
});
}
@ -106,8 +109,20 @@ pub fn swatches(app: &mut DrawcraftApp, ui: &mut Ui) {
pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) {
let current = match first_selected(app) {
Some(n) => if app.session.fill_active { n.appearance.fill_paint() } else { n.appearance.stroke_paint() },
None => if app.session.fill_active { app.session.paint.fill.clone() } else { app.session.paint.stroke.clone() },
Some(n) => {
if app.session.fill_active {
n.appearance.fill_paint()
} else {
n.appearance.stroke_paint()
}
}
None => {
if app.session.fill_active {
app.session.paint.fill.clone()
} else {
app.session.paint.stroke.clone()
}
}
};
let c = current.color().unwrap_or(Color::BLACK);
let mut rgb = c.to_rgba8(1.0);
@ -140,7 +155,8 @@ pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) {
let h = i as f32 / n as f32 * 360.0;
let v = 1.0 - j as f32 / 4.0 * 0.8;
let [cr, cg, cb, _] = Color::from_hsb(h, 1.0, v).to_rgba8(1.0);
let cell = egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0));
let cell =
egui::Rect::from_min_size(r.min + vec2(i as f32 * r.width() / n as f32, j as f32 * 10.0), vec2(r.width() / n as f32 + 0.5, 10.0));
ui.painter().rect_filled(cell, 0.0, egui::Color32::from_rgb(cr, cg, cb));
}
}
@ -159,7 +175,8 @@ pub fn color(app: &mut DrawcraftApp, ui: &mut Ui) {
}
fn color_guide(app: &mut DrawcraftApp, ui: &mut Ui) {
let base = first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1));
let base =
first_selected(app).and_then(|n| n.appearance.fill_paint().color()).or(app.session.paint.fill.color()).unwrap_or(Color::rgb(0.9, 0.3, 0.1));
for h in drawcraft_color::harmony::Harmony::ALL {
ui.horizontal(|ui| {
ui.add_sized(vec2(110.0, 18.0), egui::Label::new(egui::RichText::new(h.label()).size(11.0)));
@ -189,7 +206,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) {
let align = st.as_ref().map(|s| s.align).unwrap_or_default();
ui.horizontal(|ui| {
dim_label(ui, "Cap:");
for (v, lbl, name) in [(drawcraft_doc::LineCap::Butt, "Butt", "butt"), (drawcraft_doc::LineCap::Round, "Round", "round"), (drawcraft_doc::LineCap::Square, "Projecting", "square")] {
for (v, lbl, name) in [
(drawcraft_doc::LineCap::Butt, "Butt", "butt"),
(drawcraft_doc::LineCap::Round, "Round", "round"),
(drawcraft_doc::LineCap::Square, "Projecting", "square"),
] {
if ui.selectable_label(cap == v, lbl).clicked() {
app.run("stroke.set", json!({"cap": name})).ok();
}
@ -197,7 +218,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) {
});
ui.horizontal(|ui| {
dim_label(ui, "Corner:");
for (v, lbl, name) in [(drawcraft_doc::LineJoin::Miter, "Miter", "miter"), (drawcraft_doc::LineJoin::Round, "Round", "round"), (drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel")] {
for (v, lbl, name) in [
(drawcraft_doc::LineJoin::Miter, "Miter", "miter"),
(drawcraft_doc::LineJoin::Round, "Round", "round"),
(drawcraft_doc::LineJoin::Bevel, "Bevel", "bevel"),
] {
if ui.selectable_label(join == v, lbl).clicked() {
app.run("stroke.set", json!({"join": name})).ok();
}
@ -205,7 +230,11 @@ pub fn stroke(app: &mut DrawcraftApp, ui: &mut Ui) {
});
ui.horizontal(|ui| {
dim_label(ui, "Align:");
for (v, lbl, name) in [(drawcraft_doc::StrokeAlign::Center, "Center", "center"), (drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"), (drawcraft_doc::StrokeAlign::Outside, "Outside", "outside")] {
for (v, lbl, name) in [
(drawcraft_doc::StrokeAlign::Center, "Center", "center"),
(drawcraft_doc::StrokeAlign::Inside, "Inside", "inside"),
(drawcraft_doc::StrokeAlign::Outside, "Outside", "outside"),
] {
if ui.selectable_label(align == v, lbl).clicked() {
app.run("stroke.set", json!({"align": name})).ok();
}
@ -304,7 +333,8 @@ pub fn appearance(app: &mut DrawcraftApp, ui: &mut Ui) {
fn graphic_styles(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let styles: Vec<(String, drawcraft_doc::Appearance)> = app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default();
let styles: Vec<(String, drawcraft_doc::Appearance)> =
app.session.active().map(|d| d.doc.graphic_styles.iter().map(|g| (g.name.clone(), g.appearance.clone())).collect()).unwrap_or_default();
ui.horizontal_wrapped(|ui| {
for (name, ap) in styles {
let (r, resp) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::click());
@ -373,7 +403,12 @@ pub fn align(app: &mut DrawcraftApp, ui: &mut Ui) {
pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) {
dim_label(ui, "Shape Modes:");
ui.horizontal(|ui| {
for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] {
for (icon, tip, op) in [
("squares-unite", "Unite", "unite"),
("squares-subtract", "Minus Front", "minusFront"),
("squares-intersect", "Intersect", "intersect"),
("squares-exclude", "Exclude", "exclude"),
] {
if widgets::icon_button(ui, icon, tip, false, 30.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
@ -381,7 +416,9 @@ pub fn pathfinder(app: &mut DrawcraftApp, ui: &mut Ui) {
});
dim_label(ui, "Pathfinders:");
ui.horizontal_wrapped(|ui| {
for (tip, op) in [("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")] {
for (tip, op) in
[("Divide", "divide"), ("Trim", "trim"), ("Merge", "merge"), ("Crop", "crop"), ("Outline", "outline"), ("Minus Back", "minusBack")]
{
if widgets::flat_button(ui, tip, 72.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}
@ -444,13 +481,20 @@ fn info(app: &mut DrawcraftApp, ui: &mut Ui) {
fn artboards(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let abs: Vec<(usize, String)> = app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default();
let abs: Vec<(usize, String)> =
app.session.active().map(|d| d.doc.artboards.iter().enumerate().map(|(i, a)| (i, a.name.clone())).collect()).unwrap_or_default();
for (i, name) in abs {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
ui.painter().text(r.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, format!("{}", i + 1), egui::FontId::proportional(12.0), t.text_dim);
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{}", i + 1),
egui::FontId::proportional(12.0),
t.text_dim,
);
ui.painter().text(r.left_center() + vec2(28.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text);
}
ui.horizontal(|ui| {
@ -464,7 +508,11 @@ fn gradient(app: &mut DrawcraftApp, ui: &mut Ui) {
let n = first_selected(app);
let paint = n.as_ref().map(|n| n.appearance.fill_paint()).unwrap_or(Paint::None);
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 20.0), Sense::hover());
let preview = if matches!(paint, Paint::Gradient(_)) { paint.clone() } else { Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))) };
let preview = if matches!(paint, Paint::Gradient(_)) {
paint.clone()
} else {
Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default())))
};
paint_chip(ui, r, &preview);
ui.horizontal(|ui| {
dim_label(ui, "Type:");

View File

@ -59,7 +59,12 @@ pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
divider(ui);
section_header(ui, "Pathfinder");
ui.horizontal(|ui| {
for (icon, tip, op) in [("squares-unite", "Unite", "unite"), ("squares-subtract", "Minus Front", "minusFront"), ("squares-intersect", "Intersect", "intersect"), ("squares-exclude", "Exclude", "exclude")] {
for (icon, tip, op) in [
("squares-unite", "Unite", "unite"),
("squares-subtract", "Minus Front", "minusFront"),
("squares-intersect", "Intersect", "intersect"),
("squares-exclude", "Exclude", "exclude"),
] {
if widgets::icon_button(ui, icon, tip, false, 28.0).clicked() {
app.run(&format!("object.pathfinder.{op}"), json!({})).ok();
}

View File

@ -12,7 +12,8 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
if s.is_empty() {
return None;
}
let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').unwrap_or(("", s)) };
let (mods_part, key_part) =
if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').unwrap_or(("", s)) };
let mut m = Modifiers::NONE;
for part in mods_part.split('+').filter(|p| !p.is_empty()) {
match part {
@ -55,7 +56,9 @@ fn all_shortcuts() -> Vec<(KeyboardShortcut, &'static str)> {
}
}
// Most specific (most modifiers) first so Cmd+Shift+Z isn't eaten by Cmd+Z.
v.sort_by_key(|(sc, _)| std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8));
v.sort_by_key(|(sc, _)| {
std::cmp::Reverse(sc.modifiers.shift as u8 + sc.modifiers.alt as u8 + sc.modifiers.command as u8 + sc.modifiers.ctrl as u8)
});
v
}
@ -88,17 +91,24 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
}
// Type tool editing: text and editing keys go to the tool.
if app.session.tool_wants_text() {
let texts: Vec<String> = ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect());
let texts: Vec<String> =
ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect());
for t in texts {
let _ = app.session.tool_text(&t, view);
}
for (k, tk) in [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] {
for (k, tk) in
[(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]
{
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
}
}
// Enter was already delivered above as ToolKey::Enter (newline).
let fire = all_shortcuts().into_iter().filter(|(sc, _)| sc.modifiers.command).find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc))).map(|(_, id)| id);
let fire = all_shortcuts()
.into_iter()
.filter(|(sc, _)| sc.modifiers.command)
.find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc)))
.map(|(_, id)| id);
if let Some(id) = fire {
crate::menus::invoke(app, id, json!({}));
}

View File

@ -27,12 +27,17 @@ impl Default for View {
/// Illustrator's preset zoom stops, in percent.
pub const ZOOM_STOPS: [f64; 26] = [
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0, 4800.0, 6400.0, 12800.0, 25600.0, 64000.0,
3.13, 4.17, 6.25, 8.33, 12.5, 16.67, 25.0, 33.33, 50.0, 66.67, 100.0, 150.0, 200.0, 300.0, 400.0, 600.0, 800.0, 1200.0, 1600.0, 2400.0, 3200.0,
4800.0, 6400.0, 12800.0, 25600.0, 64000.0,
];
pub fn next_zoom(z: f64, up: bool) -> f64 {
let pct = z * 100.0;
let stop = if up { ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0) } else { ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13) };
let stop = if up {
ZOOM_STOPS.iter().find(|s| **s > pct + 0.01).copied().unwrap_or(64000.0)
} else {
ZOOM_STOPS.iter().rev().find(|s| **s < pct - 0.01).copied().unwrap_or(3.13)
};
stop / 100.0
}
@ -104,7 +109,20 @@ pub struct ViewFlags {
impl Default for ViewFlags {
fn default() -> Self {
Self { rulers: true, grid: false, guides: true, smart_guides: true, bounding_box: true, outline: false, pixel_preview: false, snap_to_grid: false, snap_to_point: true, artboards: true, transparency_grid: false, edges: true }
Self {
rulers: false,
grid: false,
guides: true,
smart_guides: true,
bounding_box: true,
outline: false,
pixel_preview: false,
snap_to_grid: false,
snap_to_point: true,
artboards: true,
transparency_grid: false,
edges: true,
}
}
}
@ -147,6 +165,14 @@ pub struct UiState {
pub control_bar: bool,
pub toolbar: bool,
pub toolbar_double: bool,
/// Advanced toolbar (every tool group) instead of the categorized Basic toolbar.
#[serde(default)]
pub toolbar_advanced: bool,
#[serde(default = "yes")]
pub task_bar: bool,
/// Last tool shown in each toolbar slot (keyed by the slot's first tool id).
#[serde(default)]
pub slot_tool: std::collections::BTreeMap<String, String>,
pub status_bar: bool,
pub dock: bool,
pub view: ViewFlags,
@ -168,12 +194,15 @@ pub struct UiState {
impl Default for UiState {
fn default() -> Self {
Self {
brightness: Brightness::Dark,
brightness: Brightness::MediumDark,
dock_tab: DockTab::Properties,
open_panel: None,
control_bar: true,
control_bar: false,
toolbar: true,
toolbar_double: false,
toolbar_advanced: false,
task_bar: true,
slot_tool: Default::default(),
status_bar: true,
dock: true,
view: ViewFlags::default(),
@ -190,6 +219,10 @@ impl Default for UiState {
}
}
fn yes() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -7,8 +7,8 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Brightness {
#[default]
Dark,
#[default]
MediumDark,
MediumLight,
Light,
@ -65,6 +65,19 @@ pub struct Tokens {
pub measure_bg: Color32,
pub button: Color32,
pub radius: u8,
/// Field values, button labels, link labels.
pub text_strong: Color32,
/// Panel tab strips, doc-tab bar, dock header.
pub tab_strip: Color32,
/// Outline of secondary (outlined) buttons.
pub button_border: Color32,
/// Default Layer 1 selection colour and selected-anchor fill.
pub selection: Color32,
pub anchor_selected: Color32,
/// Active item text in tool flyouts.
pub flyout_active: Color32,
/// Hint-bar / tab-bar heights etc. scale with this base font size.
pub font_size: f32,
}
impl Tokens {
@ -95,31 +108,46 @@ impl Tokens {
guide: hex(0x4affff),
measure_bg: Color32::from_rgba_unmultiplied(92, 92, 92, 235),
button: hex(0x444444),
radius: 3,
radius: 2,
text_strong: hex(0xf5f5f5),
tab_strip: hex(0x282828),
button_border: hex(0x6a6a6a),
selection: hex(0x4f80ff),
anchor_selected: hex(0x3d82ff),
flyout_active: hex(0x54b2f7),
font_size: 13.0,
};
match b {
Brightness::Dark => base,
// Measured from Illustrator 2026 (plan/illustrator/10-observed-ui.md §1).
Brightness::MediumDark => Tokens {
app_bar: hex(0x444444),
app_bar: hex(0x535353),
panel: hex(0x535353),
panel_darker: hex(0x474747),
input: hex(0x3a3a3a),
input_border: hex(0x6a6a6a),
panel_darker: hex(0x424242),
tab_strip: hex(0x424242),
input: hex(0x454545),
input_border: hex(0x5f5f5f),
border: hex(0x383838),
divider: hex(0x656565),
text: hex(0xeeeeee),
text_dim: hex(0xc4c4c4),
icon: hex(0xe6e6e6),
hover: hex(0x646464),
tool_active: hex(0x383838),
row_selected: hex(0x6a6d72),
pasteboard: hex(0x494949),
ruler: hex(0x4c4c4c),
ruler_tick: hex(0xb0b0b0),
button: hex(0x656565),
divider: hex(0x4b4b4b),
text: hex(0xd1d1d1),
text_strong: hex(0xffffff),
text_dim: hex(0xb0b0b0),
text_disabled: hex(0x7a7a7a),
icon: hex(0xc2c2c2),
hover: hex(0x606060),
tool_active: hex(0x303030),
row_selected: hex(0x52677c),
pasteboard: hex(0x606060),
ruler: hex(0x333333),
ruler_tick: hex(0x8a8a8a),
button: hex(0x535353),
button_border: hex(0x747474),
..base
},
Brightness::MediumLight => Tokens {
tab_strip: hex(0xa6a6a6),
text_strong: hex(0x000000),
button_border: hex(0x7a7a7a),
dark: false,
app_bar: hex(0xa9a9a9),
panel: hex(0xb8b8b8),
@ -142,6 +170,9 @@ impl Tokens {
..base
},
Brightness::Light => Tokens {
tab_strip: hex(0xdddddd),
text_strong: hex(0x000000),
button_border: hex(0x9a9a9a),
dark: false,
app_bar: hex(0xe4e4e4),
panel: hex(0xf0f0f0),
@ -167,7 +198,7 @@ impl Tokens {
}
pub fn get(ctx: &egui::Context) -> Tokens {
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::Dark))
ctx.data(|d| d.get_temp::<Tokens>(egui::Id::NULL)).unwrap_or_else(|| Tokens::for_brightness(Brightness::MediumDark))
}
pub fn cr(&self) -> CornerRadius {
CornerRadius::same(self.radius)
@ -228,7 +259,13 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
v.popup_shadow = egui::epaint::Shadow { offset: [0, 4], blur: 14, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 60 }) };
v.window_shadow = egui::epaint::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 70 }) };
let w = &mut v.widgets;
for (wv, fill) in [(&mut w.noninteractive, t.panel), (&mut w.inactive, t.button), (&mut w.hovered, t.hover), (&mut w.active, t.tool_active), (&mut w.open, t.hover)] {
for (wv, fill) in [
(&mut w.noninteractive, t.panel),
(&mut w.inactive, t.button),
(&mut w.hovered, t.hover),
(&mut w.active, t.tool_active),
(&mut w.open, t.hover),
] {
wv.bg_fill = fill;
wv.weak_bg_fill = fill;
wv.corner_radius = t.cr();
@ -242,13 +279,13 @@ pub fn apply(ctx: &egui::Context, b: Brightness) {
ctx.global_style_mut(|s| {
s.spacing.item_spacing = egui::vec2(6.0, 5.0);
s.spacing.button_padding = egui::vec2(6.0, 2.0);
s.spacing.interact_size = egui::vec2(24.0, 22.0);
s.spacing.interact_size = egui::vec2(24.0, 24.0);
s.spacing.menu_margin = egui::Margin::same(6);
s.spacing.slider_width = 120.0;
s.text_styles = [
(TextStyle::Small, FontId::proportional(10.5)),
(TextStyle::Body, FontId::proportional(12.5)),
(TextStyle::Button, FontId::proportional(12.5)),
(TextStyle::Small, FontId::proportional(11.0)),
(TextStyle::Body, FontId::proportional(13.0)),
(TextStyle::Button, FontId::proportional(13.0)),
(TextStyle::Heading, FontId::new(15.0, FontFamily::Name(FONT_UI_SEMIBOLD.into()))),
(TextStyle::Monospace, FontId::new(11.5, FontFamily::Name(FONT_MONO.into()))),
]

View File

@ -1,182 +1,306 @@
//! The Tools panel: tool groups with flyouts, fill/stroke proxy, colour/gradient/none, drawing
//! modes, screen mode and the Edit Toolbar drawer.
//! The Tools panel. Default: Illustrator 2026's categorized single-column toolbar (Select, Shapes,
//! Draw, Modify, Type, Navigate, Color). Window → Toolbars → Advanced shows every tool group.
//! Bottom: fill/stroke proxy, colour/gradient/none, drawing modes, screen mode, Edit Toolbar.
use drawcraft_tools::{TOOL_GROUPS, ToolInfo};
use egui::{CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use drawcraft_tools::{TOOL_GROUPS, ToolInfo, tool_info};
use egui::{Color32, CornerRadius, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use crate::theme::Tokens;
use crate::{DrawcraftApp, icons, menus, widgets};
use crate::theme::{self, Tokens};
use crate::{DrawcraftApp, icons, widgets};
const CELL: f32 = 32.0;
const PITCH: f32 = 30.0;
const WIDTH: f32 = 48.0;
fn tool_tip(t: &ToolInfo) -> String {
/// The Basic toolbar: (category, slots); each slot is a flyout group (first = default).
pub const BASIC: &[(&str, &[&[&str]])] = &[
("Select", &[&["selection"], &["directSelection", "groupSelection"], &["lasso", "magicWand"]]),
(
"Shapes",
&[
&["rectangle", "roundedRectangle", "star", "lineSegment", "arc", "spiral", "rectangularGrid", "polarGrid", "flare"],
&["ellipse"],
&["polygon"],
&["shaper"],
],
),
(
"Draw",
&[
&["pencil", "smooth", "pathEraser", "join"],
&["eraser", "scissors", "knife"],
&["paintbrush", "blobBrush"],
&["pen", "addAnchor", "deleteAnchor", "anchorPoint"],
&["curvature"],
],
),
(
"Modify",
&[
&["width", "warp", "twirl", "pucker", "bloat", "scallop", "crystallize", "wrinkle"],
&["rotate", "reflect", "scale", "shear", "reshape", "freeTransform", "puppetWarp"],
&["shapeBuilder", "livePaintBucket", "livePaintSelection", "blend"],
],
),
("Type", &[&["areaType", "typeOnPath", "verticalType", "verticalAreaType", "verticalTypeOnPath"], &["type", "touchType"]]),
("Navigate", &[&["zoom"], &["hand", "printTiling"], &["rotateView"]]),
("Color", &[&["gradient", "mesh"], &["eyedropper", "measure"]]),
];
fn tip(t: &ToolInfo) -> String {
match t.shortcut {
Some(s) => format!("{} ({})", t.label, s),
None => t.label.to_string(),
}
}
/// Slots of the current layout: (category label for the first slot of a category, tool ids).
fn slots(app: &DrawcraftApp) -> Vec<(Option<&'static str>, Vec<&'static str>)> {
if app.ui.toolbar_advanced {
TOOL_GROUPS.iter().map(|g| (None, g.iter().map(|t| t.id).collect())).collect()
} else {
BASIC.iter().flat_map(|(cat, ss)| ss.iter().enumerate().map(move |(i, s)| (if i == 0 { Some(*cat) } else { None }, s.to_vec()))).collect()
}
}
/// Remember the tool shown in the slot that contains `id`.
pub fn remember(app: &mut DrawcraftApp, id: &str) {
for (_, s) in slots(app) {
if s.contains(&id) {
app.ui.slot_tool.insert(s[0].to_string(), id.to_string());
}
}
}
pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
let all = slots(app);
let avail = ui.available_height();
let need = TOOL_GROUPS.len() as f32 * (CELL - 1.0) + 190.0;
let labels = all.iter().filter(|s| s.0.is_some()).count() as f32 * 20.0;
let need = all.len() as f32 * PITCH + labels + 190.0;
let cols = if app.ui.toolbar_double || avail < need { 2 } else { 1 };
let w = CELL * cols as f32 + 10.0;
egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(5, 4)).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
// Header: gripper with «/» toggle.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 12.0), Sense::click());
let chev = if app.ui.toolbar_double { "chevrons-left" } else { "chevrons-right" };
icons::paint(ui, chev, egui::Rect::from_center_size(hdr.center(), vec2(11.0, 11.0)), if hresp.hovered() { t.text } else { t.text_dim });
if hresp.on_hover_text("Toggle single/double column").clicked() {
app.ui.toolbar_double = !app.ui.toolbar_double;
}
ui.add_space(2.0);
let active = app.session.tool_id();
let mut open_flyout: Option<(usize, egui::Rect)> = None;
let groups: Vec<(usize, &'static [ToolInfo])> = TOOL_GROUPS.iter().copied().enumerate().collect();
for row in groups.chunks(cols) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
for (gi, group) in row {
let shown_id = app.ui.group_tool.get(*gi).cloned().unwrap_or_else(|| group[0].id.to_string());
let shown = group.iter().find(|x| x.id == shown_id).unwrap_or(&group[0]);
let is_active = group.iter().any(|x| x.id == active);
let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL - 2.0), Sense::click_and_drag());
let bg = if is_active {
t.tool_active
} else if resp.hovered() {
t.hover
} else {
egui::Color32::TRANSPARENT
};
ui.painter().rect_filled(rect.shrink(1.0), CornerRadius::same(4), bg);
let icon = icons::tool_icon(shown.icon);
icons::paint(ui, icon, rect.shrink(7.0), if is_active { t.text } else { t.icon });
if group.len() > 1 {
let c = rect.right_bottom() + vec2(-4.0, -4.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-4.0, 0.0), c + vec2(0.0, -4.0)], t.text_dim, Stroke::NONE));
}
let long_press = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35));
if resp.secondary_clicked() || long_press || (resp.clicked() && ui.input(|i| i.modifiers.alt) && group.len() > 1) {
if ui.input(|i| i.modifiers.alt) && resp.clicked() {
// Option-click cycles through the group.
let idx = group.iter().position(|x| x.id == shown.id).unwrap_or(0);
let next = group[(idx + 1) % group.len()].id;
app.select_tool(next);
} else if group.len() > 1 {
open_flyout = Some((*gi, rect));
}
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tool_tip(shown));
let w = if cols == 2 { 76.0 } else { WIDTH };
egui::Panel::left("toolbar")
.resizable(false)
.exact_size(w)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).stroke(Stroke::new(1.5, t.border)))
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Dock header with » and a grip strip.
let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click());
ui.painter().rect_filled(hdr, 0.0, t.tab_strip);
icons::paint(
ui,
if cols == 2 { "chevrons-left" } else { "chevrons-right" },
egui::Rect::from_min_size(hdr.left_top() + vec2(3.0, 2.0), vec2(10.0, 10.0)),
if hresp.hovered() { t.text_strong } else { t.text },
);
if hresp.on_hover_text("Toggle single/double column").clicked() {
app.ui.toolbar_double = !app.ui.toolbar_double;
}
let (grip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 6.0), Sense::hover());
for k in 0..6 {
ui.painter().line_segment(
[pos2(grip.center().x - 9.0, grip.top() + 1.5 + k as f32 * 0.6), pos2(grip.center().x + 9.0, grip.top() + 1.5 + k as f32 * 0.6)],
Stroke::new(0.4, t.text_disabled),
);
}
let active = app.session.tool_id();
let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
let mut i = 0;
while i < all.len() {
if let Some(cat) = all[i].0 {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 18.0), Sense::hover());
let label = if cols == 1 && cat.len() > 6 { format!("{}...", &cat[..4]) } else { cat.to_string() };
ui.painter().text(r.center() + vec2(0.0, 2.0), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(11.0), t.text);
}
});
ui.add_space(1.0);
}
if let Some(f) = open_flyout {
app.ui.flyout = Some(f.0);
ui.data_mut(|d| d.insert_temp(egui::Id::new("flyout-anchor"), f.1));
}
ui.add_space(6.0);
// Fill / stroke proxy.
let (fill, stroke) = (app.session.paint.fill.clone(), app.session.paint.stroke.clone());
let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) {
Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
_ => (fill, stroke),
};
ui.vertical_centered(|ui| {
let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 40.0);
if f {
app.session.fill_active = true;
// One row = `cols` slots (a category label always starts a new row).
let mut row = vec![i];
while row.len() < cols && i + row.len() < all.len() && all[i + row.len()].0.is_none() {
row.push(i + row.len());
}
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
if cols == 1 {
ui.add_space((WIDTH - 36.0) / 2.0);
} else {
ui.add_space(2.0);
}
for &k in &row {
let slot = &all[k].1;
let shown_id = if slot.contains(&active) {
active.to_string()
} else {
app.ui.slot_tool.get(slot[0]).cloned().unwrap_or_else(|| slot[0].to_string())
};
let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue };
let is_active = slot.contains(&active);
let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());
let well = egui::Rect::from_center_size(rect.center(), vec2(35.5, 27.5));
if is_active {
ui.painter().rect_filled(well, CornerRadius::same(1), t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(well, CornerRadius::same(1), t.hover);
}
let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
if slot.len() > 1 {
let c = rect.center() + vec2(12.5, 10.0);
ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
}
let long_press =
resp.is_pointer_button_down_on() && ui.input(|inp| inp.pointer.press_start_time().is_some_and(|s| inp.time - s > 0.35));
let alt = ui.input(|inp| inp.modifiers.alt);
if (resp.secondary_clicked() || long_press) && slot.len() > 1 {
open_flyout = Some((slot.clone(), rect));
} else if resp.clicked() && alt && slot.len() > 1 {
let idx = slot.iter().position(|x| *x == shown.id).unwrap_or(0);
app.select_tool(slot[(idx + 1) % slot.len()]);
} else if resp.clicked() {
app.select_tool(shown.id);
}
resp.on_hover_text(tip(shown));
}
});
i += row.len();
}
if s {
app.session.fill_active = false;
}
if swap {
app.run("paint.swap", json!({})).ok();
}
if def {
app.run("paint.default", json!({})).ok();
if let Some((slot, rect)) = open_flyout {
app.ui.flyout = Some(0);
ui.data_mut(|d| {
d.insert_temp(egui::Id::new("flyout-anchor"), rect);
d.insert_temp(egui::Id::new("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
});
}
ui.add_space(8.0);
bottom_controls(app, ui, &t);
});
ui.add_space(4.0);
// Color / Gradient / None.
let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" };
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
let s = 10.0;
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK);
widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last));
if resp.on_hover_text("Color (,)").clicked() {
app.run(target, json!({"color": last.to_hex()})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))));
if resp.on_hover_text("Gradient (.)").clicked() {
app.run(target, json!({"gradient": {"kind": "linear"}})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::None);
if resp.on_hover_text("None (/)").clicked() {
app.run(target, json!({"none": true})).ok();
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
let m = app.ui.draw_mode as usize % 3;
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() {
app.ui.draw_mode = (app.ui.draw_mode + 1) % 3;
}
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
}
});
});
flyout(app, ui.ctx());
}
fn bottom_controls(app: &mut DrawcraftApp, ui: &mut Ui, t: &Tokens) {
let (fill, stroke) = match app.session.active().and_then(|d| d.selection.objects.first().and_then(|id| d.doc.node(*id))) {
Some(n) if !n.is_container() => (n.appearance.fill_paint(), n.appearance.stroke_paint()),
_ => (app.session.paint.fill.clone(), app.session.paint.stroke.clone()),
};
ui.vertical_centered(|ui| {
let (f, s, swap, def) = widgets::fill_stroke_proxy(ui, &fill, &stroke, app.session.fill_active, 36.0);
if f {
app.session.fill_active = true;
}
if s {
app.session.fill_active = false;
}
if swap {
app.run("paint.swap", json!({})).ok();
}
if def {
app.run("paint.default", json!({})).ok();
}
});
ui.add_space(5.0);
let target = if app.session.fill_active { "paint.setFill" } else { "paint.setStroke" };
ui.horizontal(|ui| {
ui.add_space((ui.available_width() - 27.0) / 2.0);
ui.spacing_mut().item_spacing.x = 2.0;
let s = 7.5;
let last = app.session.paint.fill.color().unwrap_or(drawcraft_color::Color::BLACK);
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::solid(last));
if resp.on_hover_text("Color (,)").clicked() {
app.run(target, json!({"color": last.to_hex()})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::Gradient(Box::new(drawcraft_color::GradientPaint::new(Default::default()))));
if resp.on_hover_text("Gradient (.)").clicked() {
app.run(target, json!({"gradient": {"kind": "linear"}})).ok();
}
let (r, resp) = ui.allocate_exact_size(vec2(s, s), Sense::click());
widgets::paint_chip(ui, r, &drawcraft_color::Paint::None);
if resp.on_hover_text("None (/)").clicked() {
app.run(target, json!({"none": true})).ok();
}
});
ui.add_space(6.0);
ui.vertical_centered(|ui| {
let modes = ["dc-draw-normal", "dc-draw-behind", "dc-draw-inside"];
let names = ["Draw Normal", "Draw Behind", "Draw Inside"];
let m = app.ui.draw_mode as usize % 3;
if widgets::icon_button(ui, modes[m], &format!("{} (Shift+D)", names[m]), false, 26.0).clicked() {
app.ui.draw_mode = (app.ui.draw_mode + 1) % 3;
}
if widgets::icon_button(ui, "dc-screen-mode", "Change Screen Mode (F)", false, 26.0).clicked() {
app.run("view.screenMode", json!({})).ok();
}
if widgets::icon_button(ui, "ellipsis", "Edit Toolbar", false, 26.0).clicked() {
app.ui.dialog = Some(crate::state::Dialog::new("allTools", json!({})));
}
});
let _ = t;
}
fn flyout(app: &mut DrawcraftApp, ctx: &egui::Context) {
let Some(gi) = app.ui.flyout else { return };
let Some(group) = TOOL_GROUPS.get(gi) else { return };
let anchor: egui::Rect = ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
if app.ui.flyout.is_none() {
return;
}
let tools: Vec<String> = ctx.data(|d| d.get_temp(egui::Id::new("flyout-tools"))).unwrap_or_default();
if tools.is_empty() {
app.ui.flyout = None;
return;
}
let anchor: egui::Rect =
ctx.data(|d| d.get_temp(egui::Id::new("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
let t = Tokens::get(ctx);
let mut chosen = None;
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(4.0, 0.0)).show(ctx, |ui| {
egui::Frame::popup(ui.style()).fill(t.panel).inner_margin(egui::Margin::same(4)).show(ui, |ui| {
ui.set_min_width(230.0);
for tool in group.iter() {
let active = app.session.tool_id() == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(230.0, 28.0), Sense::click());
if resp.hovered() || active {
ui.painter().rect_filled(r, CornerRadius::same(4), if resp.hovered() { t.accent_strong } else { t.hover });
}
if active {
ui.painter().rect_filled(egui::Rect::from_min_size(r.min + vec2(3.0, 11.0), vec2(4.0, 6.0)), 0.0, t.text);
}
let icon = icons::tool_icon(tool.icon);
icons::paint(ui, icon, egui::Rect::from_min_size(r.min + vec2(12.0, 5.0), vec2(18.0, 18.0)), t.text);
ui.painter().text(r.left_center() + vec2(38.0, 0.0), egui::Align2::LEFT_CENTER, tool.label, egui::FontId::proportional(12.5), t.text);
if let Some(sc) = tool.shortcut {
ui.painter().text(r.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, sc, egui::FontId::proportional(12.0), t.text_dim);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
});
});
let resp = egui::Area::new(egui::Id::new("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
ctx,
|ui| {
egui::Frame::NONE
.fill(t.panel)
.stroke(Stroke::new(1.0, t.input_border))
.shadow(egui::epaint::Shadow { offset: [0, 3], blur: 10, spread: 0, color: Color32::from_black_alpha(90) })
.show(ui, |ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
let w = 250.0;
for id in &tools {
let Some(tool) = tool_info(id) else { continue };
let active = app.session.tool_id() == tool.id;
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 0.0, t.hover);
}
if active {
ui.painter().rect_filled(egui::Rect::from_center_size(r.left_center() + vec2(12.0, 0.0), vec2(5.0, 5.0)), 0.0, t.text);
}
let ir = egui::Rect::from_min_size(r.min + vec2(24.0, 6.0), vec2(18.0, 18.0));
icons::paint(ui, icons::tool_icon(tool.icon), ir, t.icon);
let color = if active { t.flyout_active } else { t.text_strong };
ui.painter().text(
r.left_center() + vec2(52.0, 0.0),
egui::Align2::LEFT_CENTER,
tool.label,
egui::FontId::proportional(13.0),
color,
);
if let Some(sc) = tool.shortcut {
ui.painter().text(
r.right_center() - vec2(18.0, 0.0),
egui::Align2::RIGHT_CENTER,
format!("({sc})"),
egui::FontId::proportional(13.0),
color,
);
}
if resp.clicked() {
chosen = Some(tool.id);
}
}
});
},
);
if let Some(id) = chosen {
app.select_tool(id);
} else if resp.response.clicked_elsewhere() {
app.ui.flyout = None;
}
let _ = menus::pretty_shortcut("");
let _ = theme::semibold;
}

View File

@ -21,26 +21,26 @@ pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str, selected: bool, size: f32
ui.painter().rect_filled(rect, CornerRadius::same(3), bg);
let pad = (size * 0.2).round();
icons::paint(ui, icon, rect.shrink(pad), if selected { t.text } else { t.icon });
if !tip.is_empty() {
resp.on_hover_text(tip)
} else {
resp
}
if !tip.is_empty() { resp.on_hover_text(tip) } else { resp }
}
/// Small flat text button (Quick Actions style).
pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response {
let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click());
let bg = if resp.is_pointer_button_down_on() {
t.tool_active
let rect = rect.shrink2(vec2(0.0, 0.5));
if resp.is_pointer_button_down_on() {
ui.painter().rect_filled(rect, CornerRadius::same(2), t.tool_active);
} else if resp.hovered() {
t.hover
} else {
t.button
};
ui.painter().rect_filled(rect, CornerRadius::same(4), bg);
ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.0), t.text);
ui.painter().rect_filled(rect, CornerRadius::same(2), t.hover);
}
ui.painter().rect_stroke(
rect,
CornerRadius::same(2),
Stroke::new(1.0, if resp.hovered() { t.text } else { t.button_border }),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(12.5), t.text_strong);
resp
}
@ -74,7 +74,7 @@ pub fn secondary_button(ui: &mut Ui, text: &str) -> Response {
pub fn section_header(ui: &mut Ui, text: &str) {
let t = Tokens::get(ui.ctx());
ui.add_space(2.0);
ui.label(egui::RichText::new(text).font(theme::semibold(12.0)).color(t.text));
ui.label(egui::RichText::new(text).size(13.0).color(t.text));
ui.add_space(2.0);
}
@ -89,7 +89,7 @@ pub fn divider(ui: &mut Ui) {
pub fn dim_label(ui: &mut Ui, text: &str) -> Response {
let t = Tokens::get(ui.ctx());
ui.label(egui::RichText::new(text).color(t.text_dim).size(11.5))
ui.label(egui::RichText::new(text).color(t.text).size(12.5))
}
/// A recessed numeric field showing `value` (points) in `unit`. Returns the new value (points)
@ -106,9 +106,18 @@ pub fn num_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, value:
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(5, 2))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text)))
.corner_radius(CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut buf)
.id(id)
.frame(egui::Frame::NONE)
.desired_width(width - 14.0)
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
})
.inner;
let commit = resp.lost_focus() && buf != shown;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
@ -129,9 +138,18 @@ pub fn plain_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, valu
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))
.corner_radius(CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(5, 2))
.show(ui, |ui| ui.add(egui::TextEdit::singleline(&mut buf).id(id).frame(egui::Frame::NONE).desired_width(width - 12.0).font(egui::FontId::proportional(12.0)).text_color(t.text)))
.corner_radius(CornerRadius::same(2))
.inner_margin(egui::Margin::symmetric(6, 4))
.show(ui, |ui| {
ui.add(
egui::TextEdit::singleline(&mut buf)
.id(id)
.frame(egui::Frame::NONE)
.desired_width(width - 14.0)
.font(egui::FontId::proportional(12.5))
.text_color(t.text_strong),
)
})
.inner;
ui.data_mut(|d| d.insert_temp(id, buf.clone()));
if resp.lost_focus() && buf != shown {
@ -235,19 +253,15 @@ pub fn fill_stroke_proxy(ui: &mut Ui, fill: &Paint, stroke: &Paint, fill_active:
pub fn dropdown(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, current: &str, options: &[&str], width: f32) -> Option<usize> {
let t = Tokens::get(ui.ctx());
let mut chosen = None;
let resp = egui::Frame::NONE
.fill(t.input)
.stroke(Stroke::new(1.0, t.input_border))
.corner_radius(CornerRadius::same(3))
.show(ui, |ui| {
egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if ui.selectable_label(*o == current, *o).clicked() {
chosen = Some(i);
}
let resp = egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.input_border)).corner_radius(CornerRadius::same(3)).show(ui, |ui| {
egui::ComboBox::from_id_salt(ui.id().with(id)).selected_text(egui::RichText::new(current).size(12.0)).width(width - 4.0).show_ui(ui, |ui| {
for (i, o) in options.iter().enumerate() {
if ui.selectable_label(*o == current, *o).clicked() {
chosen = Some(i);
}
})
});
}
})
});
let _ = resp;
chosen
}

View File

@ -63,14 +63,9 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "ef
fn intra_layer_allowed(from: &str, to: &str) -> bool {
let (from, to) = (short_name(from), short_name(to));
INTRA_LAYER_ORDER.iter().any(|chain| {
match (
chain.iter().position(|n| *n == from),
chain.iter().position(|n| *n == to),
) {
(Some(f), Some(t)) => t < f,
_ => false,
}
INTRA_LAYER_ORDER.iter().any(|chain| match (chain.iter().position(|n| *n == from), chain.iter().position(|n| *n == to)) {
(Some(f), Some(t)) => t < f,
_ => false,
})
}
@ -120,64 +115,30 @@ pub struct Crate {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Violation {
Unregistered {
krate: String,
},
Upward {
krate: String,
dep: String,
from: u8,
to: u8,
kind: DepKind,
},
StandaloneHasWorkspaceDep {
krate: String,
dep: String,
},
TestkitAsNormalDep {
krate: String,
},
UiBelowL6 {
krate: String,
dep: String,
layer: u8,
},
Unregistered { krate: String },
Upward { krate: String, dep: String, from: u8, to: u8, kind: DepKind },
StandaloneHasWorkspaceDep { krate: String, dep: String },
TestkitAsNormalDep { krate: String },
UiBelowL6 { krate: String, dep: String, layer: u8 },
}
impl std::fmt::Display for Violation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Violation::Unregistered { krate } => write!(
f,
"{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)"
),
Violation::Upward {
krate,
dep,
from,
to,
kind,
} => write!(
f,
"{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers"
),
Violation::Unregistered { krate } => {
write!(f, "{krate}: unknown workspace crate; register it in xtask/src/layers.rs TABLE (see plan/architecture.md §3)")
}
Violation::Upward { krate, dep, from, to, kind } => {
write!(f, "{krate} (L{from}) -> {dep} (L{to}) [{kind:?}]: may only depend on strictly lower layers")
}
Violation::StandaloneHasWorkspaceDep { krate, dep } => {
write!(
f,
"{krate}: standalone crate must not depend on workspace crate {dep}"
)
write!(f, "{krate}: standalone crate must not depend on workspace crate {dep}")
}
Violation::TestkitAsNormalDep { krate } => {
write!(
f,
"{krate}: drawcraft-testkit may only be a dev-dependency"
)
write!(f, "{krate}: drawcraft-testkit may only be a dev-dependency")
}
Violation::UiBelowL6 { krate, dep, layer } => {
write!(
f,
"{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+"
)
write!(f, "{krate} (L{layer}) depends on UI crate `{dep}`; UI toolkits are only allowed in L6+")
}
}
}
@ -188,9 +149,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
let mut out = Vec::new();
for c in crates {
let Some(class) = classify(&c.name) else {
out.push(Violation::Unregistered {
krate: c.name.clone(),
});
out.push(Violation::Unregistered { krate: c.name.clone() });
continue;
};
if class == Class::Exempt {
@ -204,10 +163,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
}
if d.workspace {
if class == Class::Standalone {
out.push(Violation::StandaloneHasWorkspaceDep {
krate: c.name.clone(),
dep: d.name.clone(),
});
out.push(Violation::StandaloneHasWorkspaceDep { krate: c.name.clone(), dep: d.name.clone() });
continue;
}
match classify(&d.name) {
@ -215,9 +171,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
None => {}
Some(Class::Testkit) => {
if d.kind != DepKind::Dev {
out.push(Violation::TestkitAsNormalDep {
krate: c.name.clone(),
});
out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() });
}
}
Some(dc) => {
@ -234,11 +188,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
}
}
} else if layer < UI_MIN_LAYER && is_ui_crate(&d.name) {
out.push(Violation::UiBelowL6 {
krate: c.name.clone(),
dep: d.name.clone(),
layer,
});
out.push(Violation::UiBelowL6 { krate: c.name.clone(), dep: d.name.clone(), layer });
}
}
}
@ -249,9 +199,7 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> {
/// Build the model from `cargo metadata --format-version 1 --no-deps`.
pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
let pkgs = meta["packages"]
.as_array()
.ok_or("metadata: no packages array")?;
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
let members: Vec<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
let mut out = Vec::new();
for p in pkgs {
@ -265,11 +213,7 @@ pub fn from_metadata(meta: &Value) -> Result<Vec<Crate>, String> {
_ => DepKind::Normal,
};
let workspace = members.contains(&dname.as_str()) || d["path"].is_string();
deps.push(Dep {
name: dname,
kind,
workspace,
});
deps.push(Dep { name: dname, kind, workspace });
}
out.push(Crate { name, deps });
}
@ -292,17 +236,7 @@ mod tests {
use super::*;
fn c(name: &str, deps: &[(&str, DepKind, bool)]) -> Crate {
Crate {
name: name.into(),
deps: deps
.iter()
.map(|(n, k, w)| Dep {
name: (*n).into(),
kind: *k,
workspace: *w,
})
.collect(),
}
Crate { name: name.into(), deps: deps.iter().map(|(n, k, w)| Dep { name: (*n).into(), kind: *k, workspace: *w }).collect() }
}
use DepKind::*;
@ -311,17 +245,8 @@ mod tests {
let g = [
c("drawcraft-geom", &[("kurbo", Normal, false)]),
c("drawcraft-doc", &[("drawcraft-geom", Normal, true)]),
c(
"drawcraft-engine",
&[
("drawcraft-doc", Normal, true),
("drawcraft-testkit", Dev, true),
],
),
c(
"drawcraft-ui-egui",
&[("drawcraft-engine", Normal, true), ("egui", Normal, false)],
),
c("drawcraft-engine", &[("drawcraft-doc", Normal, true), ("drawcraft-testkit", Dev, true)]),
c("drawcraft-ui-egui", &[("drawcraft-engine", Normal, true), ("egui", Normal, false)]),
c("drawcraft-cli", &[("drawcraft-ui-egui", Normal, true)]),
];
assert!(check(&g).is_empty(), "{:?}", check(&g));
@ -335,30 +260,20 @@ mod tests {
#[test]
fn sideways_dependency_flagged() {
let v = check(&[c("drawcraft-ops", &[("drawcraft-algo", Normal, true)])]);
let v = check(&[c("drawcraft-text", &[("drawcraft-pathops", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 2, to: 2, .. }]));
}
#[test]
fn l0_foundation_chain_allowed_one_way() {
assert!(
check(&[c(
"drawcraft-raster",
&[
("drawcraft-color", Normal, true),
("drawcraft-geom", Normal, true)
]
)])
.is_empty()
);
assert!(check(&[c("drawcraft-color", &[("drawcraft-geom", Normal, true)])]).is_empty());
let v = check(&[c("drawcraft-geom", &[("drawcraft-raster", Normal, true)])]);
let v = check(&[c("drawcraft-geom", &[("drawcraft-color", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
}
#[test]
fn self_dev_dependency_ignored() {
assert!(check(&[c("drawcraft-psd", &[("drawcraft-psd", Dev, true)])]).is_empty());
assert!(check(&[c("drawcraft-doc", &[("drawcraft-doc", Dev, true)])]).is_empty());
}
#[test]
@ -367,103 +282,37 @@ mod tests {
assert!(matches!(v[..], [Violation::Upward { kind: Dev, .. }]));
}
#[test]
fn standalone_crates_have_no_workspace_deps() {
for s in [
"drawcraft-psd",
"drawcraft-codecs",
"drawcraft-adobe-assets",
] {
let v = check(&[c(s, &[("drawcraft-geom", Normal, true)])]);
assert!(
matches!(v[..], [Violation::StandaloneHasWorkspaceDep { .. }]),
"{s}"
);
assert!(check(&[c(s, &[("image", Normal, false)])]).is_empty());
}
}
#[test]
fn standalone_is_usable_from_higher_layers() {
assert!(check(&[c("drawcraft-io", &[("drawcraft-psd", Normal, true)])]).is_empty());
let v = check(&[c("drawcraft-geom", &[("drawcraft-codecs", Normal, true)])]);
assert!(matches!(v[..], [Violation::Upward { from: 0, to: 0, .. }]));
}
#[test]
fn ui_crates_below_l6_flagged() {
for dep in [
"egui",
"eframe",
"winit",
"egui_kittest",
"rfd",
"bevy_ecs",
"bevy",
] {
for dep in ["egui", "eframe", "winit", "egui_kittest", "rfd", "bevy_ecs", "bevy"] {
let v = check(&[c("drawcraft-engine", &[(dep, Normal, false)])]);
assert!(
matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]),
"{dep}"
);
assert!(matches!(v[..], [Violation::UiBelowL6 { layer: 5, .. }]), "{dep}");
}
assert!(
check(&[c(
"drawcraft-engine",
&[("egui_extras_not", Normal, false)]
)])
.is_empty()
);
assert!(check(&[c("drawcraft-platform", &[("winit", Normal, false)])]).is_empty());
assert!(check(&[c("drawcraft-engine", &[("egui_extras_not", Normal, false)])]).is_empty());
assert!(check(&[c("drawcraft-mcp", &[("winit", Normal, false)])]).is_empty());
}
#[test]
fn unregistered_crate_is_error() {
let v = check(&[c("drawcraft-mystery", &[])]);
assert!(
matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery")
);
assert!(matches!(&v[..], [Violation::Unregistered { krate }] if krate == "drawcraft-mystery"));
assert!(v[0].to_string().contains("register"));
}
#[test]
fn testkit_only_as_dev_dependency() {
let v = check(&[c(
"drawcraft-raster",
&[("drawcraft-testkit", Normal, true)],
)]);
let v = check(&[c("drawcraft-render", &[("drawcraft-testkit", Normal, true)])]);
assert!(matches!(v[..], [Violation::TestkitAsNormalDep { .. }]));
assert!(check(&[c("drawcraft-raster", &[("drawcraft-testkit", Dev, true)])]).is_empty());
assert!(check(&[c("drawcraft-render", &[("drawcraft-testkit", Dev, true)])]).is_empty());
// testkit itself may use anything up to L5 but not L6 crates.
assert!(
check(&[c(
"drawcraft-testkit",
&[("drawcraft-engine", Normal, true)]
)])
.is_empty()
);
assert!(
!check(&[c(
"drawcraft-testkit",
&[("drawcraft-ui-egui", Normal, true)]
)])
.is_empty()
);
assert!(check(&[c("drawcraft-testkit", &[("drawcraft-engine", Normal, true)])]).is_empty());
assert!(!check(&[c("drawcraft-testkit", &[("drawcraft-ui-egui", Normal, true)])]).is_empty());
}
#[test]
fn apps_and_xtask_exempt() {
for app in ["drawcraft", "drawcraft-cli", "drawcraft-web", "xtask"] {
assert!(
check(&[c(
app,
&[
("egui", Normal, false),
("drawcraft-ui-egui", Normal, true)
]
)])
.is_empty()
);
assert!(check(&[c(app, &[("egui", Normal, false), ("drawcraft-ui-egui", Normal, true)])]).is_empty());
}
}

View File

@ -47,10 +47,7 @@ fn main() -> ExitCode {
/// Workspace root (parent of the xtask crate).
pub fn root() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.expect("xtask has a parent dir")
.to_path_buf()
Path::new(env!("CARGO_MANIFEST_DIR")).parent().expect("xtask has a parent dir").to_path_buf()
}
pub fn cargo() -> Command {
@ -61,26 +58,14 @@ pub fn cargo() -> Command {
fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}");
let status = cmd
.status()
.map_err(|e| format!("{what}: failed to spawn: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("{what}: exited with {status}"))
}
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
if status.success() { Ok(()) } else { Err(format!("{what}: exited with {status}")) }
}
pub fn metadata() -> Result<serde_json::Value, String> {
let out = cargo()
.args(["metadata", "--format-version", "1", "--no-deps"])
.output()
.map_err(|e| format!("cargo metadata: {e}"))?;
let out = cargo().args(["metadata", "--format-version", "1", "--no-deps"]).output().map_err(|e| format!("cargo metadata: {e}"))?;
if !out.status.success() {
return Err(format!(
"cargo metadata failed:\n{}",
String::from_utf8_lossy(&out.stderr)
));
return Err(format!("cargo metadata failed:\n{}", String::from_utf8_lossy(&out.stderr)));
}
serde_json::from_slice(&out.stdout).map_err(|e| format!("cargo metadata: bad JSON: {e}"))
}
@ -103,12 +88,7 @@ fn cmd_layers() -> Result<(), String> {
format!("{}{k}", layers::short_name(&d.name))
})
.collect();
println!(
"{:<28} {:<14} {}",
c.name,
layers::describe(layers::classify(&c.name)),
ws.join(", ")
);
println!("{:<28} {:<14} {}", c.name, layers::describe(layers::classify(&c.name)), ws.join(", "));
}
let violations = layers::check(&crates);
println!();
@ -145,11 +125,7 @@ fn cmd_wasm() -> Result<(), String> {
for pkg in &set {
let mut c = cargo();
c.args(["check", "--target", "wasm32-unknown-unknown", "-p", pkg]);
let ok = run(
c,
&format!("cargo check --target wasm32-unknown-unknown -p {pkg}"),
)
.is_ok();
let ok = run(c, &format!("cargo check --target wasm32-unknown-unknown -p {pkg}")).is_ok();
results.push((pkg.clone(), ok));
}
println!("\nwasm32-unknown-unknown check:");
@ -157,11 +133,7 @@ fn cmd_wasm() -> Result<(), String> {
println!(" {:<6} {p}", if *ok { "ok" } else { "FAIL" });
}
let failed = results.iter().filter(|r| !r.1).count();
if failed == 0 {
Ok(())
} else {
Err(format!("{failed} crate(s) failed the wasm check"))
}
if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) }
}
fn cmd_ci() -> Result<(), String> {
@ -179,14 +151,7 @@ fn cmd_ci() -> Result<(), String> {
"clippy",
Box::new(|| {
let mut c = cargo();
c.args([
"clippy",
"--workspace",
"--all-targets",
"--",
"-D",
"warnings",
]);
c.args(["clippy", "--workspace", "--all-targets", "--", "-D", "warnings"]);
run(c, "cargo clippy --workspace --all-targets -- -D warnings")
}),
),
@ -217,11 +182,7 @@ fn cmd_ci() -> Result<(), String> {
}
done.push(*name);
}
println!(
"\nCI summary: all {} steps passed ({})",
done.len(),
done.join(", ")
);
println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", "));
Ok(())
}
@ -256,17 +217,7 @@ Tests that use a corpus skip cleanly when it is absent.
tar.arg("-xzf").arg(&tgz).arg("-C").arg(&dest);
run(tar, "tar -xzf PngSuite-2017jul19.tgz")?;
let _ = std::fs::remove_file(&tgz);
let n = std::fs::read_dir(&dest)
.map(|d| {
d.flatten()
.filter(|e| e.path().extension().is_some_and(|x| x == "png"))
.count()
})
.unwrap_or(0);
let n = std::fs::read_dir(&dest).map(|d| d.flatten().filter(|e| e.path().extension().is_some_and(|x| x == "png")).count()).unwrap_or(0);
println!("PngSuite: {n} PNG files in {}", dest.display());
if n == 0 {
Err("no PNG files extracted".into())
} else {
Ok(())
}
if n == 0 { Err("no PNG files extracted".into()) } else { Ok(()) }
}

View File

@ -55,19 +55,11 @@ fn count_tests(src: &str) -> (usize, usize) {
}
fn exact_tests(pkg: &str) -> Option<usize> {
let out = crate::cargo()
.args(["test", "-q", "-p", pkg, "--", "--list"])
.output()
.ok()?;
let out = crate::cargo().args(["test", "-q", "-p", pkg, "--", "--list"]).output().ok()?;
if !out.status.success() {
return None;
}
Some(
String::from_utf8_lossy(&out.stdout)
.lines()
.filter(|l| l.ends_with(": test"))
.count(),
)
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
}
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
@ -75,10 +67,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
let mut rows = Vec::new();
for p in meta["packages"].as_array().into_iter().flatten() {
let name = p["name"].as_str().unwrap_or("?").to_owned();
let Some(dir) = p["manifest_path"]
.as_str()
.and_then(|m| Path::new(m).parent().map(Path::to_path_buf))
else {
let Some(dir) = p["manifest_path"].as_str().and_then(|m| Path::new(m).parent().map(Path::to_path_buf)) else {
continue;
};
let mut c = Counts::default();
@ -88,15 +77,8 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
}
rows.sort_by(|a, b| a.0.cmp(&b.0));
let exact_col = if exact {
format!(" {:>8}", "harness")
} else {
String::new()
};
println!(
"{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}",
"crate", "files", "lines", "#[test]", "proptest!"
);
let exact_col = if exact { format!(" {:>8}", "harness") } else { String::new() };
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{exact_col}", "crate", "files", "lines", "#[test]", "proptest!");
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
let mut total = Counts::default();
let mut total_exact = 0;
@ -109,29 +91,17 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
}
(true, None) => format!(" {:>8}", "error"),
};
println!(
"{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}",
c.files, c.lines, c.tests, c.proptests
);
println!("{name:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", c.files, c.lines, c.tests, c.proptests);
total.files += c.files;
total.lines += c.lines;
total.tests += c.tests;
total.proptests += c.proptests;
}
println!("{}", "-".repeat(62 + if exact { 9 } else { 0 }));
let ex_s = if exact {
format!(" {total_exact:>8}")
} else {
String::new()
};
println!(
"{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}",
"TOTAL", total.files, total.lines, total.tests, total.proptests
);
let ex_s = if exact { format!(" {total_exact:>8}") } else { String::new() };
println!("{:<26} {:>6} {:>8} {:>7} {:>10}{ex_s}", "TOTAL", total.files, total.lines, total.tests, total.proptests);
if !exact {
println!(
"\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)"
);
println!("\n(#[test] counts source attributes; macro-generated tests count once. Use --exact for harness counts.)");
}
Ok(())
}
@ -142,8 +112,7 @@ mod tests {
#[test]
fn counts_attributes_and_proptest_blocks() {
let src =
"#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
assert_eq!(count_tests(src), (2, 1));
}
}