mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06: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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8cd8f8f8a7
commit
874c0e6ca2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user