mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
8cd8f8f8a7
commit
874c0e6ca2
19 files changed
+1325
-67
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"))),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() });
|
||||
}
|
||||
|
||||
@@ -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![],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()>>,
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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%");
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
Reference in new issue
Block a user