M2.8/M6.2/M7.2: smart guides + snapping, Type tool, Pathfinder/path/type commands, web build

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 06:28:59 -04:00
1 parent 8cd8f8f8a7
commit 874c0e6ca2
19 files changed
+1325 -67

No files matched your search

+4
View File
@@ -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
})
}
+95 -26
View File
@@ -16,27 +16,97 @@ use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
})?;
Ok(ids_json(&ids))
}
+209
View File
@@ -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<CommandSpec> {
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<NodeId> {
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<Value> {
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<NodeId> = 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::<Vec<_>>() }))
}
fn text_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<NodeId>> {
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<Value> {
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::<Vec<_>>() }))
}
fn justify_param(v: &str) -> Option<Justify> {
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<Value> {
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::<Vec<_>>() }))
}
+4
View File
@@ -162,6 +162,7 @@ pub struct Session {
/// Executed commands (for actions and debugging).
pub journal: Vec<(String, Value)>,
pub(crate) tool: Box<dyn Tool>,
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;
+405
View File
@@ -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<NodeId> {
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<Value> = (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);
}
}
+15
View File
@@ -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<Vec<UiRequest>> {
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<Vec<UiRequest>> {
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)
+161
View File
@@ -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<Rect>) -> 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<Overlay>) {
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<Overlay>) {
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<Overlay>) {
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());
}
}
+13
View File
@@ -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<Action> {
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<Action> {
vec![]
@@ -227,6 +239,7 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"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"))),
}
+2 -1
View File
@@ -44,7 +44,8 @@ impl Tool for PenTool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
let mut p = ev.pos;
let exclude: Vec<drawcraft_doc::NodeId> = 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 {
+18 -1
View File
@@ -38,6 +38,9 @@ enum State {
pub struct SelectionTool {
state: State,
measure: Option<(Point, String)>,
guides: Vec<Overlay>,
targets: Option<crate::guides::Targets>,
start_bounds: Option<Rect>,
}
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<drawcraft_doc::NodeId> = 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() });
}
+11 -3
View File
@@ -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<Overlay>,
}
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<Action> {
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![],
}
+179
View File
@@ -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<Point>,
drag: Option<Point>,
}
impl TypeTool {
fn text_of(cx: &ToolContext, id: NodeId) -> Option<String> {
match &cx.doc.node(id)?.kind {
NodeKind::Text(t) => Some(t.plain_text()),
_ => None,
}
}
fn finish(&mut self) -> Vec<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Action> {
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<Overlay> {
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
}
}
+5 -5
View File
@@ -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
+16 -20
View File
@@ -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();
}
});
});
}
+11 -5
View File
@@ -31,19 +31,25 @@ use serde_json::{Value, json};
pub use control::{ControlRequest, ControlResponse};
pub use state::{UiState, View};
pub type PickSave = Box<dyn FnMut(&str) -> Option<String>>;
pub type ReadFn = Box<dyn Fn(&str) -> Result<Vec<u8>, String>>;
pub type WriteFn = Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>;
pub type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// 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<Box<dyn FnMut() -> Option<String>>>,
/// Show a save dialog with a suggested file name; returns a path.
pub pick_save: Option<Box<dyn FnMut(&str) -> Option<String>>>,
pub read: Option<Box<dyn Fn(&str) -> Result<Vec<u8>, String>>>,
pub write: Option<Box<dyn FnMut(&str, &[u8]) -> Result<(), String>>>,
pub pick_save: Option<PickSave>,
pub read: Option<ReadFn>,
pub write: Option<WriteFn>,
/// Files that arrived asynchronously (web open / drops): (name, bytes).
pub inbox: Option<Arc<Mutex<Vec<(String, Vec<u8>)>>>>,
pub inbox: Option<Inbox>,
/// Web: trigger a browser download instead of writing a path.
pub download: Option<Box<dyn FnMut(&str, &[u8])>>,
pub download: Option<DownloadFn>,
/// Web: start an async open (bytes arrive via `inbox`).
pub open_async: Option<Box<dyn FnMut()>>,
}
+23 -5
View File
@@ -12,14 +12,14 @@ pub fn parse(s: &str) -> Option<KeyboardShortcut> {
if s.is_empty() {
return None;
}
let (mods_part, key_part) = if let Some(stripped) = s.strip_suffix("++") { (stripped.trim_end_matches('+'), "+") } else { s.rsplit_once('+').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<String> = ctx.input(|i| i.events.iter().filter_map(|e| if let egui::Event::Text(t) = e { Some(t.clone()) } else { None }).collect());
for t in texts {
let _ = app.session.tool_text(&t, view);
}
for (k, tk) in [(Key::Backspace, ToolKey::Backspace), (Key::Delete, ToolKey::Delete), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)] {
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)) {
+1 -1
View File
@@ -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%");
+25
View File
@@ -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 `<a download>`, 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).
+128
View File
@@ -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`.