M5.2/M5.4: render geometry cache (Arc identity), opacity folding, LOD, multithreaded rasterizer (20k objects: 187→21 ms); native macOS menu bar; Effect menu + live-preview dialogs; PDF open/save; bench example
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6d21d36d16
commit
60c562c956
|
|
@ -723,6 +723,34 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-channel"
|
||||
version = "0.5.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.23"
|
||||
|
|
@ -804,6 +832,7 @@ dependencies = [
|
|||
"eframe",
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -860,6 +889,7 @@ dependencies = [
|
|||
"drawcraft-format",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-pathops",
|
||||
"drawcraft-pdf",
|
||||
"drawcraft-render",
|
||||
"drawcraft-svg",
|
||||
"drawcraft-text",
|
||||
|
|
@ -1006,9 +1036,11 @@ version = "0.1.0"
|
|||
dependencies = [
|
||||
"drawcraft-color",
|
||||
"drawcraft-doc",
|
||||
"drawcraft-effects",
|
||||
"drawcraft-engine",
|
||||
"drawcraft-format",
|
||||
"drawcraft-geom",
|
||||
"drawcraft-pdf",
|
||||
"drawcraft-render",
|
||||
"drawcraft-svg",
|
||||
"drawcraft-text",
|
||||
|
|
@ -1985,6 +2017,16 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "khronos-egl"
|
||||
version = "6.0.0"
|
||||
|
|
@ -2194,6 +2236,25 @@ dependencies = [
|
|||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6744ab645ebe79f9c428455c7cb9dfa4275fa3b50a25408e08f927da8dc618c"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2 0.6.4",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"once_cell",
|
||||
"png 0.18.1",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "naga"
|
||||
version = "30.0.1"
|
||||
|
|
@ -2627,6 +2688,15 @@ dependencies = [
|
|||
"libredox",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-channel"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95be4d57809897b5a7539fc15a7dfe0e84141bc3dfaa2e9b1b27caa90acf61ab"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-float"
|
||||
version = "5.5.0"
|
||||
|
|
@ -3048,6 +3118,26 @@ dependencies = [
|
|||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "read-fonts"
|
||||
version = "0.39.2"
|
||||
|
|
@ -3707,6 +3797,15 @@ dependencies = [
|
|||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
|
|
@ -4041,9 +4140,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"crossbeam-channel",
|
||||
"glifo 0.3.0",
|
||||
"hashbrown 0.17.1",
|
||||
"ordered-channel",
|
||||
"png 0.18.1",
|
||||
"rayon",
|
||||
"thread_local",
|
||||
"vello_common 0.2.0",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -19,3 +19,6 @@ log = { workspace = true }
|
|||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
muda = { version = "0.21", default-features = false }
|
||||
|
|
|
|||
|
|
@ -7,14 +7,25 @@
|
|||
//! See `drawcraft_ui_egui::control` for the methods.
|
||||
|
||||
mod control_server;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod native_menu;
|
||||
|
||||
use drawcraft_engine::Session;
|
||||
use drawcraft_ui_egui::{DrawcraftApp, Services};
|
||||
|
||||
struct App(DrawcraftApp);
|
||||
struct App(DrawcraftApp, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if self.1.is_none() && std::env::var_os("DRAWCRAFT_NO_NATIVE_MENU").is_none() {
|
||||
self.1 = Some(native_menu::NativeMenu::install(&mut self.0));
|
||||
}
|
||||
if let Some(m) = &mut self.1 {
|
||||
m.poll(&mut self.0);
|
||||
}
|
||||
}
|
||||
self.0.logic(ctx);
|
||||
if self.0.ui.status == "quit" {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
|
|
@ -83,7 +94,11 @@ fn main() -> eframe::Result {
|
|||
eprintln!("drawcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
None,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
//! macOS: DrawCraft's menu tree as the native system menu bar (like Illustrator on the Mac).
|
||||
//! Items dispatch through the same command path as the in-window menus; enablement, check marks
|
||||
//! and dynamic labels ("Undo Move") are refreshed a few times per second.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::str::FromStr;
|
||||
|
||||
use drawcraft_ui_egui::DrawcraftApp;
|
||||
use drawcraft_ui_egui::menus::{self, Item};
|
||||
use muda::accelerator::Accelerator;
|
||||
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde_json::Value;
|
||||
|
||||
enum Handle {
|
||||
Plain(MenuItem),
|
||||
Check(CheckMenuItem),
|
||||
}
|
||||
|
||||
pub struct NativeMenu {
|
||||
_menu: Menu,
|
||||
items: HashMap<String, (String, Value, Handle)>,
|
||||
last_refresh: f64,
|
||||
}
|
||||
|
||||
/// Accelerator for a shortcut like "Cmd+Shift+]" (modifier-less shortcuts stay in the app so they
|
||||
/// don't steal keys from text fields).
|
||||
fn accel(sc: &str) -> Option<Accelerator> {
|
||||
if !(sc.contains("Cmd") || sc.contains("Ctrl") || sc.contains("Alt") || sc.starts_with('F')) {
|
||||
return None;
|
||||
}
|
||||
Accelerator::from_str(&sc.replace("Cmd", "CMD").replace("Alt", "ALT").replace("Shift", "SHIFT").replace("Ctrl", "CTRL")).ok()
|
||||
}
|
||||
|
||||
impl NativeMenu {
|
||||
pub fn install(app: &mut DrawcraftApp) -> Self {
|
||||
let menu = Menu::new();
|
||||
let mut items = HashMap::new();
|
||||
let mut counter = 0usize;
|
||||
for (title, entries) in menus::menu_tree() {
|
||||
let sub = Submenu::new(title, true);
|
||||
build(app, &sub, &entries, &mut items, &mut counter);
|
||||
let _ = menu.append(&sub);
|
||||
}
|
||||
menu.init_for_nsapp();
|
||||
app.native_menu = true;
|
||||
app.native_shortcuts =
|
||||
items.values().filter(|(c, p, _)| p.is_null() && menus::shortcut_of(c).and_then(accel).is_some()).map(|(c, _, _)| c.clone()).collect();
|
||||
Self { _menu: menu, items, last_refresh: 0.0 }
|
||||
}
|
||||
|
||||
/// Dispatch clicked items and refresh state.
|
||||
pub fn poll(&mut self, app: &mut DrawcraftApp) {
|
||||
while let Ok(ev) = MenuEvent::receiver().try_recv() {
|
||||
if let Some((cmd, params, _)) = self.items.get(ev.id.as_ref()) {
|
||||
let p = if params.is_null() { serde_json::json!({}) } else { params.clone() };
|
||||
menus::invoke(app, cmd, p);
|
||||
}
|
||||
}
|
||||
let now = drawcraft_ui_egui::now_ms();
|
||||
if now - self.last_refresh < 250.0 {
|
||||
return;
|
||||
}
|
||||
self.last_refresh = now;
|
||||
for (cmd, params, h) in self.items.values() {
|
||||
let en = menus::enabled(app, cmd);
|
||||
match h {
|
||||
Handle::Plain(i) => {
|
||||
i.set_enabled(en);
|
||||
if matches!(
|
||||
cmd.as_str(),
|
||||
"edit.undo"
|
||||
| "edit.redo"
|
||||
| "view.outline"
|
||||
| "view.rulers"
|
||||
| "view.guides"
|
||||
| "view.grid"
|
||||
| "view.edges"
|
||||
| "view.artboards"
|
||||
| "view.boundingBox"
|
||||
| "view.transparencyGrid"
|
||||
) {
|
||||
i.set_text(menus::dynamic_label(app, cmd, ""));
|
||||
}
|
||||
}
|
||||
Handle::Check(c) => {
|
||||
c.set_enabled(en);
|
||||
c.set_checked(menus::checked(app, cmd, params).unwrap_or(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build(app: &DrawcraftApp, parent: &Submenu, entries: &[Item], items: &mut HashMap<String, (String, Value, Handle)>, counter: &mut usize) {
|
||||
for e in entries {
|
||||
match e {
|
||||
Item::Sep => {
|
||||
let _ = parent.append(&PredefinedMenuItem::separator());
|
||||
}
|
||||
Item::Header(h) => {
|
||||
let _ = parent.append(&MenuItem::new(*h, false, None));
|
||||
}
|
||||
Item::Todo(label, sc) => {
|
||||
let _ = parent.append(&MenuItem::new(*label, false, accel(sc)));
|
||||
}
|
||||
Item::Sub(label, children) => {
|
||||
let sub = Submenu::new(*label, true);
|
||||
build(app, &sub, children, items, counter);
|
||||
let _ = parent.append(&sub);
|
||||
}
|
||||
Item::Cmd(label, cmd, params) => {
|
||||
*counter += 1;
|
||||
let id = format!("dc{counter}");
|
||||
let sc = if params.is_null() { menus::shortcut_of(cmd).and_then(accel) } else { None };
|
||||
let handle = if menus::checked(app, cmd, params).is_some() {
|
||||
let c = CheckMenuItem::with_id(id.clone(), *label, true, false, sc);
|
||||
let _ = parent.append(&c);
|
||||
Handle::Check(c)
|
||||
} else {
|
||||
let i = MenuItem::with_id(id.clone(), *label, true, sc);
|
||||
let _ = parent.append(&i);
|
||||
Handle::Plain(i)
|
||||
};
|
||||
items.insert(id, (cmd.to_string(), params.clone(), handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ drawcraft-render = { workspace = true }
|
|||
drawcraft-pathops = { workspace = true }
|
||||
drawcraft-svg = { workspace = true }
|
||||
drawcraft-format = { workspace = true }
|
||||
drawcraft-pdf = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64} → {index}; .drawcraft, .svg (PDF/.ai via drawcraft-pdf when available)",
|
||||
"{path} or {name, dataBase64} → {index}; .drawcraft, .svg, .pdf, .ai (PDF-compatible)",
|
||||
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 "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg|pdf|png} → {dataBase64 | text}", has_doc, serialize),
|
||||
cmd!("document.export", "Export Document", [], None, "{format: svg|png|pdf|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),
|
||||
]
|
||||
}
|
||||
|
|
@ -37,6 +37,12 @@ fn load(name: &str, bytes: &[u8]) -> Result<Document> {
|
|||
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);
|
||||
}
|
||||
if lower.ends_with(".pdf") || lower.ends_with(".ai") || bytes.starts_with(b"%PDF") {
|
||||
let r = drawcraft_pdf::import_with_report(bytes, &drawcraft_pdf::ImportOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let mut d = r.document;
|
||||
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}`")))
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +80,7 @@ pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) ->
|
|||
Ok(match format {
|
||||
"drawcraft" => drawcraft_format::save(doc, true),
|
||||
"svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(),
|
||||
"pdf" => drawcraft_pdf::export(doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
"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()
|
||||
|
|
|
|||
|
|
@ -78,3 +78,12 @@ fn save_and_open_path() {
|
|||
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_roundtrip_through_engine() {
|
||||
let mut s = session();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 80, "height": 40})).unwrap();
|
||||
let b64 = s.execute("document.serialize", &json!({"format": "pdf"})).unwrap()["dataBase64"].as_str().unwrap().to_string();
|
||||
s.execute("document.open", &json!({"name": "x.pdf", "dataBase64": b64})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.node_count() >= 2);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,5 +20,8 @@ vello_common = { version = "0.2", default-features = false }
|
|||
image = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
vello_cpu = { workspace = true, features = ["multithreading"] }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
//! `cargo run --release -p drawcraft-render --example bench` — render timing on synthetic documents.
|
||||
|
||||
use drawcraft_color::{Color, Paint};
|
||||
use drawcraft_doc::{Appearance, Document, Node};
|
||||
use drawcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use drawcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
fn rng(seed: &mut u64) -> f64 {
|
||||
*seed ^= *seed << 13;
|
||||
*seed ^= *seed >> 7;
|
||||
*seed ^= *seed << 17;
|
||||
(*seed % 1_000_000) as f64 / 1_000_000.0
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let n: usize = std::env::args().nth(1).and_then(|s| s.parse().ok()).unwrap_or(20_000);
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut seed = 42u64;
|
||||
for i in 0..n {
|
||||
let x = rng(&mut seed) * 1600.0;
|
||||
let y = rng(&mut seed) * 1200.0;
|
||||
let r = 3.0 + rng(&mut seed) * 22.0;
|
||||
let c = Color::rgb(rng(&mut seed) as f32, rng(&mut seed) as f32, rng(&mut seed) as f32);
|
||||
let id = d.alloc_id();
|
||||
let mut node = match i % 3 {
|
||||
0 => Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * r, 2.0 * r))),
|
||||
Appearance::basic(Paint::solid(c), Paint::solid(Color::BLACK), 0.5),
|
||||
),
|
||||
1 => Node::path(id, shapes::rectangle(Rect::new(x, y, x + 2.0 * r, y + r)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)),
|
||||
_ => Node::path(id, shapes::star(Point::new(x, y), r, r / 2.0, 5, 0.0), Appearance::basic(Paint::None, Paint::solid(c), 2.0)),
|
||||
};
|
||||
if i % 3 == 1 {
|
||||
node.opacity = 0.8;
|
||||
}
|
||||
d.insert(Some(l), usize::MAX, node).unwrap();
|
||||
}
|
||||
let mut r = Renderer::new();
|
||||
let opts = RenderOptions::default();
|
||||
for (label, w, h, view) in [
|
||||
("fit 2880x1800", 2880u32, 1800u32, Affine::scale(1.6)),
|
||||
("zoom 400% 2880x1800", 2880, 1800, Affine::scale(6.4) * Affine::translate((-600.0, -400.0))),
|
||||
("thumb 400x300", 400, 300, Affine::scale(0.25)),
|
||||
] {
|
||||
r.render(&d, w, h, view, &opts); // warm up
|
||||
let t = std::time::Instant::now();
|
||||
let iters = 5;
|
||||
for _ in 0..iters {
|
||||
r.render(&d, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
"{n} objects, {label}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
r.stats.culled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -83,8 +83,26 @@ impl Rendered {
|
|||
}
|
||||
}
|
||||
|
||||
/// Cached per-node geometry, keyed by `Arc` identity. Structural sharing means an unchanged node
|
||||
/// keeps its allocation across edits, so a pointer match (with the Arc kept alive here so the
|
||||
/// address can't be reused) is an exact cache hit — no invalidation logic needed.
|
||||
struct GeomEntry {
|
||||
node: Arc<Node>,
|
||||
bounds: Option<Rect>,
|
||||
path: Option<Arc<BezPath>>,
|
||||
stamp: u64,
|
||||
}
|
||||
|
||||
/// Reusable renderer (keeps the render context, decoded images and glyph caches between frames).
|
||||
pub struct Renderer {
|
||||
/// Single-threaded context used when the document has raster filters (vello's filters require it).
|
||||
ctx_st: Option<RenderContext>,
|
||||
/// Worker threads for the multithreaded rasterizer (0 = single-threaded).
|
||||
pub threads: u16,
|
||||
geom: HashMap<usize, GeomEntry>,
|
||||
stamp: u64,
|
||||
/// Opacity folded into paint alpha for the leaf being drawn (avoids a compositing layer).
|
||||
alpha: f32,
|
||||
ctx: Option<RenderContext>,
|
||||
resources: Resources,
|
||||
images: HashMap<String, Arc<Pixmap>>,
|
||||
|
|
@ -117,7 +135,17 @@ struct Frame<'a> {
|
|||
|
||||
impl Renderer {
|
||||
pub fn new() -> Self {
|
||||
Self { ctx: None, resources: Resources::new(), images: HashMap::new(), stats: FrameStats::default() }
|
||||
Self {
|
||||
ctx_st: None,
|
||||
threads: default_threads(),
|
||||
geom: HashMap::new(),
|
||||
stamp: 0,
|
||||
alpha: 1.0,
|
||||
ctx: None,
|
||||
resources: Resources::new(),
|
||||
images: HashMap::new(),
|
||||
stats: FrameStats::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render `doc` into a `width`×`height` image using `view` (document → pixel transform).
|
||||
|
|
@ -125,12 +153,21 @@ impl Renderer {
|
|||
let start = now();
|
||||
let w = width.clamp(1, u16::MAX as u32) as u16;
|
||||
let h = height.clamp(1, u16::MAX as u32) as u16;
|
||||
let mut ctx = match self.ctx.take() {
|
||||
// Raster filters (drop shadow, glows, blur) need the single-threaded pipeline.
|
||||
let mut has_filters = false;
|
||||
doc.walk(|n| {
|
||||
if !has_filters && !n.appearance.effects.is_empty() && !drawcraft_effects::raster_effects(&n.appearance.effects).is_empty() {
|
||||
has_filters = true;
|
||||
}
|
||||
});
|
||||
let threads = if has_filters { 0 } else { self.threads };
|
||||
let slot = if threads == 0 { self.ctx_st.take() } else { self.ctx.take() };
|
||||
let mut ctx = match slot {
|
||||
Some(mut c) if c.width() == w && c.height() == h => {
|
||||
c.reset();
|
||||
c
|
||||
}
|
||||
_ => RenderContext::new(w, h),
|
||||
_ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }),
|
||||
};
|
||||
self.stats = FrameStats::default();
|
||||
let inv = view.inverse();
|
||||
|
|
@ -150,13 +187,23 @@ impl Renderer {
|
|||
ctx.fill_rect(&ab.rect);
|
||||
}
|
||||
}
|
||||
self.stamp += 1;
|
||||
for layer in &doc.layers {
|
||||
self.draw_node(&mut ctx, &frame, layer, false);
|
||||
self.draw_arc(&mut ctx, &frame, layer);
|
||||
}
|
||||
// Drop cache entries not seen for a few frames.
|
||||
let g = self.stamp;
|
||||
if self.geom.len() > 1024 {
|
||||
self.geom.retain(|_, e| g - e.stamp <= 3);
|
||||
}
|
||||
ctx.flush();
|
||||
let mut pm = Pixmap::new(w, h);
|
||||
ctx.render(&mut pm, &mut self.resources);
|
||||
self.ctx = Some(ctx);
|
||||
if threads == 0 {
|
||||
self.ctx_st = Some(ctx);
|
||||
} else {
|
||||
self.ctx = Some(ctx);
|
||||
}
|
||||
self.stats.micros = now().saturating_sub(start);
|
||||
Rendered { width: w as u32, height: h as u32, pixels: pm.data_as_u8_slice().to_vec() }
|
||||
}
|
||||
|
|
@ -186,11 +233,91 @@ impl Renderer {
|
|||
Some(Rendered { width: size, height: size, pixels: pm.data_as_u8_slice().to_vec() })
|
||||
}
|
||||
|
||||
/// Cached cull bounds of a node (containers union their cached children).
|
||||
fn bounds_of(&mut self, a: &Arc<Node>) -> Option<Rect> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some(e) = self.geom.get_mut(&key)
|
||||
&& Arc::ptr_eq(&e.node, a)
|
||||
{
|
||||
e.stamp = self.stamp;
|
||||
return e.bounds;
|
||||
}
|
||||
let b = match &a.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } if !fx::has_fx(a) => {
|
||||
let mut acc: Option<Rect> = None;
|
||||
for c in children {
|
||||
if c.visible {
|
||||
acc = drawcraft_geom::union_opt(acc, self.bounds_of(c));
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
_ => fx::cull_bounds(a),
|
||||
};
|
||||
self.geom.insert(key, GeomEntry { node: a.clone(), bounds: b, path: None, stamp: self.stamp });
|
||||
b
|
||||
}
|
||||
|
||||
/// Cached BezPath of a path node.
|
||||
fn path_of(&mut self, a: &Arc<Node>) -> Option<Arc<BezPath>> {
|
||||
let key = Arc::as_ptr(a) as usize;
|
||||
if let Some(e) = self.geom.get(&key)
|
||||
&& Arc::ptr_eq(&e.node, a)
|
||||
&& let Some(p) = &e.path
|
||||
{
|
||||
return Some(p.clone());
|
||||
}
|
||||
let p = Arc::new(a.path_data()?.to_bezpath());
|
||||
let bounds = self.bounds_of(a);
|
||||
self.geom.insert(key, GeomEntry { node: a.clone(), bounds, path: Some(p.clone()), stamp: self.stamp });
|
||||
Some(p)
|
||||
}
|
||||
|
||||
fn draw_arc(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc<Node>) {
|
||||
if !a.visible || f.opts.hidden.contains(&a.id) {
|
||||
return;
|
||||
}
|
||||
match self.bounds_of(a) {
|
||||
Some(b) => {
|
||||
let pad = f.px * 2.0;
|
||||
if !rects_overlap(b.inflate(pad, pad), f.visible) {
|
||||
self.stats.culled += 1;
|
||||
return;
|
||||
}
|
||||
// Level of detail: leaves smaller than a quarter pixel are invisible.
|
||||
if !a.is_container() && b.width() < f.px * 0.25 && b.height() < f.px * 0.25 {
|
||||
self.stats.culled += 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
None if !a.is_container() => return,
|
||||
None => {}
|
||||
}
|
||||
// Fast path for plain paths: cached geometry, opacity folded into the paint.
|
||||
if let NodeKind::Path { rule, guide: false, .. } = &a.kind
|
||||
&& !f.opts.outline
|
||||
&& a.blend == drawcraft_color::BlendMode::Normal
|
||||
&& !a.isolate
|
||||
&& !fx::has_fx(a)
|
||||
&& (a.opacity >= 1.0 || painted_items(a) == 1)
|
||||
&& let Some(bp) = self.path_of(a)
|
||||
{
|
||||
self.alpha = a.opacity.clamp(0.0, 1.0);
|
||||
self.draw_shape(ctx, f, a, &bp, *rule);
|
||||
self.alpha = 1.0;
|
||||
self.stats.drawn += 1;
|
||||
return;
|
||||
}
|
||||
self.draw_node(ctx, f, a, true);
|
||||
}
|
||||
|
||||
fn draw_node(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, force: bool) {
|
||||
if !force && (!n.visible || f.opts.hidden.contains(&n.id)) {
|
||||
return;
|
||||
}
|
||||
if let Some(b) = fx::cull_bounds(n) {
|
||||
if force {
|
||||
// Bounds were already checked by draw_arc (or the caller wants it drawn regardless).
|
||||
} else 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();
|
||||
|
|
@ -211,7 +338,7 @@ impl Renderer {
|
|||
match &n.kind {
|
||||
NodeKind::Layer { children, .. } | NodeKind::Group { children, clip: false } => {
|
||||
for c in children {
|
||||
self.draw_node(ctx, f, c, false);
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
}
|
||||
NodeKind::Group { children, clip: true } => {
|
||||
|
|
@ -222,7 +349,7 @@ impl Renderer {
|
|||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
ctx.push_clip_layer(&clip);
|
||||
for c in children.iter().skip(1) {
|
||||
self.draw_node(ctx, f, c, false);
|
||||
self.draw_arc(ctx, f, c);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
|
|
@ -297,6 +424,7 @@ impl Renderer {
|
|||
}
|
||||
ctx.set_transform(f.view);
|
||||
if paint::set_paint(ctx, &fl.paint, bounds, f.doc) {
|
||||
self.fold_alpha(ctx, &fl.paint);
|
||||
ctx.set_fill_rule(fill_rule(rule));
|
||||
ctx.fill_path(bp);
|
||||
}
|
||||
|
|
@ -314,6 +442,16 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Multiply the folded object opacity into a solid paint.
|
||||
fn fold_alpha(&self, ctx: &mut RenderContext, p: &drawcraft_color::Paint) {
|
||||
if self.alpha < 1.0
|
||||
&& let drawcraft_color::Paint::Solid { color, .. } = p
|
||||
{
|
||||
let [r, g, b, a] = color.to_rgba8(self.alpha);
|
||||
ctx.set_paint(peniko::Color::from_rgba8(r, g, b, a));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_stroke(&mut self, ctx: &mut RenderContext, f: &Frame, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
|
||||
let layered = st.opacity < 1.0 || st.blend != drawcraft_color::BlendMode::Normal || st.align == StrokeAlign::Outside;
|
||||
if layered {
|
||||
|
|
@ -357,6 +495,7 @@ impl Renderer {
|
|||
ctx.push_clip_layer(bp);
|
||||
}
|
||||
if paint::set_paint(ctx, &st.paint, bounds.inflate(st.width / 2.0, st.width / 2.0), f.doc) {
|
||||
self.fold_alpha(ctx, &st.paint);
|
||||
ctx.stroke_path(bp);
|
||||
}
|
||||
if inside {
|
||||
|
|
@ -470,6 +609,34 @@ impl Renderer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Number of visible painted fill/stroke items (opacity folding is exact only for one).
|
||||
fn painted_items(n: &Node) -> usize {
|
||||
n.appearance
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.visible && !f.paint.is_none() && matches!(f.paint, drawcraft_color::Paint::Solid { .. }),
|
||||
AppearanceItem::Stroke(s) => s.visible && !s.paint.is_none() && s.width > 0.0 && matches!(s.paint, drawcraft_color::Paint::Solid { .. }) && s.dash.is_none(),
|
||||
})
|
||||
.count()
|
||||
.max(if n.appearance.items.iter().any(|i| matches!(i, AppearanceItem::Fill(f) if f.visible && !matches!(f.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None)) || matches!(i, AppearanceItem::Stroke(s) if s.visible && !matches!(s.paint, drawcraft_color::Paint::Solid { .. } | drawcraft_color::Paint::None))) { 2 } else { 0 })
|
||||
}
|
||||
|
||||
/// Rasterizer worker threads: 0 on wasm; otherwise up to 4 (vello's sweet spot), leaving a core free.
|
||||
fn default_threads() -> u16 {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if std::env::var_os("DRAWCRAFT_RENDER_THREADS").is_some() {
|
||||
return std::env::var("DRAWCRAFT_RENDER_THREADS").ok().and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
}
|
||||
std::thread::available_parallelism().map(|n| (n.get().saturating_sub(1)).min(4) as u16).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
fn rects_overlap(a: Rect, b: Rect) -> bool {
|
||||
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ drawcraft-text = { workspace = true }
|
|||
drawcraft-tools = { workspace = true }
|
||||
drawcraft-engine = { workspace = true }
|
||||
drawcraft-svg = { workspace = true }
|
||||
drawcraft-effects = { workspace = true }
|
||||
drawcraft-pdf = { workspace = true }
|
||||
drawcraft-format = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -694,6 +694,21 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
}
|
||||
}
|
||||
// Bounding box with handles (Selection tool).
|
||||
// Live corner widgets (Direct Selection on a single live rectangle).
|
||||
if tool == "directSelection"
|
||||
&& 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 color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
let inset = (radii[0].max(10.0 / xf.zoom)).min(w.min(*h) / 2.0);
|
||||
for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] {
|
||||
let sp = xf.to_screen(*lxf * Point::new(cx, cy));
|
||||
p.circle_filled(sp, 3.0, Color32::WHITE);
|
||||
p.circle_stroke(sp, 3.0, Stroke::new(1.0, color));
|
||||
p.circle_filled(sp, 1.0, color);
|
||||
}
|
||||
}
|
||||
if tool == "selection" && app.ui.view.bounding_box && !st.selection.is_empty() && st.selection.anchors.is_empty() {
|
||||
let Some(b) = st.doc.bounds_of(&st.selection.objects, false) else { return };
|
||||
let color = c32(st.doc.layer_color(st.selection.objects[0]));
|
||||
|
|
@ -705,20 +720,6 @@ fn selection_overlay(app: &DrawcraftApp, p: &egui::Painter, xf: &Xf) {
|
|||
p.rect_filled(hr, 0.0, Color32::WHITE);
|
||||
p.rect_stroke(hr, 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
|
||||
}
|
||||
// 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)
|
||||
&& r.width() > 40.0
|
||||
&& r.height() > 40.0
|
||||
{
|
||||
let inset = (radii[0].max(20.0 / xf.zoom)).min(w.min(*h) / 2.0);
|
||||
for (cx, cy) in [(inset, inset), (w - inset, inset), (w - inset, h - inset), (inset, h - inset)] {
|
||||
let sp = xf.to_screen(*lxf * Point::new(cx, cy));
|
||||
p.circle_stroke(sp, 3.5, Stroke::new(1.0, color));
|
||||
p.circle_filled(sp, 1.3, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ pub fn app_bar(app: &mut DrawcraftApp, ui: &mut Ui) {
|
|||
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"})));
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
menus::menu_bar(app, ui);
|
||||
if app.native_menu {
|
||||
let full = ui.max_rect();
|
||||
ui.painter().text(full.center(), egui::Align2::CENTER_CENTER, "DrawCraft", egui::FontId::proportional(13.5), t.text);
|
||||
} else {
|
||||
menus::menu_bar(app, ui);
|
||||
}
|
||||
let full = ui.max_rect();
|
||||
let right = egui::Rect::from_min_max(egui::pos2(full.right() - 340.0, full.top()), full.right_bottom());
|
||||
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
|
|
@ -244,7 +249,6 @@ 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());
|
||||
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)))
|
||||
|
|
|
|||
|
|
@ -45,6 +45,51 @@ fn origin_params(d: &Dialog, mut p: Value) -> Value {
|
|||
p
|
||||
}
|
||||
|
||||
/// Effect parameters from the dialog fields (drop UI-only keys).
|
||||
fn effect_params(d: &Dialog) -> Value {
|
||||
Value::Object(d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect())
|
||||
}
|
||||
|
||||
/// Generic editor for an effect dialog: numbers, booleans, strings and colours.
|
||||
fn effect_fields(ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut changed = false;
|
||||
egui::Grid::new("fxgrid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
|
||||
let keys: Vec<(String, Value)> =
|
||||
d.fields.iter().filter(|(k, _)| !k.starts_with("__") && k.as_str() != "preview").map(|(k, v)| (k.clone(), v.clone())).collect();
|
||||
for (k, v) in keys {
|
||||
ui.label(egui::RichText::new(humanize(&k)).color(t.text));
|
||||
match v {
|
||||
Value::Number(n) => {
|
||||
let mut x = n.as_f64().unwrap_or(0.0);
|
||||
let speed = if x.abs() > 20.0 { 1.0 } else { 0.1 };
|
||||
if ui.add(egui::DragValue::new(&mut x).speed(speed).max_decimals(2)).changed() {
|
||||
d.fields.insert(k, json!(x));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Value::Bool(mut b) => {
|
||||
if ui.checkbox(&mut b, "").changed() {
|
||||
d.fields.insert(k, json!(b));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
Value::String(mut s) => {
|
||||
if ui.add(egui::TextEdit::singleline(&mut s).desired_width(140.0)).changed() {
|
||||
d.fields.insert(k, json!(s));
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
other => {
|
||||
ui.label(egui::RichText::new(other.to_string()).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
changed
|
||||
}
|
||||
|
||||
fn title(kind: &str) -> &'static str {
|
||||
match kind {
|
||||
"newDocument" => "New Document",
|
||||
|
|
@ -75,6 +120,20 @@ fn title(kind: &str) -> &'static str {
|
|||
pub fn confirm(app: &mut DrawcraftApp) -> Result<Value, String> {
|
||||
let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
|
||||
let copy = d.bool("copy");
|
||||
if d.kind == "effect" {
|
||||
let effect = d.str("__effect");
|
||||
let params = effect_params(&d);
|
||||
let r = if app.session.in_interaction() {
|
||||
// Live preview already applied: keep it (the interaction commits as one undo step).
|
||||
let _ = app.session.preview("effect.apply", &json!({"effect": effect, "params": params}));
|
||||
app.session.commit_interaction().map(|_| Value::Null).map_err(|e| e.to_string())
|
||||
} else {
|
||||
app.run("effect.apply", json!({"effect": effect, "params": params}))
|
||||
};
|
||||
app.last_effect = Some((effect, params));
|
||||
app.ui.dialog = None;
|
||||
return r;
|
||||
}
|
||||
let r = match d.kind.as_str() {
|
||||
"newDocument" => {
|
||||
let r = app.run("file.new", json!({"width": d.f64("width", 612.0), "height": d.f64("height", 792.0), "units": d.str("units"), "title": d.str("name"), "artboards": d.f64("artboards", 1.0), "colorMode": d.str("colorMode").to_lowercase()}));
|
||||
|
|
@ -168,7 +227,7 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
let mut ok = false;
|
||||
let mut cancel = false;
|
||||
egui::Area::new(egui::Id::new("modal-dim")).order(egui::Order::Middle).fixed_pos(egui::pos2(0.0, 0.0)).show(ctx, |ui| {
|
||||
ui.painter().rect_filled(ctx.content_rect(), 0.0, egui::Color32::from_black_alpha(90));
|
||||
// Illustrator's dialogs are modal but don't dim the canvas (previews stay readable).
|
||||
ui.allocate_rect(ctx.content_rect(), egui::Sense::click());
|
||||
});
|
||||
egui::Window::new(title(&d.kind))
|
||||
|
|
@ -181,9 +240,24 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(22)))
|
||||
.show(ctx, |ui| {
|
||||
ui.set_min_width(if d.kind == "newDocument" { 560.0 } else { 320.0 });
|
||||
ui.label(egui::RichText::new(title(&d.kind)).font(theme::semibold(16.0)).color(t.text));
|
||||
let heading = if d.kind == "effect" { d.str("__label") } else { title(&d.kind).to_string() };
|
||||
ui.label(egui::RichText::new(heading).font(theme::semibold(16.0)).color(t.text));
|
||||
ui.add_space(12.0);
|
||||
match d.kind.as_str() {
|
||||
"effect" => {
|
||||
let changed = effect_fields(ui, &mut d);
|
||||
ui.add_space(6.0);
|
||||
let mut pv = d.bool("preview");
|
||||
let pv_changed = ui.checkbox(&mut pv, "Preview").changed();
|
||||
d.fields.insert("preview".into(), json!(pv));
|
||||
if pv && (changed || pv_changed || !app.session.in_interaction()) {
|
||||
let label = d.str("__label");
|
||||
let _ = app.session.begin_interaction(&label);
|
||||
let _ = app.session.preview("effect.apply", &json!({"effect": d.str("__effect"), "params": effect_params(&d)}));
|
||||
} else if !pv && pv_changed {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
}
|
||||
"newDocument" => new_document(ui, &mut d),
|
||||
"allTools" => {
|
||||
for g in drawcraft_tools::TOOL_GROUPS {
|
||||
|
|
@ -234,8 +308,12 @@ pub fn show(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
if ctx.input(|i| i.key_pressed(egui::Key::Enter)) && d.kind != "allTools" {
|
||||
ok = true;
|
||||
}
|
||||
let is_effect = d.kind == "effect";
|
||||
app.ui.dialog = Some(d);
|
||||
if cancel {
|
||||
if is_effect {
|
||||
let _ = app.session.cancel_interaction();
|
||||
}
|
||||
app.ui.dialog = None;
|
||||
} else if ok && let Err(e) = confirm(app) {
|
||||
app.status(e);
|
||||
|
|
|
|||
|
|
@ -94,6 +94,14 @@ pub fn open_bytes(app: &mut DrawcraftApp, name: &str, bytes: &[u8], path: Option
|
|||
app.status(format!("Opened with {} warning(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if e == "pdf" || e == "ai" || bytes.starts_with(b"%PDF") {
|
||||
let drawcraft_pdf::ImportReport { document: mut d, warnings } =
|
||||
drawcraft_pdf::import_with_report(bytes, &drawcraft_pdf::ImportOptions::default()).map_err(|e| e.to_string())?;
|
||||
d.title = name.to_string();
|
||||
if !warnings.is_empty() {
|
||||
app.status(format!("Opened with {} note(s): {}", warnings.len(), warnings.first().cloned().unwrap_or_default()));
|
||||
}
|
||||
(d, false)
|
||||
} else if ["png", "jpg", "jpeg", "gif", "webp"].contains(&e.as_str()) {
|
||||
(image_doc(name, bytes)?, false)
|
||||
} else {
|
||||
|
|
@ -173,6 +181,7 @@ pub fn export(app: &mut DrawcraftApp, format: &str, path: Option<String>, scale:
|
|||
};
|
||||
let bytes = match format {
|
||||
"svg" => drawcraft_svg::export(&doc, &drawcraft_svg::ExportOptions { artboard: Some(0), ..Default::default() }).into_bytes(),
|
||||
"pdf" => drawcraft_pdf::export(&doc, &drawcraft_pdf::PdfOptions::default()).map_err(|e| e.to_string())?,
|
||||
"png" | "jpg" | "jpeg" => {
|
||||
let r = doc.artboards.first().map(|a| a.rect).ok_or("no artboard")?;
|
||||
let img = app.canvas.renderer.render_region(&doc, r, scale, format != "png");
|
||||
|
|
|
|||
|
|
@ -95,6 +95,12 @@ pub struct DrawcraftApp {
|
|||
pub perf: Perf,
|
||||
/// macOS: draw our own title strip under the traffic lights.
|
||||
pub integrated_titlebar: bool,
|
||||
/// The host installed a native menu bar (macOS): don't draw in-window menus.
|
||||
pub native_menu: bool,
|
||||
/// Last applied effect (Effect → Apply Last Effect).
|
||||
pub last_effect: Option<(String, serde_json::Value)>,
|
||||
/// Commands whose shortcuts the native menu handles (skip them in egui to avoid double firing).
|
||||
pub native_shortcuts: std::collections::HashSet<String>,
|
||||
control_rx: Option<Receiver<ControlRequest>>,
|
||||
pending_screenshots: Vec<(u64, Option<String>, Sender<ControlResponse>)>,
|
||||
queued_screenshots: Vec<(u64, f64, u32)>,
|
||||
|
|
@ -129,6 +135,9 @@ impl DrawcraftApp {
|
|||
},
|
||||
perf: Perf::default(),
|
||||
integrated_titlebar: false,
|
||||
native_menu: false,
|
||||
last_effect: None,
|
||||
native_shortcuts: Default::default(),
|
||||
control_rx: None,
|
||||
pending_screenshots: vec![],
|
||||
queued_screenshots: vec![],
|
||||
|
|
@ -372,6 +381,7 @@ impl DrawcraftApp {
|
|||
}
|
||||
if self.ui.status_bar && self.ui.screen_mode < 3 {
|
||||
chrome::status_bar(self, ui);
|
||||
chrome::hint_bar(self, ui);
|
||||
}
|
||||
if self.ui.toolbar && self.ui.screen_mode < 3 {
|
||||
toolbar::show(self, ui);
|
||||
|
|
|
|||
|
|
@ -88,6 +88,9 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
|||
("window.newWindow", "New Window", "", "{}"),
|
||||
("tool.select", "Select Tool", "", "{tool: id} (see tools)"),
|
||||
("tool.setOption", "Tool Option", "", "{key, value}"),
|
||||
("effect.dialog", "Effect…", "", "{effect: id} open the effect's dialog with live preview"),
|
||||
("effect.applyLast", "Apply Last Effect", "Cmd+Shift+E", "{}"),
|
||||
("file.export.pdf", "Save as PDF…", "", "{path?}"),
|
||||
("help.about", "About DrawCraft", "", "{}"),
|
||||
("help.commandPalette", "Search Commands…", "Cmd+Shift+/", "{}"),
|
||||
("app.quit", "Quit DrawCraft", "Cmd+Q", "{}"),
|
||||
|
|
@ -276,6 +279,25 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option<Res
|
|||
app.session.set_tool_option(&k, p.get("value").unwrap_or(&Value::Null));
|
||||
Ok(app.session.tool_options())
|
||||
}
|
||||
"effect.dialog" => {
|
||||
let id = s("effect").unwrap_or_default();
|
||||
match drawcraft_effects::effect_catalog().into_iter().find(|e| e.id == id) {
|
||||
Some(e) => {
|
||||
let mut fields = e.defaults.as_object().cloned().unwrap_or_default();
|
||||
fields.insert("__effect".into(), json!(e.id));
|
||||
fields.insert("__label".into(), json!(e.label.trim_end_matches('…')));
|
||||
fields.insert("preview".into(), json!(true));
|
||||
app.ui.dialog = Some(crate::state::Dialog { kind: "effect".into(), fields });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
None => Err(format!("unknown effect `{id}`")),
|
||||
}
|
||||
}
|
||||
"effect.applyLast" => match app.last_effect.clone() {
|
||||
Some((e, params)) => app.run("effect.apply", json!({"effect": e, "params": params})),
|
||||
None => Err("no effect applied yet".into()),
|
||||
},
|
||||
"file.export.pdf" => io::export(app, "pdf", s("path"), 1.0).map(|p| json!({"path": p})),
|
||||
"help.about" => {
|
||||
app.ui.about = true;
|
||||
Ok(Value::Null)
|
||||
|
|
@ -324,7 +346,7 @@ pub fn checked(app: &DrawcraftApp, id: &str, p: &Value) -> Option<bool> {
|
|||
}
|
||||
|
||||
/// Label for toggles whose text flips (Outline/Preview, Hide/Show …).
|
||||
fn dynamic_label(app: &DrawcraftApp, id: &str, label: &'static str) -> String {
|
||||
pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String {
|
||||
let v = &app.ui.view;
|
||||
match id {
|
||||
"view.outline" => if v.outline { "Preview" } else { "Outline" }.into(),
|
||||
|
|
@ -368,6 +390,9 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool {
|
|||
| "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()),
|
||||
"effect.dialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"effect.applyLast" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()),
|
||||
"file.export.pdf" => app.session.active().is_some(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -526,7 +551,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("Show All", "object.showAll"),
|
||||
Sep,
|
||||
todo("Expand…"),
|
||||
todo("Expand Appearance"),
|
||||
c("Expand Appearance", "effect.expandAppearance"),
|
||||
todo("Crop Image"),
|
||||
todo("Rasterize…"),
|
||||
todo("Create Gradient Mesh…"),
|
||||
|
|
@ -708,81 +733,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
|||
todo("Edit Selection…"),
|
||||
],
|
||||
),
|
||||
(
|
||||
"Effect",
|
||||
vec![
|
||||
todos("Apply Last Effect", "Cmd+Shift+E"),
|
||||
todos("Last Effect", "Cmd+Alt+Shift+E"),
|
||||
Sep,
|
||||
todo("Document Raster Effects Settings…"),
|
||||
Sep,
|
||||
Item::Header("Illustrator Effects"),
|
||||
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("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"),
|
||||
],
|
||||
),
|
||||
todo("Rasterize…"),
|
||||
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",
|
||||
vec![
|
||||
todo("Arc…"),
|
||||
todo("Arc Lower…"),
|
||||
todo("Arc Upper…"),
|
||||
todo("Arch…"),
|
||||
todo("Bulge…"),
|
||||
todo("Shell Lower…"),
|
||||
todo("Shell Upper…"),
|
||||
todo("Flag…"),
|
||||
todo("Wave…"),
|
||||
todo("Fish…"),
|
||||
todo("Rise…"),
|
||||
todo("Fisheye…"),
|
||||
todo("Inflate…"),
|
||||
todo("Squeeze…"),
|
||||
todo("Twist…"),
|
||||
],
|
||||
),
|
||||
Sep,
|
||||
Item::Header("Raster Effects"),
|
||||
todo("Effect Gallery…"),
|
||||
sub("Blur", vec![todo("Gaussian Blur…"), todo("Radial Blur…"), todo("Smart Blur…")]),
|
||||
sub("Pixelate", vec![todo("Color Halftone…"), todo("Crystallize…"), todo("Mezzotint…"), todo("Pointillize…")]),
|
||||
sub("Stylize", vec![todo("Glowing Edges…")]),
|
||||
],
|
||||
),
|
||||
("Effect", effect_menu()),
|
||||
(
|
||||
"View",
|
||||
vec![
|
||||
|
|
@ -1092,6 +1043,54 @@ pub fn menu_entries(app: &DrawcraftApp) -> Vec<MenuEntry> {
|
|||
out
|
||||
}
|
||||
|
||||
/// The Effect menu, built from the effects catalogue (Illustrator Effects), plus raster effects.
|
||||
fn effect_menu() -> Vec<Item> {
|
||||
let cat = drawcraft_effects::effect_catalog();
|
||||
let mut out = vec![
|
||||
c("Apply Last Effect", "effect.applyLast"),
|
||||
todos("Last Effect", "Cmd+Alt+Shift+E"),
|
||||
Sep,
|
||||
todo("Document Raster Effects Settings…"),
|
||||
Sep,
|
||||
Item::Header("Illustrator Effects"),
|
||||
];
|
||||
// Submenus in Illustrator's order.
|
||||
let order = ["3D and Materials", "Convert to Shape", "Distort & Transform", "Path", "Pathfinder", "Stylize", "SVG Filters", "Warp", "Blur"];
|
||||
for sub_name in order {
|
||||
let items: Vec<Item> = cat
|
||||
.iter()
|
||||
.filter(|e| e.menu.last().copied() == Some(sub_name))
|
||||
.map(|e| Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })))
|
||||
.collect();
|
||||
if sub_name == "Blur" {
|
||||
if !items.is_empty() {
|
||||
out.push(Sep);
|
||||
out.push(Item::Header("Raster Effects"));
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if items.is_empty() {
|
||||
let placeholder = match sub_name {
|
||||
"3D and Materials" => vec![todo("Extrude & Bevel…"), todo("Revolve…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")],
|
||||
"Pathfinder" => vec![todo("Add"), todo("Intersect"), todo("Exclude"), todo("Subtract")],
|
||||
"SVG Filters" => vec![todo("Apply SVG Filter…")],
|
||||
_ => continue,
|
||||
};
|
||||
out.push(sub(sub_name, placeholder));
|
||||
} else {
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
// Anything not placed above (future effects) still shows up.
|
||||
for e in cat.iter().filter(|e| !e.menu.last().is_some_and(|m| order.contains(m))) {
|
||||
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
|
||||
}
|
||||
out.push(Sep);
|
||||
out.push(c("Expand Appearance", "effect.expandAppearance"));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -1131,3 +1130,4 @@ mod tests {
|
|||
assert_eq!(pretty_shortcut(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -200,8 +200,8 @@ pub fn transform_section(app: &mut DrawcraftApp, ui: &mut Ui) {
|
|||
ui.data_mut(|d| d.insert_temp(egui::Id::new("refpt"), i));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 5.0]).show(ui, |ui| {
|
||||
let fw = 78.0;
|
||||
let fw = ((ui.available_width() - 44.0) / 2.0).clamp(60.0, 110.0);
|
||||
egui::Grid::new("xf-grid").num_columns(4).spacing([4.0, 6.0]).min_col_width(0.0).show(ui, |ui| {
|
||||
dim_label(ui, "X:");
|
||||
if let Some(v) = widgets::num_field(ui, "tx", Some(rp.x), units, fw) {
|
||||
app.run("object.setBounds", json!({"x": v, "reference": refi})).ok();
|
||||
|
|
|
|||
|
|
@ -130,6 +130,9 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) {
|
|||
if plain && (sc.logical_key.name().len() == 1 || sc.logical_key == Key::Slash) {
|
||||
continue;
|
||||
}
|
||||
if app.native_shortcuts.contains(id) {
|
||||
continue;
|
||||
}
|
||||
if ctx.input_mut(|i| i.consume_shortcut(&sc)) {
|
||||
fire = Some(id);
|
||||
break;
|
||||
|
|
|
|||
Loading…
Reference in New Issue