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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 06:28:59 -04:00
co-authored by Claude Opus 5.5
parent 8cd8f8f8a7
commit 874c0e6ca2
19 changed files with 1325 additions and 67 deletions
+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)