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:
Brandon Thomas 2026-09-30 07:07:59 -04:00
parent 6d21d36d16
commit 60c562c956
19 changed files with 713 additions and 110 deletions

103
Cargo.lock generated
View File

@ -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",
]

View File

@ -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 }

View File

@ -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,
)))
}),
)
}

View File

@ -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));
}
}
}
}

View File

@ -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 }

View File

@ -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()

View File

@ -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);
}

View File

@ -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 }

View File

@ -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
);
}
}

View File

@ -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
}

View File

@ -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 }

View File

@ -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);
}
}
}
}

View File

@ -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)))

View File

@ -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);

View File

@ -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");

View File

@ -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);

View File

@ -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(""), "");
}
}

View File

@ -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();

View File

@ -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;