From 874c0e6ca286b9359e6aeab6d0a50aea97cc2c7d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 06:28:59 -0400 Subject: [PATCH] M2.8/M6.2/M7.2: smart guides + snapping, Type tool, Pathfinder/path/type commands, web build Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 4 + crates/engine/src/cmd/pathops.rs | 121 +++++++-- crates/engine/src/cmd/typecmd.rs | 209 +++++++++++++++ crates/engine/src/lib.rs | 4 + crates/engine/src/tests_pathops.rs | 405 +++++++++++++++++++++++++++++ crates/engine/src/tooling.rs | 15 ++ crates/tools/src/guides.rs | 161 ++++++++++++ crates/tools/src/lib.rs | 13 + crates/tools/src/pen.rs | 3 +- crates/tools/src/select.rs | 19 +- crates/tools/src/shape.rs | 14 +- crates/tools/src/text.rs | 179 +++++++++++++ crates/ui-egui/src/canvas.rs | 10 +- crates/ui-egui/src/chrome.rs | 36 ++- crates/ui-egui/src/lib.rs | 16 +- crates/ui-egui/src/shortcuts.rs | 28 +- crates/ui-egui/src/state.rs | 2 +- docs/development.md | 25 ++ docs/mcp.md | 128 +++++++++ 19 files changed, 1325 insertions(+), 67 deletions(-) create mode 100644 crates/engine/src/cmd/typecmd.rs create mode 100644 crates/engine/src/tests_pathops.rs create mode 100644 crates/tools/src/guides.rs create mode 100644 crates/tools/src/text.rs create mode 100644 docs/development.md create mode 100644 docs/mcp.md diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 66a4e52e..607e06bf 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -6,7 +6,9 @@ mod layer; mod object; mod paint; mod path; +mod pathops; mod select; +mod typecmd; use drawcraft_color::Color; use drawcraft_doc::NodeId; @@ -111,6 +113,8 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(select::specs()); v.extend(paint::specs()); v.extend(layer::specs()); + v.extend(pathops::specs()); + v.extend(typecmd::specs()); v }) } diff --git a/crates/engine/src/cmd/pathops.rs b/crates/engine/src/cmd/pathops.rs index 67b20c28..8eaeb907 100644 --- a/crates/engine/src/cmd/pathops.rs +++ b/crates/engine/src/cmd/pathops.rs @@ -16,27 +16,97 @@ use super::edit::selected_roots; use super::*; pub fn specs() -> Vec { - const PF: &str = "{} operate on the selected objects (back → front) → {ids}"; vec![ - cmd!("object.pathfinder.unite", "Unite", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Unite)), - cmd!("object.pathfinder.minusFront", "Minus Front", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( - s, - PathfinderOp::MinusFront - )), - cmd!("object.pathfinder.intersect", "Intersect", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( - s, - PathfinderOp::Intersect - )), - cmd!("object.pathfinder.exclude", "Exclude", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Exclude)), - cmd!("object.pathfinder.divide", "Divide", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Divide)), - cmd!("object.pathfinder.trim", "Trim", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Trim)), - cmd!("object.pathfinder.merge", "Merge", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Merge)), - cmd!("object.pathfinder.crop", "Crop", ["Window", "Pathfinder"], None, PF, has_multi, |s, _| run_pf(s, PathfinderOp::Crop)), - cmd!("object.pathfinder.outline", "Outline", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf(s, PathfinderOp::Outline)), - cmd!("object.pathfinder.minusBack", "Minus Back", ["Window", "Pathfinder"], None, PF, has_selection, |s, _| run_pf( - s, - PathfinderOp::MinusBack - )), + cmd!( + "object.pathfinder.unite", + "Unite", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Unite) + ), + cmd!( + "object.pathfinder.minusFront", + "Minus Front", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::MinusFront) + ), + cmd!( + "object.pathfinder.intersect", + "Intersect", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Intersect) + ), + cmd!( + "object.pathfinder.exclude", + "Exclude", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Exclude) + ), + cmd!( + "object.pathfinder.divide", + "Divide", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Divide) + ), + cmd!( + "object.pathfinder.trim", + "Trim", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Trim) + ), + cmd!( + "object.pathfinder.merge", + "Merge", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Merge) + ), + cmd!( + "object.pathfinder.crop", + "Crop", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_multi, + |s, _| run_pf(s, PathfinderOp::Crop) + ), + cmd!( + "object.pathfinder.outline", + "Outline", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::Outline) + ), + cmd!( + "object.pathfinder.minusBack", + "Minus Back", + ["Window", "Pathfinder"], + None, + "{} operate on the selected objects (back → front) → {ids}", + has_selection, + |s, _| run_pf(s, PathfinderOp::MinusBack) + ), cmd!( "object.path.outlineStroke", "Outline Stroke", @@ -239,7 +309,8 @@ fn pf_label(op: PathfinderOp) -> &'static str { fn run_pf(s: &mut Session, op: PathfinderOp) -> Result { let roots = selected_roots(s)?; let Some(&top) = roots.last() else { return Err(EngineError::Other("nothing selected".into())) }; - let shape_mode = matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); + let shape_mode = + matches!(op, PathfinderOp::Unite | PathfinderOp::MinusFront | PathfinderOp::Intersect | PathfinderOp::Exclude | PathfinderOp::MinusBack); let label = pf_label(op); let ids = s.edit(label, |d, sel| { // Build the stack (back → front) with a style per entry. @@ -432,7 +503,8 @@ fn outline_stroke(s: &mut Session, _: &Value) -> Result { continue; } let mut stroke_node = shape_node(d, outline, Some(&l)); - stroke_node.appearance = Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] }; + stroke_node.appearance = + Appearance { items: vec![AppearanceItem::Fill(drawcraft_doc::appearance::FillLayer::new(st.paint.clone()))], effects: vec![] }; let fill = l.appearance.fill().filter(|f| f.visible && !f.paint.is_none()).cloned(); let new = if let Some(f) = fill { let mut fill_node = l.clone(); @@ -598,9 +670,7 @@ fn clean_up(s: &mut Session, p: &Value) -> Result { if *guide || *clipping || (parent_clip && first) { return; } - if stray && path.anchor_count() <= 1 { - v.push(n.id); - } else if unpainted && !parent_compound && !is_painted(&n.appearance) { + if (stray && path.anchor_count() <= 1) || (unpainted && !parent_compound && !is_painted(&n.appearance)) { v.push(n.id); } } @@ -707,4 +777,3 @@ fn divide_objects_below(s: &mut Session, _: &Value) -> Result { })?; Ok(ids_json(&ids)) } - diff --git a/crates/engine/src/cmd/typecmd.rs b/crates/engine/src/cmd/typecmd.rs new file mode 100644 index 00000000..c4f55fd3 --- /dev/null +++ b/crates/engine/src/cmd/typecmd.rs @@ -0,0 +1,209 @@ +//! Type menu: Create Outlines, and the Character/Paragraph panel setters (`text.setText`, +//! `text.setStyle`). + +use std::sync::Arc; + +use drawcraft_doc::{Document, Justify, Node, NodeId, NodeKind, TextObject}; +use drawcraft_geom::PathData; +use serde_json::{Value, json}; + +use super::edit::selected_roots; +use super::pathops::{num_param, shape_node, text_style_appearance}; +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + "type.createOutlines", + "Create Outlines", + ["Type"], + Some("Cmd+Shift+O"), + "{} convert the selected text to groups of compound paths (one per glyph) → {ids}", + has_selection, + create_outlines + ), + cmd!( + "text.setText", + "Set Text", + [], + None, + "{id?|ids?, text} replace the contents of text objects (keeps the first run's style)", + has_doc, + set_text + ), + cmd!( + "text.setStyle", + "Character", + [], + None, + "{ids?|id?, font?, style?, size?: pt, leading?: pt|\"auto\", tracking?: 1/1000 em, justify?: \"left\"|\"center\"|\"right\"|\"justifyAll\", fill?: colour}", + has_doc, + set_style + ), + ] +} + +/// Recompute the layout bounds cache after a text edit. +pub(crate) fn refresh_bounds(t: &mut TextObject) { + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + t.cached_bounds = Some(lay.bounds); +} + +/// Text objects among `ids` and their descendants. +fn text_ids(d: &Document, ids: &[NodeId]) -> Vec { + let mut out = vec![]; + for id in ids { + if let Some(n) = d.node(*id) { + n.walk(&mut |c| { + if matches!(c.kind, NodeKind::Text(_)) && !out.contains(&c.id) { + out.push(c.id); + } + }); + } + } + out +} + +fn create_outlines(s: &mut Session, _: &Value) -> Result { + let roots = selected_roots(s)?; + let ids = s.edit("Create Outlines", |d, sel| { + let texts = text_ids(d, &roots); + if texts.is_empty() { + return Err(EngineError::Other("Create Outlines: select text objects".into())); + } + let mut new_sel: Vec = roots.iter().copied().filter(|r| !texts.contains(r)).collect(); + let mut out = vec![]; + for tid in texts { + let Some(n) = d.node(tid).cloned() else { continue }; + let NodeKind::Text(t) = &n.kind else { continue }; + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let mut children = vec![]; + for g in &lay.glyphs { + let path = PathData::from_bezpath(&g.outline).transformed(t.xf); + if path.is_empty() { + continue; + } + let st = t.runs.get(g.run).map(|r| r.style.clone()).unwrap_or_else(|| t.first_style()); + let mut node = shape_node(d, path, None); + node.appearance = text_style_appearance(&st.fill, &st.stroke, st.stroke_width); + children.push(Arc::new(node)); + } + let (par, idx, _) = d.position(tid).ok_or(EngineError::NoNode(tid))?; + d.remove(tid)?; + if children.is_empty() { + continue; + } + let gid = d.alloc_id(); + let mut g = Node::group(gid, children); + g.opacity = n.opacity; + g.blend = n.blend; + g.name = n.name.clone(); + d.insert(par, idx, g)?; + out.push(gid); + if roots.contains(&tid) { + new_sel.push(gid); + } + } + sel.set(new_sel); + Ok(out) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result> { + let ids = targets(s, p)?; + let t = text_ids(&s.doc()?.doc, &ids); + if t.is_empty() { + return Err(bad(cmd, "no text objects selected")); + } + Ok(t) +} + +fn set_text(s: &mut Session, p: &Value) -> Result { + let text = str_param(p, "text").ok_or_else(|| bad("text.setText", "missing `text`"))?.to_string(); + let ids = text_targets(s, p, "text.setText")?; + s.edit("Typing", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let style = t.first_style(); + t.runs = vec![drawcraft_doc::TextRun { text: text.clone(), style }]; + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} + +fn justify_param(v: &str) -> Option { + Some(match v.to_ascii_lowercase().as_str() { + "left" => Justify::Left, + "center" => Justify::Center, + "right" => Justify::Right, + "justifyleft" => Justify::JustifyLeft, + "justifycenter" => Justify::JustifyCenter, + "justifyright" => Justify::JustifyRight, + "justifyall" | "justify" => Justify::JustifyAll, + _ => return None, + }) +} + +fn set_style(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setStyle"; + let font = str_param(p, "font").map(str::to_string); + let style = str_param(p, "style").map(str::to_string); + let size = num_param(p, "size"); + if size.is_some_and(|v| v <= 0.0) { + return Err(bad(C, "size must be positive")); + } + let leading = match p.get("leading") { + None | Some(Value::Null) => None, + Some(Value::String(a)) if a.eq_ignore_ascii_case("auto") => Some(None), + Some(_) => Some(Some(num_param(p, "leading").ok_or_else(|| bad(C, "leading must be a number or \"auto\""))?.clamp(0.1, 5000.0))), + }; + let tracking = num_param(p, "tracking"); + let justify = match str_param(p, "justify") { + Some(j) => Some(justify_param(j).ok_or_else(|| bad(C, "justify must be left|center|right|justifyAll"))?), + None => None, + }; + let fill = match p.get("fill") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if n.eq_ignore_ascii_case("none") => Some(drawcraft_color::Paint::None), + Some(v) => Some(drawcraft_color::Paint::solid(color_value(v).ok_or_else(|| bad(C, "bad fill colour"))?)), + }; + if font.is_none() && style.is_none() && size.is_none() && leading.is_none() && tracking.is_none() && justify.is_none() && fill.is_none() { + return Err(bad(C, "nothing to change")); + } + let ids = text_targets(s, p, C)?; + s.edit("Character", |d, _| { + for id in &ids { + let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + for r in &mut t.runs { + let st = &mut r.style; + if let Some(f) = &font { + st.font_family = f.clone(); + } + if let Some(f) = &style { + st.font_style = f.clone(); + } + if let Some(v) = size { + st.size = v.clamp(0.1, 1296.0); + } + if let Some(l) = leading { + st.leading = l; + } + if let Some(v) = tracking { + st.tracking = v.clamp(-1000.0, 10000.0); + } + if let Some(f) = &fill { + st.fill = f.clone(); + } + } + if let Some(j) = justify { + t.para.justify = j; + } + refresh_bounds(t); + } + Ok(()) + })?; + Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::>() })) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a690425d..f91daaea 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -162,6 +162,7 @@ pub struct Session { /// Executed commands (for actions and debugging). pub journal: Vec<(String, Value)>, pub(crate) tool: Box, + pub(crate) last_view: ViewInfo, untitled_counter: u32, } @@ -182,6 +183,7 @@ impl Session { clipboard: vec![], journal: vec![], tool: drawcraft_tools::create("selection"), + last_view: ViewInfo::default(), untitled_counter: 0, } } @@ -351,3 +353,5 @@ impl Session { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_pathops; diff --git a/crates/engine/src/tests_pathops.rs b/crates/engine/src/tests_pathops.rs new file mode 100644 index 00000000..8c29da50 --- /dev/null +++ b/crates/engine/src/tests_pathops.rs @@ -0,0 +1,405 @@ +//! Tests for Pathfinder, Object → Path and Type commands. + +use drawcraft_color::{Color, Paint}; +use drawcraft_doc::{Appearance, NodeKind}; +use drawcraft_geom::{FillRule, PathData, Rect}; +use serde_json::{Value, json}; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap(); + s +} + +fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64) -> NodeId { + let r = s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": w, "height": h})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +fn ids(v: &Value) -> Vec { + v["ids"].as_array().unwrap().iter().map(|x| NodeId(x.as_u64().unwrap())).collect() +} + +fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node { + s.doc().unwrap().doc.node(id).cloned().unwrap() +} + +fn path_of(s: &Session, id: NodeId) -> (PathData, FillRule) { + let n = node(s, id); + match &n.kind { + NodeKind::Path { path, rule, .. } => (path.clone(), *rule), + NodeKind::Compound { children, rule } => { + (PathData::new(children.iter().flat_map(|c| c.path_data().unwrap().subpaths.clone()).collect()), *rule) + } + _ => panic!("not a path"), + } +} + +fn area_of(s: &Session, id: NodeId) -> f64 { + let (p, r) = path_of(s, id); + drawcraft_pathops::area(&p, r) +} + +fn bounds(s: &Session, id: NodeId) -> Rect { + node(s, id).geometric_bounds().unwrap() +} + +fn close(a: Rect, b: Rect, tol: f64) -> bool { + (a.x0 - b.x0).abs() < tol && (a.y0 - b.y0).abs() < tol && (a.x1 - b.x1).abs() < tol && (a.y1 - b.y1).abs() < tol +} + +fn set_fill(s: &mut Session, id: NodeId, c: Color) { + s.edit("t", |d, _| { + d.node_mut(id).unwrap().appearance.set_fill(Paint::solid(c)); + Ok(()) + }) + .unwrap(); +} + +fn two_rects(s: &mut Session) -> (NodeId, NodeId) { + let a = rect(s, 0.0, 0.0, 100.0, 100.0); + let b = rect(s, 50.0, 50.0, 100.0, 100.0); + set_fill(s, a, Color::rgb(1.0, 0.0, 0.0)); + set_fill(s, b, Color::rgb(0.0, 0.0, 1.0)); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + (a, b) +} + +#[test] +fn unite_two_rects() { + let mut s = session(); + let (a, b) = two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap()); + assert_eq!(r.len(), 1); + assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_none()); + assert!(matches!(node(&s, r[0]).kind, NodeKind::Path { .. })); + assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 150.0, 150.0), 1e-3)); + assert!((area_of(&s, r[0]) - (10000.0 + 10000.0 - 2500.0)).abs() < 1.0); + // Top object's style. + assert_eq!(node(&s, r[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); + assert_eq!(s.doc().unwrap().selection.objects, r); +} + +#[test] +fn minus_front_keeps_back_style() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.minusFront", &json!({})).unwrap()); + assert!((area_of(&s, r[0]) - 7500.0).abs() < 1.0); + assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 100.0, 100.0), 1e-3)); + assert_eq!(node(&s, r[0]).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); +} + +#[test] +fn minus_back_keeps_front_style() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.minusBack", &json!({})).unwrap()); + assert!((area_of(&s, r[0]) - 7500.0).abs() < 1.0); + assert!(close(bounds(&s, r[0]), Rect::new(50.0, 50.0, 150.0, 150.0), 1e-3)); + assert_eq!(node(&s, r[0]).appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 0.0, 1.0))); +} + +#[test] +fn intersect_rects() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.intersect", &json!({})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(50.0, 50.0, 100.0, 100.0), 1e-3)); + assert!((area_of(&s, r[0]) - 2500.0).abs() < 1.0); +} + +#[test] +fn intersect_disjoint_is_error_and_no_change() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 50.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let undo_len = s.doc().unwrap().history.undo.len(); + assert!(s.execute("object.pathfinder.intersect", &json!({})).is_err()); + assert!(s.doc().unwrap().doc.node(a).is_some()); + assert_eq!(s.doc().unwrap().history.undo.len(), undo_len); +} + +#[test] +fn exclude_makes_compound() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.exclude", &json!({})).unwrap()); + assert!((area_of(&s, r[0]) - 15000.0).abs() < 1.0); +} + +#[test] +fn divide_produces_group_of_faces() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.divide", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert!(matches!(g.kind, NodeKind::Group { .. })); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 3); + let total: f64 = ch.iter().map(|c| area_of(&s, c.id)).sum(); + assert!((total - 17500.0).abs() < 1.0); + // The overlap face takes the top (blue) style. + let blue = Paint::solid(Color::rgb(0.0, 0.0, 1.0)); + assert_eq!(ch.iter().filter(|c| c.appearance.fill_paint() == blue).count(), 2); +} + +#[test] +fn trim_and_merge() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.trim", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(g.children().unwrap().len(), 2); + s.execute("edit.undo", &json!({})).unwrap(); + // Same colour → merge joins them. + let sel = s.doc().unwrap().selection.objects.clone(); + set_fill(&mut s, sel[0], Color::rgb(0.0, 0.0, 1.0)); + s.execute("select.set", &json!({"ids": [sel[0].0, sel[1].0]})).unwrap(); + let r = ids(&s.execute("object.pathfinder.merge", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert_eq!(g.children().unwrap().len(), 1); +} + +#[test] +fn crop_and_outline() { + let mut s = session(); + two_rects(&mut s); + let r = ids(&s.execute("object.pathfinder.crop", &json!({})).unwrap()); + let g = node(&s, r[0]); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 1); + assert!((area_of(&s, ch[0].id) - 2500.0).abs() < 1.0); + s.execute("edit.undo", &json!({})).unwrap(); + let r = ids(&s.execute("object.pathfinder.outline", &json!({})).unwrap()); + let g = node(&s, r[0]); + assert!(g.children().unwrap().len() >= 4); + assert!(g.children().unwrap().iter().all(|c| c.appearance.stroke_width() > 0.0)); +} + +#[test] +fn pathfinder_undo_restores() { + let mut s = session(); + let (a, b) = two_rects(&mut s); + let before = s.doc().unwrap().doc.clone(); + s.execute("object.pathfinder.unite", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(*s.doc().unwrap().doc, *before); + assert!(s.doc().unwrap().selection.contains(a) && s.doc().unwrap().selection.contains(b)); +} + +#[test] +fn unite_with_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let b = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap(); + let g = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let c = rect(&mut s, 5.0, 5.0, 20.0, 10.0); + s.execute("select.set", &json!({"ids": [g, c.0]})).unwrap(); + let r = ids(&s.execute("object.pathfinder.unite", &json!({})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 30.0, 15.0), 1e-3)); + assert!((area_of(&s, r[0]) - (100.0 + 100.0 + 200.0 - 25.0 - 25.0)).abs() < 1.0); +} + +#[test] +fn offset_rect_grows_bounds() { + let mut s = session(); + let a = rect(&mut s, 100.0, 100.0, 50.0, 40.0); + let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": 10, "joins": "miter", "miterLimit": 4})).unwrap()); + assert_eq!(r.len(), 1); + assert!(s.doc().unwrap().doc.node(a).is_some(), "original is kept"); + assert!(close(bounds(&s, r[0]), Rect::new(90.0, 90.0, 160.0, 150.0), 1e-2), "{:?}", bounds(&s, r[0])); + assert_eq!(s.doc().unwrap().selection.objects, r); + // Above the original. + let d = &s.doc().unwrap().doc; + assert!(d.index_path(r[0]).unwrap() > d.index_path(a).unwrap()); + // Negative insets; string params are accepted. + s.execute("select.set", &json!({"ids": [a.0]})).unwrap(); + let r = ids(&s.execute("object.path.offsetPath", &json!({"offset": "-5 pt", "joins": "round"})).unwrap()); + assert!(close(bounds(&s, r[0]), Rect::new(105.0, 105.0, 145.0, 135.0), 1e-2)); +} + +#[test] +fn outline_stroke_of_line() { + let mut s = session(); + let r = s.execute("shape.line", &json!({"x1": 0, "y1": 50, "x2": 100, "y2": 50})).unwrap(); + let l = NodeId(r["id"].as_u64().unwrap()); + s.edit("t", |d, _| { + d.node_mut(l).unwrap().appearance = Appearance::basic(Paint::None, Paint::solid(Color::rgb(1.0, 0.0, 0.0)), 10.0); + Ok(()) + }) + .unwrap(); + let r = ids(&s.execute("object.path.outlineStroke", &json!({})).unwrap()); + let n = node(&s, r[0]); + assert!(close(n.geometric_bounds().unwrap(), Rect::new(0.0, 45.0, 100.0, 55.0), 1e-2)); + assert_eq!(n.appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(n.appearance.stroke().is_none()); + assert!((area_of(&s, r[0]) - 1000.0).abs() < 1.0); +} + +#[test] +fn outline_stroke_with_fill_makes_group() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + s.edit("t", |d, _| { + d.node_mut(a).unwrap().appearance.stroke_mut().unwrap().width = 4.0; + Ok(()) + }) + .unwrap(); + let r = ids(&s.execute("object.path.outlineStroke", &json!({})).unwrap()); + let g = node(&s, r[0]); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 2); + assert_eq!(ch[0].appearance.fill_paint(), Paint::solid(Color::WHITE)); + assert_eq!(ch[1].appearance.fill_paint(), Paint::solid(Color::BLACK)); + assert!(close(ch[1].geometric_bounds().unwrap(), Rect::new(-2.0, -2.0, 102.0, 102.0), 1e-2)); +} + +#[test] +fn simplify_dense_polyline() { + let mut s = session(); + let anchors: Vec = (0..=100) + .map(|i| { + let x = i as f64 * 3.0; + json!({"x": x, "y": 100.0 + 40.0 * (x / 50.0).sin()}) + }) + .collect(); + let r = s.execute("path.create", &json!({"anchors": anchors})).unwrap(); + let id = NodeId(r["id"].as_u64().unwrap()); + let before = path_of(&s, id).0.anchor_count(); + let out = s.execute("object.path.simplify", &json!({"tolerance": 1.0})).unwrap(); + let after = path_of(&s, id).0.anchor_count(); + assert_eq!(before, 101); + assert!(after < before / 4, "{after}"); + assert_eq!(out["after"].as_u64().unwrap() as usize, after); + assert!(close(bounds(&s, id), Rect::new(0.0, 60.0, 300.0, 140.0), 2.0)); +} + +#[test] +fn add_anchor_points_doubles() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + s.execute("object.path.addAnchorPoints", &json!({})).unwrap(); + assert_eq!(path_of(&s, a).0.anchor_count(), 8); + assert!(close(bounds(&s, a), Rect::new(0.0, 0.0, 10.0, 10.0), 1e-9)); +} + +#[test] +fn split_into_grid_cells() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 110.0, 50.0); + let r = ids(&s.execute("object.path.splitIntoGrid", &json!({"rows": 2, "columns": 3, "gutter": 10})).unwrap()); + assert_eq!(r.len(), 6); + assert!(close(bounds(&s, r[0]), Rect::new(0.0, 0.0, 30.0, 20.0), 1e-9)); + assert!(s.execute("object.path.splitIntoGrid", &json!({"rows": 0})).is_err()); +} + +#[test] +fn clean_up_removes_junk() { + let mut s = session(); + let keep = rect(&mut s, 0.0, 0.0, 10.0, 10.0); + let unpainted = rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.edit("t", |d, _| { + d.node_mut(unpainted).unwrap().appearance = Appearance::basic(Paint::None, Paint::None, 1.0); + Ok(()) + }) + .unwrap(); + let stray = NodeId(s.execute("path.create", &json!({"anchors": [{"x": 5, "y": 5}]})).unwrap()["id"].as_u64().unwrap()); + let empty = NodeId(s.execute("text.create", &json!({"x": 10, "y": 10, "text": ""})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("object.path.cleanUp", &json!({})).unwrap(); + assert_eq!(r["removed"], 3); + let d = &s.doc().unwrap().doc; + assert!(d.node(keep).is_some()); + assert!(d.node(unpainted).is_none() && d.node(stray).is_none() && d.node(empty).is_none()); +} + +#[test] +fn divide_objects_below_cuts() { + let mut s = session(); + let a = rect(&mut s, 0.0, 0.0, 100.0, 100.0); + let cutter = rect(&mut s, 50.0, -10.0, 100.0, 120.0); + s.execute("select.set", &json!({"ids": [cutter.0]})).unwrap(); + let r = ids(&s.execute("object.path.divideObjectsBelow", &json!({})).unwrap()); + assert_eq!(r.len(), 2); + let d = &s.doc().unwrap().doc; + assert!(d.node(a).is_none() && d.node(cutter).is_none()); + let total: f64 = r.iter().map(|id| area_of(&s, *id)).sum(); + assert!((total - 10000.0).abs() < 1.0); +} + +fn text(s: &mut Session, t: &str, size: f64) -> NodeId { + let r = s.execute("text.create", &json!({"x": 10, "y": 50, "text": t, "size": size})).unwrap(); + NodeId(r["id"].as_u64().unwrap()) +} + +#[test] +fn create_outlines_matches_text_bounds() { + let mut s = session(); + let t = text(&mut s, "Hello bo", 36.0); + let tb = bounds(&s, t); + let r = ids(&s.execute("type.createOutlines", &json!({})).unwrap()); + assert!(s.doc().unwrap().doc.node(t).is_none()); + let g = node(&s, r[0]); + let ch = g.children().unwrap(); + assert_eq!(ch.len(), 7, "one per visible glyph"); + assert!(ch.iter().any(|c| matches!(c.kind, NodeKind::Compound { .. })), "o/b have holes"); + assert!(ch.iter().all(|c| c.appearance.fill_paint() == Paint::solid(Color::BLACK))); + let gb = g.geometric_bounds().unwrap(); + assert!(gb.x0 >= tb.x0 - 2.0 && gb.x1 <= tb.x1 + 2.0 && gb.y0 >= tb.y0 - 2.0 && gb.y1 <= tb.y1 + 2.0, "{gb:?} vs {tb:?}"); + assert!(gb.width() > tb.width() * 0.8); + assert_eq!(s.doc().unwrap().selection.objects, r); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc.node(t).is_some()); +} + +#[test] +fn set_style_size_changes_bounds() { + let mut s = session(); + let t = text(&mut s, "Hello", 12.0); + let b0 = bounds(&s, t); + s.execute("text.setStyle", &json!({"size": 24, "justify": "center", "fill": "#ff0000", "leading": "auto"})).unwrap(); + let b1 = bounds(&s, t); + assert!(b1.width() > b0.width() * 1.8, "{b0:?} → {b1:?}"); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!() }; + assert_eq!(tx.first_style().size, 24.0); + assert_eq!(tx.para.justify, drawcraft_doc::Justify::Center); + assert_eq!(tx.first_style().fill, Paint::solid(Color::rgb(1.0, 0.0, 0.0))); + assert!(s.execute("text.setStyle", &json!({"justify": "diagonal"})).is_err()); + assert!(s.execute("text.setStyle", &json!({})).is_err()); +} + +#[test] +fn set_text_updates_bounds() { + let mut s = session(); + let t = text(&mut s, "Hi", 24.0); + let b0 = bounds(&s, t); + s.execute("text.setText", &json!({"id": t.0, "text": "Hello there"})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!() }; + assert_eq!(tx.plain_text(), "Hello there"); + assert!(bounds(&s, t).width() > b0.width() * 2.0); + assert!(s.execute("text.setText", &json!({"id": t.0})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + let NodeKind::Text(tx) = node(&s, t).kind else { panic!() }; + assert_eq!(tx.plain_text(), "Hi"); +} + +#[test] +fn commands_reject_bad_input() { + let mut s = session(); + two_rects(&mut s); + for (c, p) in [ + ("object.path.offsetPath", json!({"offset": "abc"})), + ("object.path.offsetPath", json!({"offset": 1e300})), + ("object.path.simplify", json!({"tolerance": -5})), + ("object.path.splitIntoGrid", json!({"rows": 1e9, "columns": -1})), + ("text.setStyle", json!({"size": -3})), + ("type.createOutlines", json!({})), + ("object.pathfinder.divide", json!({"junk": [1, 2]})), + ] { + let _ = s.execute(c, &p); + } +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index f4a834fe..4ee97bb1 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -66,6 +66,7 @@ impl Session { /// Feed a pointer event to the active tool. Returns requests for the UI. pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result> { + self.last_view = view; let acts = self.with_cx(view, |t, cx| t.pointer(cx, ev)); self.apply_actions(acts) } @@ -75,6 +76,16 @@ impl Session { self.apply_actions(acts) } + /// Typed text for the active tool (Type tool). + pub fn tool_text(&mut self, text: &str, view: ViewInfo) -> Result> { + let acts = self.with_cx(view, |t, cx| t.text_input(cx, text)); + self.apply_actions(acts) + } + + pub fn tool_wants_text(&self) -> bool { + self.tool.wants_text() + } + pub fn tool_busy(&self) -> bool { self.tool.busy() } @@ -112,6 +123,10 @@ impl Session { } Action::Dialog(k, p) => ui.push(UiRequest::Dialog(k, p)), Action::SwitchTool(t) => ui.push(UiRequest::SwitchTool(t)), + Action::Notify(what) => { + let view = self.last_view; + self.with_cx(view, |t, cx| t.notify(cx, &what)); + } } } Ok(ui) diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs new file mode 100644 index 00000000..5ba41e63 --- /dev/null +++ b/crates/tools/src/guides.rs @@ -0,0 +1,161 @@ +//! Smart Guides: snapping to anchors, object bounds (edges/centres), artboards, with the magenta +//! construction lines and labels Illustrator users expect. + +use drawcraft_doc::{Document, NodeId, NodeKind}; +use drawcraft_geom::{Point, Rect, Vec2}; + +use crate::{Overlay, ToolContext}; + +pub const MAGENTA: [u8; 3] = [0xff, 0x3d, 0xfc]; + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Anchor, + Center, + Edge, + Artboard, +} + +impl Kind { + fn label(self) -> &'static str { + match self { + Kind::Anchor => "anchor", + Kind::Center => "center", + Kind::Edge => "path", + Kind::Artboard => "artboard", + } + } +} + +/// Snap targets gathered from the document (excluding the objects being edited). +#[derive(Default)] +pub struct Targets { + points: Vec<(Point, Kind)>, + xs: Vec<(f64, Point, Kind)>, + ys: Vec<(f64, Point, Kind)>, +} + +impl Targets { + pub fn collect(doc: &Document, exclude: &[NodeId], visible: Option) -> Self { + let mut t = Targets::default(); + let excluded = |id: NodeId| exclude.iter().any(|e| doc.ancestry(id).is_some_and(|a| a.contains(e))); + let add_rect = |t: &mut Targets, r: Rect, kind: Kind| { + let c = r.center(); + for p in [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)] { + t.xs.push((p.x, p, kind)); + t.ys.push((p.y, p, kind)); + } + t.points.push((c, Kind::Center)); + t.xs.push((c.x, c, Kind::Center)); + t.ys.push((c.y, c, Kind::Center)); + }; + for ab in &doc.artboards { + add_rect(&mut t, ab.rect, Kind::Artboard); + } + let mut budget = 20_000usize; + doc.walk(|n| { + if budget == 0 || n.is_container() || !n.visible || excluded(n.id) { + return; + } + let Some(b) = n.geometric_bounds() else { return }; + if let Some(v) = visible + && b.intersect(v).area() <= 0.0 + && !v.contains(b.center()) + { + return; + } + if let NodeKind::Path { path, .. } = &n.kind { + for (_, _, a) in path.anchors() { + t.points.push((a.p, Kind::Anchor)); + budget = budget.saturating_sub(1); + } + } + add_rect(&mut t, b, Kind::Edge); + }); + t + } + + /// Snap a single point. Returns the snapped point and guide overlays. + pub fn snap_point(&self, p: Point, tol: f64) -> (Point, Vec) { + if let Some((q, k)) = self.points.iter().filter(|(q, _)| q.distance(p) <= tol).min_by(|a, b| a.0.distance(p).total_cmp(&b.0.distance(p))) { + return (*q, vec![Overlay::Label { p: *q, text: k.label().into(), color: MAGENTA }]); + } + let mut out = p; + let mut ov = vec![]; + if let Some((x, from, _)) = self.xs.iter().filter(|(x, _, _)| (x - p.x).abs() <= tol).min_by(|a, b| (a.0 - p.x).abs().total_cmp(&(b.0 - p.x).abs())) { + out.x = *x; + ov.push(Overlay::Line { a: *from, b: Point::new(*x, p.y), color: MAGENTA, dashed: false }); + } + if let Some((y, from, _)) = self.ys.iter().filter(|(y, _, _)| (y - p.y).abs() <= tol).min_by(|a, b| (a.0 - p.y).abs().total_cmp(&(b.0 - p.y).abs())) { + out.y = *y; + ov.push(Overlay::Line { a: *from, b: Point::new(p.x, *y), color: MAGENTA, dashed: false }); + } + if !ov.is_empty() { + ov.push(Overlay::Label { p: out, text: "align".into(), color: MAGENTA }); + } + (out, ov) + } + + /// Snap a moving rectangle (selection bounds after a move by `d`): tries its corners/edges/centre. + pub fn snap_rect(&self, r: Rect, tol: f64) -> (Vec2, Vec) { + let c = r.center(); + let xs = [r.x0, c.x, r.x1]; + let ys = [r.y0, c.y, r.y1]; + let best_x = xs.iter().flat_map(|x| self.xs.iter().map(move |(t, from, _)| (t - x, *from, *x))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); + let best_y = ys.iter().flat_map(|y| self.ys.iter().map(move |(t, from, _)| (t - y, *from, *y))).filter(|(d, _, _)| d.abs() <= tol).min_by(|a, b| a.0.abs().total_cmp(&b.0.abs())); + let mut d = Vec2::ZERO; + let mut ov = vec![]; + if let Some((dx, from, x)) = best_x { + d.x = dx; + let x = x + dx; + let (y0, y1) = (from.y.min(r.y0), from.y.max(r.y1)); + ov.push(Overlay::Line { a: Point::new(x, y0), b: Point::new(x, y1), color: MAGENTA, dashed: false }); + } + if let Some((dy, from, y)) = best_y { + d.y = dy; + let y = y + dy; + let (x0, x1) = (from.x.min(r.x0), from.x.max(r.x1)); + ov.push(Overlay::Line { a: Point::new(x0, y), b: Point::new(x1, y), color: MAGENTA, dashed: false }); + } + (d, ov) + } +} + +/// Snap `p` for a drawing tool when smart guides (or grid snapping) are on. +pub fn snap_draw(cx: &ToolContext, p: Point, exclude: &[NodeId]) -> (Point, Vec) { + if cx.snap_to_grid { + let s = cx.doc.grid.spacing / cx.doc.grid.subdivisions.max(1) as f64; + return (drawcraft_geom::snap::snap_point_to_grid(p, s), vec![]); + } + if !cx.smart_guides { + return (p, vec![]); + } + Targets::collect(cx.doc, exclude, None).snap_point(p, cx.tol(5.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::testutil::*; + + #[test] + fn snaps_to_anchor_and_alignment() { + let (d, _) = doc_with_rect(); + let t = Targets::collect(&d, &[], None); + let (p, ov) = t.snap_point(Point::new(102.0, 99.0), 4.0); + assert_eq!(p, Point::new(100.0, 100.0)); + assert!(matches!(&ov[0], Overlay::Label { text, .. } if text == "anchor")); + let (p, _) = t.snap_point(Point::new(301.0, 199.0), 4.0); + assert_eq!(p.y, 200.0); + } + + #[test] + fn rect_snap_aligns_edges() { + let (d, id) = doc_with_rect(); + let t = Targets::collect(&d, &[id], None); + // Artboard is 0..500; a rect at x0=2 snaps to the artboard's left edge. + let (dv, ov) = t.snap_rect(Rect::new(2.0, 50.0, 52.0, 90.0), 4.0); + assert_eq!(dv.x, -2.0); + assert!(!ov.is_empty()); + } +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 41b9e762..f5bcab4f 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -10,9 +10,11 @@ pub mod bbox; pub mod catalog; pub mod direct; +pub mod guides; pub mod pen; pub mod select; pub mod shape; +pub mod text; use drawcraft_color::Paint; use drawcraft_doc::{Document, NodeId, Selection}; @@ -112,6 +114,8 @@ pub enum Action { Dialog(String, Value), /// Switch to another tool (e.g. after placing a text frame). SwitchTool(String), + /// Call the tool's `notify` after the preceding actions ran (e.g. to pick up a created object). + Notify(String), } /// Paint defaults for new art (the fill/stroke proxy). @@ -214,6 +218,14 @@ pub trait Tool: Send { fn busy(&self) -> bool { false } + /// Does the tool want typed text (Type tool editing)? Single-key shortcuts are suppressed. + fn wants_text(&self) -> bool { + false + } + fn text_input(&mut self, _cx: &ToolContext, _s: &str) -> Vec { + vec![] + } + fn notify(&mut self, _cx: &ToolContext, _what: &str) {} /// Called when the user switches away (finish pending work). fn deactivate(&mut self, _cx: &ToolContext) -> Vec { vec![] @@ -227,6 +239,7 @@ pub fn create(id: &str) -> Box { "directSelection" => Box::new(direct::DirectSelectionTool::new(false)), "groupSelection" => Box::new(direct::DirectSelectionTool::new(true)), "pen" => Box::new(pen::PenTool::default()), + "type" => Box::new(text::TypeTool::default()), "rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)), other => Box::new(NoopTool(tool_info(other).map(|t| t.id).unwrap_or("selection"))), } diff --git a/crates/tools/src/pen.rs b/crates/tools/src/pen.rs index ac9b5a09..7ecbf846 100644 --- a/crates/tools/src/pen.rs +++ b/crates/tools/src/pen.rs @@ -44,7 +44,8 @@ impl Tool for PenTool { self.drag.is_some() } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - let mut p = ev.pos; + let exclude: Vec = if self.drawing { cx.selection.objects.clone() } else { vec![] }; + let (mut p, _) = if matches!(ev.kind, PointerKind::Down) { crate::guides::snap_draw(cx, ev.pos, &exclude) } else { (ev.pos, vec![]) }; let tol = cx.tol(5.0); let active = if self.drawing { active_path(cx) } else { None }; if self.drawing && active.is_none() && ev.kind == PointerKind::Down { diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index bd254358..76000ca1 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -38,6 +38,9 @@ enum State { pub struct SelectionTool { state: State, measure: Option<(Point, String)>, + guides: Vec, + targets: Option, + start_bounds: Option, } pub fn matrix_json(a: Affine) -> Value { @@ -131,7 +134,18 @@ impl Tool for SelectionTool { } out.push(Action::Begin(if m.alt { "Copy".into() } else { "Move".into() })); } - let d = move_delta(start, p, m.shift); + let mut d = move_delta(start, p, m.shift); + if !began { + let sel_ids: Vec = cx.selection.objects.clone(); + self.start_bounds = cx.doc.bounds_of(&sel_ids, false); + self.targets = cx.smart_guides.then(|| crate::guides::Targets::collect(cx.doc, &sel_ids, None)); + } + self.guides.clear(); + if let (Some(t), Some(b)) = (&self.targets, self.start_bounds) { + let (adj, ov) = t.snap_rect(b + d, cx.tol(5.0)); + d += adj; + self.guides = ov; + } self.state = State::Moving { start, began: true }; self.measure = Some((p, format!("dX: {:.2} pt\ndY: {:.2} pt", d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); @@ -155,6 +169,8 @@ impl Tool for SelectionTool { (PointerKind::Up, State::Moving { began, .. }) => { self.state = State::Idle; self.measure = None; + self.guides.clear(); + self.targets = None; if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { @@ -184,6 +200,7 @@ impl Tool for SelectionTool { if let State::Marquee { start, cur, .. } = self.state { o.push(Overlay::Marquee(Rect::from_points(start, cur))); } + o.extend(self.guides.iter().cloned()); if let Some((p, t)) = &self.measure { o.push(Overlay::Measure { p: *p, text: t.clone() }); } diff --git a/crates/tools/src/shape.rs b/crates/tools/src/shape.rs index e03df7e3..bab7e2c2 100644 --- a/crates/tools/src/shape.rs +++ b/crates/tools/src/shape.rs @@ -20,6 +20,7 @@ pub struct ShapeTool { pub corner_radius: f64, /// Star inner radius as a fraction of the outer radius. pub star_ratio: f64, + guides: Vec, } impl ShapeTool { @@ -32,7 +33,7 @@ impl ShapeTool { "lineSegment" => "lineSegment", _ => "rectangle", }; - Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5 } + Self { id, start: None, last: Point::ZERO, mods: Mods::default(), began: false, sides: 6, points: 5, corner_radius: 12.0, star_ratio: 0.5, guides: vec![] } } fn label(&self) -> &'static str { @@ -95,13 +96,17 @@ impl Tool for ShapeTool { fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { match ev.kind { PointerKind::Down => { - self.start = Some(ev.pos); + let (p, _) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.start = Some(p); self.last = ev.pos; self.began = false; vec![] } PointerKind::Drag => { let Some(s) = self.start else { return vec![] }; + let (pos, g) = crate::guides::snap_draw(cx, ev.pos, &[]); + self.guides = g; + let ev = &PointerEvent { pos, ..*ev }; self.last = ev.pos; self.mods = ev.mods; let mut out = vec![]; @@ -118,6 +123,7 @@ impl Tool for ShapeTool { } PointerKind::Up => { let Some(s) = self.start.take() else { return vec![] }; + self.guides.clear(); if self.began { self.began = false; vec![Action::Commit] @@ -172,7 +178,9 @@ impl Tool for ShapeTool { match self.start { Some(s) if self.began => { let d = self.last - s; - vec![Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }] + let mut o = self.guides.clone(); + o.push(Overlay::Measure { p: self.last, text: format!("W: {:.2} pt\nH: {:.2} pt", d.x.abs(), d.y.abs()) }); + o } _ => vec![], } diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs new file mode 100644 index 00000000..a1c8a1a8 --- /dev/null +++ b/crates/tools/src/text.rs @@ -0,0 +1,179 @@ +//! The Type tool (T): click places point type, drag draws an area-type frame, clicking into +//! existing text places the caret. Typing is coalesced into one undo step per editing session. + +use drawcraft_doc::hit::hit_test; +use drawcraft_doc::{NodeId, NodeKind}; +use drawcraft_geom::{Point, Rect}; +use serde_json::json; + +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey}; + +#[derive(Default)] +pub struct TypeTool { + /// Text object being edited and the caret (byte offset). + editing: Option<(NodeId, usize)>, + /// Current text of the edited object (mirrors the preview). + buffer: String, + typing: bool, + press: Option, + drag: Option, +} + +impl TypeTool { + fn text_of(cx: &ToolContext, id: NodeId) -> Option { + match &cx.doc.node(id)?.kind { + NodeKind::Text(t) => Some(t.plain_text()), + _ => None, + } + } + fn finish(&mut self) -> Vec { + let mut out = vec![]; + if self.typing { + out.push(Action::Commit); + } + self.typing = false; + self.editing = None; + out + } + fn edit(&mut self, new_text: String, caret: usize) -> Vec { + let Some((id, _)) = self.editing else { return vec![] }; + let mut out = vec![]; + if !self.typing { + out.push(Action::Begin("Typing".into())); + self.typing = true; + } + self.buffer = new_text; + self.editing = Some((id, caret)); + out.push(Action::Preview("text.setText".into(), json!({"id": id.0, "text": self.buffer}))); + out + } +} + +impl Tool for TypeTool { + fn id(&self) -> &'static str { + "type" + } + fn busy(&self) -> bool { + self.press.is_some() + } + fn wants_text(&self) -> bool { + self.editing.is_some() + } + fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { + match ev.kind { + PointerKind::Down => { + self.press = Some(ev.pos); + self.drag = None; + vec![] + } + PointerKind::Drag => { + if self.press.is_some() { + self.drag = Some(ev.pos); + } + vec![] + } + PointerKind::Up => { + let Some(start) = self.press.take() else { return vec![] }; + let mut out = self.finish(); + // Click into existing text: place the caret. + if let Some(h) = hit_test(cx.doc, start, cx.hit_options()) + && let Some(NodeKind::Text(t)) = cx.doc.node(h.leaf).map(|n| &n.kind) + { + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let local = t.xf.inverse() * start; + let byte = drawcraft_text::hit_byte(&lay, local); + self.editing = Some((h.leaf, byte)); + self.buffer = t.plain_text(); + out.push(Action::Exec("select.set".into(), json!({"ids": [h.leaf.0]}))); + return out; + } + let area = self.drag.take().map(|d| Rect::from_points(start, d)).filter(|r| r.width() > cx.tol(6.0) && r.height() > cx.tol(6.0)); + let params = match area { + Some(r) => json!({"x": r.x0, "y": r.y0 + 12.0, "text": "", "area": {"width": r.width(), "height": r.height()}}), + None => json!({"x": start.x, "y": start.y, "text": ""}), + }; + out.push(Action::Exec("text.create".into(), params)); + out.push(Action::Notify("text.editNew".into())); + out + } + _ => vec![], + } + } + fn text_input(&mut self, cx: &ToolContext, s: &str) -> Vec { + let Some((id, caret)) = self.editing else { return vec![] }; + if !self.typing { + self.buffer = Self::text_of(cx, id).unwrap_or_default(); + } + let caret = caret.min(self.buffer.len()); + let mut t = self.buffer.clone(); + t.insert_str(caret, s); + self.edit(t, caret + s.len()) + } + fn key(&mut self, cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec { + let Some((id, caret)) = self.editing else { return vec![] }; + if !self.typing { + self.buffer = Self::text_of(cx, id).unwrap_or_default(); + } + let caret = caret.min(self.buffer.len()); + match key { + ToolKey::Escape => { + let out = self.finish(); + self.editing = None; + out + } + ToolKey::Enter => self.text_input(cx, "\n"), + ToolKey::Backspace if caret > 0 => { + let prev = self.buffer[..caret].char_indices().last().map(|(i, _)| i).unwrap_or(0); + let mut t = self.buffer.clone(); + t.replace_range(prev..caret, ""); + self.edit(t, prev) + } + ToolKey::Delete if caret < self.buffer.len() => { + let next = self.buffer[caret..].char_indices().nth(1).map(|(i, _)| caret + i).unwrap_or(self.buffer.len()); + let mut t = self.buffer.clone(); + t.replace_range(caret..next, ""); + self.edit(t, caret) + } + ToolKey::Left => { + let prev = self.buffer[..caret].char_indices().last().map(|(i, _)| i).unwrap_or(0); + self.editing = Some((id, prev)); + vec![] + } + ToolKey::Right => { + let next = self.buffer[caret..].char_indices().nth(1).map(|(i, _)| caret + i).unwrap_or(self.buffer.len()); + self.editing = Some((id, next)); + vec![] + } + _ => vec![], + } + } + fn deactivate(&mut self, _cx: &ToolContext) -> Vec { + self.finish() + } + /// After `text.create` the engine selects the new object; start editing it. + fn notify(&mut self, cx: &ToolContext, what: &str) { + if what == "text.editNew" + && let Some(id) = cx.selection.objects.first().copied() + { + self.editing = Some((id, 0)); + self.buffer.clear(); + } + } + fn overlays(&self, cx: &ToolContext) -> Vec { + let mut o = vec![]; + if let (Some(s), Some(d)) = (self.press, self.drag) { + o.push(Overlay::Marquee(Rect::from_points(s, d))); + } + if let Some((id, caret)) = self.editing + && let Some(NodeKind::Text(t)) = cx.doc.node(id).map(|n| &n.kind) + { + let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), t); + let (a, b) = drawcraft_text::caret_position(&lay, caret.min(t.plain_text().len())); + o.push(Overlay::Line { a: t.xf * a, b: t.xf * b, color: [0, 0, 0], dashed: false }); + } + o + } + fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { + Cursor::Text + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 65c371ec..5e04d82f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -353,11 +353,11 @@ fn handle_input(app: &mut DrawcraftApp, ui: &Ui, resp: &egui::Response, rect: eg Drag::Pan { .. } => {} } } - } else if let Some(p) = hover { - if pointer.is_moving() { - let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; - dispatch(app, &ev, view); - } + } else if let Some(p) = hover + && pointer.is_moving() + { + let ev = PointerEvent { kind: PointerKind::Move, pos: xf.to_doc(p), mods: mods(m, space), pressure: 1.0 }; + dispatch(app, &ev, view); } if resp.double_clicked() && let Some(p) = hover diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 8d0b3898..e3ce1ee5 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -29,26 +29,22 @@ pub fn app_bar(app: &mut DrawcraftApp, ui: &mut 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))); - (|ui: &mut Ui| { - let ws = egui::Frame::NONE.fill(t.panel).corner_radius(CornerRadius::same(4)).inner_margin(egui::Margin::symmetric(8, 3)); - ws.show(ui, |ui| { - ui.horizontal(|ui| { - ui.label(egui::RichText::new("Essentials").size(12.0).color(t.text)); - icons::icon(ui, "chevron-down", 12.0, t.text_dim); - }); - }); - ui.add_space(6.0); - // Search box → command palette. - let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); - ui.painter().rect_filled(r, CornerRadius::same(12), t.input); - ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); - icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); - ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); - if resp.clicked() { - app.ui.palette_open = true; - app.ui.palette_query.clear(); - } - })(&mut rui); + let ui = &mut rui; + let (wr, wresp) = ui.allocate_exact_size(vec2(112.0, 24.0), Sense::click()); + ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel }); + ui.painter().text(wr.left_center() + vec2(10.0, 0.0), egui::Align2::LEFT_CENTER, "Essentials", egui::FontId::proportional(12.0), t.text); + icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim); + ui.add_space(8.0); + // Search box → command palette. + let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click()); + ui.painter().rect_filled(r, CornerRadius::same(12), t.input); + ui.painter().rect_stroke(r, CornerRadius::same(12), Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }), StrokeKind::Inside); + icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim); + ui.painter().text(r.left_center() + vec2(26.0, 0.0), egui::Align2::LEFT_CENTER, "Search commands and tools", egui::FontId::proportional(11.5), t.text_dim); + if resp.clicked() { + app.ui.palette_open = true; + app.ui.palette_query.clear(); + } }); }); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e0f8729..f07d2e29 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -31,19 +31,25 @@ use serde_json::{Value, json}; pub use control::{ControlRequest, ControlResponse}; pub use state::{UiState, View}; +pub type PickSave = Box Option>; +pub type ReadFn = Box Result, String>>; +pub type WriteFn = Box Result<(), String>>; +pub type Inbox = Arc)>>>; +pub type DownloadFn = Box; + /// Platform services injected by the host app (desktop or web). #[derive(Default)] pub struct Services { /// Show an open dialog; returns a path. pub pick_open: Option Option>>, /// Show a save dialog with a suggested file name; returns a path. - pub pick_save: Option Option>>, - pub read: Option Result, String>>>, - pub write: Option Result<(), String>>>, + pub pick_save: Option, + pub read: Option, + pub write: Option, /// Files that arrived asynchronously (web open / drops): (name, bytes). - pub inbox: Option)>>>>, + pub inbox: Option, /// Web: trigger a browser download instead of writing a path. - pub download: Option>, + pub download: Option, /// Web: start an async open (bytes arrive via `inbox`). pub open_async: Option>, } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 5fbaddf0..410abd31 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -12,14 +12,14 @@ pub fn parse(s: &str) -> Option { if s.is_empty() { return None; } - let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').map(|(m, k)| (m, k)).unwrap_or(("", s)) }; + let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').unwrap_or(("", s)) }; let mut m = Modifiers::NONE; for part in mods_part.split('+').filter(|p| !p.is_empty()) { match part { - "Cmd" => m = m | Modifiers::COMMAND, - "Shift" => m = m | Modifiers::SHIFT, - "Alt" => m = m | Modifiers::ALT, - "Ctrl" => m = m | Modifiers::CTRL, + "Cmd" => m |= Modifiers::COMMAND, + "Shift" => m |= Modifiers::SHIFT, + "Alt" => m |= Modifiers::ALT, + "Ctrl" => m |= Modifiers::CTRL, _ => return None, } } @@ -86,6 +86,24 @@ pub fn handle(app: &mut DrawcraftApp, ctx: &egui::Context) { if typing { return; } + // Type tool editing: text and editing keys go to the tool. + if app.session.tool_wants_text() { + let texts: Vec = ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect()); + for t in texts { + let _ = app.session.tool_text(&t, view); + } + for (k, tk) in [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] { + if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { + let _ = app.session.tool_key(tk, Mods::default(), view); + } + } + // Enter was already delivered above as ToolKey::Enter (newline). + let fire = all_shortcuts().into_iter().filter(|(sc, _)| sc.modifiers.command).find(|(sc, _)| ctx.input_mut(|i| i.consume_shortcut(sc))).map(|(_, id)| id); + if let Some(id) = fire { + crate::menus::invoke(app, id, json!({})); + } + return; + } if busy { for (k, tk) in [(Key::ArrowUp, ToolKey::Up), (Key::ArrowDown, ToolKey::Down)] { if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, k)) { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 2f6472c9..6522ffd9 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -197,7 +197,7 @@ mod tests { #[test] fn zoom_steps() { assert_eq!(next_zoom(1.0, true), 1.5); - assert_eq!(next_zoom(1.0, false), 0.6667); + assert!((next_zoom(1.0, false) - 0.6667).abs() < 1e-9); assert_eq!(next_zoom(640.0, true), 640.0); assert_eq!(zoom_label(0.6667), "66.67%"); assert_eq!(zoom_label(1.0), "100%"); diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..c2912c1b --- /dev/null +++ b/docs/development.md @@ -0,0 +1,25 @@ +# Development + +## Web build + +`apps/drawcraft-web` runs the same `DrawcraftApp` in the browser through eframe's web runner. The renderer is wgpu: WebGPU where the browser has it, WebGL2 otherwise. It is Rust only; the only JavaScript is the glue wasm-bindgen generates. + +```sh +brew install trunk # or: cargo install trunk --locked +rustup target add wasm32-unknown-unknown +cd apps/drawcraft-web +trunk build --release # writes ../../dist/web (index.html, .js glue, .wasm) +trunk serve --release # dev server on http://127.0.0.1:8766 +``` + +Any static file server works for `dist/web`, for example `python3 -m http.server 8766` inside that directory. The release `.wasm` is about 17.5 MB, or 7.1 MB gzipped, so serve it with compression. + +URL flag: `?webgl` forces the WebGL2 backend. + +How the web shell (`apps/drawcraft-web/src/web.rs`) differs from desktop: + +- **Open** sets `Services::open_async`, which shows `rfd::AsyncFileDialog`. The bytes arrive in `Services::inbox`, which the app drains every frame. +- **Save / Save As / Export** go through `Services::download`: a Blob, an object URL and a temporary ``, all created from Rust. There is no save dialog, so the suggested name becomes the download name. +- **Drag-and-drop:** `WebShell` takes the frame's `dropped_files` before the app sees them, reads each with `DroppedFile::bytes_async` and pushes the bytes into the inbox. (The app's synchronous drop path is compiled out on wasm32.) +- **No control server:** browsers can't listen on TCP. To automate the web build, drive headless Chrome with `--remote-debugging-port`. +- Quick smoke test: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --enable-unsafe-webgpu --screenshot=web.png --window-size=1440,900 --virtual-time-budget=15000 http://127.0.0.1:8766/` (headless Chrome on macOS gets a real WebGPU adapter). diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 00000000..ab4df8e9 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,128 @@ +# DrawCraft MCP server + +`drawcraft-cli mcp` runs a [Model Context Protocol](https://modelcontextprotocol.io) server on stdio +(newline-delimited JSON-RPC 2.0, protocol `2025-06-18`; `2025-03-26` and `2024-11-05` also accepted). Agents use it to +draw, inspect and look at DrawCraft documents. + +It has two backends: + +| Mode | How | What works | +|---|---|---| +| **Remote** | `drawcraft-cli mcp --connect 127.0.0.1:7979` (the app must run with `drawcraft --control 7979`) | Everything. Tool calls are forwarded over the [control protocol](control-protocol.md), so you watch the app change live. | +| **Headless** | `drawcraft-cli mcp --headless` | An in-process engine session with a CPU renderer. Everything except the UI-only tools (`inspect_ui`, `type_text`, `open_panel`, `screenshot {window:true}`). | + +With no flag, the server tries `127.0.0.1:7979` and falls back to headless. Logs go to stderr; stdout carries +only protocol messages. + +## Build and register + +```sh +cargo build --release -p drawcraft-cli +claude mcp add drawcraft -- "$PWD/target/release/drawcraft-cli" mcp +# or pin a mode: +claude mcp add drawcraft-headless -- "$PWD/target/release/drawcraft-cli" mcp --headless +claude mcp add drawcraft-app -- "$PWD/target/release/drawcraft-cli" mcp --connect 127.0.0.1:7979 +``` + +Other clients use the same command in their JSON config: + +```json +{"mcpServers": {"drawcraft": {"command": "/abs/path/target/release/drawcraft-cli", "args": ["mcp"]}}} +``` + +For a live session, start the app first: `cargo run --release -p drawcraft -- --control 7979`. + +## Tools + +Coordinates are points in document space: y points down, the origin is the first artboard's top-left, and a new +document is 612 × 792 (US Letter). New objects become the selection. Most commands act on the selection or on +explicit `ids`. + +Paint values (`fill`, `stroke`) accept `"#rrggbb"`, `"none"`, `[r,g,b]` (0..1), `{"c","m","y","k"}`, `{"gray"}`, +or a full `paint.setFill` params object (`{"gradient": …}`, `{"swatch": "name"}`). + +| Tool | Arguments | Notes | +|---|---|---| +| `list_commands` | `{filter?, enabledOnly?}` | The command catalogue: id, label, menu, shortcut, params doc, enablement. | +| `run_command` | `{command, params?}` | Runs any command. Use it for everything without a dedicated tool. | +| `inspect_document` | `{}` | Artboards, layer tree (ids, kinds, bounds, paint), selection, history, tool. | +| `inspect_ui` | `{}` | UI state. Remote mode only. | +| `select_tool` | `{tool}` | `selection`, `directSelection`, `pen`, `rectangle`, `ellipse`, `polygon`, `star`, `lineSegment`, … | +| `pointer_gesture` | `{events:[{kind,x,y,mods?}], tool?, mods?}` | `kind` is one of `down`, `drag`, `up`, `move`, `doubleclick`. Events go through the same path as the mouse. | +| `draw_path` | `{points \| d, closed?, fill?, stroke?, strokeWidth?}` | `points` is `[[x,y],…]` or `[{x,y,in?,out?,smooth?},…]`. `d` is SVG path data. | +| `draw_shape` | `{shape, …geometry, fill?, stroke?, strokeWidth?}` | `rectangle`/`ellipse`: `x,y,width,height` (plus `radius` for corners). `polygon`: `cx,cy,radius,sides`. `star`: `cx,cy,radius1,radius2,points`. `line`: `x1,y1,x2,y2`. | +| `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. | +| `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool. | +| `type_text` | `{text}` | Remote only. | +| `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | +| `open_panel` | `{panel}` | Remote only. | +| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | +| `open_file` | `{path}` | Opens `.drawcraft` or `.svg` as a new active document. | +| `save_file` | `{path?}` | Saves in the native `.drawcraft` format. | +| `export` | `{path, format?, scale?}` | `svg`, `png` or `drawcraft`. When `format` is omitted, it comes from the path's extension. | +| `undo` / `redo` | `{}` | | + +Errors (unknown tool, bad arguments, a disabled or failing command) come back as a normal result with +`isError: true` and a message. The model can read the message and retry. + +## Resources + +| URI | Content | +|---|---| +| `drawcraft://document` | `document.inspect` summary (JSON) | +| `drawcraft://document/json` | The complete document model (JSON) | + +## Examples + +Draw, look, export: + +```json +{"name":"draw_shape","arguments":{"shape":"rectangle","x":72,"y":72,"width":200,"height":120,"radius":12,"fill":"#1e88e5","stroke":"none"}} +{"name":"draw_shape","arguments":{"shape":"star","cx":400,"cy":300,"radius1":90,"radius2":40,"points":5,"fill":"#ffc107","stroke":"#000000","strokeWidth":2}} +{"name":"draw_path","arguments":{"d":"M72 400 C 150 300 250 500 330 400","stroke":"#e53935","strokeWidth":4,"fill":"none"}} +{"name":"screenshot","arguments":{"scale":0.5}} +{"name":"export","arguments":{"path":"/tmp/art.svg"}} +``` + +Long-tail commands: + +```json +{"name":"list_commands","arguments":{"filter":"align"}} +{"name":"run_command","arguments":{"command":"select.all"}} +{"name":"run_command","arguments":{"command":"object.align","params":{"align":"left"}}} +{"name":"run_command","arguments":{"command":"object.group"}} +``` + +Drive a tool like a mouse: + +```json +{"name":"pointer_gesture","arguments":{"tool":"ellipse","events":[ + {"kind":"down","x":100,"y":100},{"kind":"drag","x":150,"y":140},{"kind":"up","x":200,"y":180}]}} +``` + +Raw protocol (for debugging): + +```sh +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"sh","version":"0"}}}' \ + '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ + '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"ellipse","x":10,"y":10,"width":100,"height":80,"fill":"#ff0000"}}}' \ + '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"inspect_document","arguments":{}}}' \ + | target/release/drawcraft-cli mcp --headless +``` + +## Headless batch CLI + +```sh +drawcraft-cli commands # command catalogue (JSON) +drawcraft-cli run --in in.svg \ + --cmd select.all --cmd paint.setFill --params '{"color":"#ff0000"}' \ + --export out.svg --export out.png --scale 2 +drawcraft-cli run --cmd file.new --params '{"width":800,"height":600}' \ + --cmd shape.star --params '{"cx":400,"cy":300,"radius1":200,"radius2":90}' \ + --export star.drawcraft +``` + +`run` prints one JSON line per step (`open`, `cmd`, `export`) and exits non-zero on the first failure. `--params` +applies to the `--cmd` just before it. `run` also accepts the host commands `file.open`, `file.save`, `file.export` +and `tool.select`.