M0: workspace skeleton — geom, color, doc, render, text, pathops, svg, tools, engine

Engine-first Cargo workspace for DrawCraft (clean-room Illustrator):
- geom: anchor-based path model, shapes, hit testing, snapping
- color: colours, paints, gradients, swatches, blend modes, harmonies
- doc: persistent Arc document tree, appearance stacks, text model, selection
- render: vello_cpu renderer (appearance, gradients, clip groups, blend modes)
- text: font DB, harfrust shaping, point/area/path layout
- pathops: curve booleans (linesweeper), Pathfinder, offset, simplify
- svg: import (usvg) and export
- tools: selection/direct/pen/shape tools emitting commands
- engine: Session, history with interactions, ~110 commands

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 05:53:29 -04:00
co-authored by Claude Opus 5.5
parent fa1689bfd8
commit bfee80a22c
102 changed files with 17872 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
//! Creating art: shapes, paths, text.
use drawcraft_doc::{Appearance, CharStyle, LiveShape, Node, NodeKind, TextObject};
use drawcraft_geom::{Affine, Anchor, AnchorKind, FillRule, PathData, Point, Rect, SubPath, shapes};
use serde_json::{Value, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("shape.rectangle", "Rectangle", [], None, "{x, y, width, height, radius?: pt} → {id}", has_doc, rectangle),
cmd!("shape.ellipse", "Ellipse", [], None, "{x, y, width, height} → {id}", has_doc, ellipse),
cmd!("shape.polygon", "Polygon", [], None, "{cx, cy, radius, sides=6, rotation?: deg} → {id}", has_doc, polygon),
cmd!("shape.star", "Star", [], None, "{cx, cy, radius1, radius2, points=5, rotation?: deg} → {id}", has_doc, star),
cmd!("shape.line", "Line Segment", [], None, "{x1, y1, x2, y2} → {id}", has_doc, line),
cmd!("shape.spiral", "Spiral", [], None, "{cx, cy, radius, decay=80 (%), segments=10, clockwise?} → {id}", has_doc, spiral),
cmd!("shape.arc", "Arc", [], None, "{x1, y1, x2, y2, closed?} → {id}", has_doc, arc),
cmd!("shape.rectangularGrid", "Rectangular Grid", [], None, "{x, y, width, height, rows=5, columns=5} → {id}", has_doc, rect_grid),
cmd!("shape.polarGrid", "Polar Grid", [], None, "{x, y, width, height, concentric=5, radial=5} → {id}", has_doc, polar_grid),
cmd!(
"path.create",
"Create Path",
[],
None,
"{anchors: [{x, y, in?: [x,y], out?: [x,y]}], closed?: bool, d?: SVG path data} → {id}",
has_doc,
path_create
),
cmd!(
"text.create",
"Create Text",
[],
None,
"{x, y, text, size?: pt, font?: family, style?, color?, area?: {width, height}} → {id}",
has_doc,
text_create
),
]
}
/// Insert a new object at the top of the insertion parent with the current paint; select it.
pub(crate) fn add_art(s: &mut Session, label: &str, kind: NodeKind, name: Option<String>) -> Result<Value> {
let appearance = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
add_node(s, label, kind, appearance, name)
}
pub(crate) fn add_node(s: &mut Session, label: &str, kind: NodeKind, appearance: Appearance, name: Option<String>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let id = s.edit(label, |d, sel| {
let id = d.alloc_id();
let mut n = Node::new(id, kind);
n.appearance = appearance;
n.name = name;
d.insert(parent, usize::MAX, n)?;
sel.set([id]);
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn path_kind(path: PathData, live: Option<LiveShape>) -> NodeKind {
NodeKind::Path { path, rule: FillRule::NonZero, live, clipping: false, guide: false }
}
fn rect_of(p: &Value, cmd: &str) -> Result<Rect> {
let x = f64_req(p, "x", cmd)?;
let y = f64_req(p, "y", cmd)?;
let w = f64_req(p, "width", cmd)?;
let h = f64_req(p, "height", cmd)?;
if !(w.is_finite() && h.is_finite()) {
return Err(bad(cmd, "invalid size"));
}
Ok(Rect::new(x, y, x + w, y + h).abs())
}
fn rectangle(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangle")?;
let radius = f64_or(p, "radius", 0.0).max(0.0);
let live = LiveShape::Rectangle { w: r.width(), h: r.height(), radii: [radius; 4], xf: Affine::translate(r.origin().to_vec2()) };
let label = if radius > 0.0 { "Rounded Rectangle" } else { "Rectangle" };
add_art(s, label, path_kind(live.to_path(), Some(live)), None)
}
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.ellipse")?;
let live = LiveShape::Ellipse { w: r.width(), h: r.height(), pie: (0.0, 360.0), xf: Affine::translate(r.origin().to_vec2()) };
add_art(s, "Ellipse", path_kind(live.to_path(), Some(live)), None)
}
fn polygon(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.polygon")?, f64_req(p, "cy", "shape.polygon")?);
let r = f64_req(p, "radius", "shape.polygon")?.abs();
let sides = p.get("sides").and_then(Value::as_u64).unwrap_or(6).clamp(3, 1000) as u32;
let rot = f64_or(p, "rotation", 0.0);
let live = LiveShape::Polygon { radius: r, sides, xf: Affine::translate(c.to_vec2()) * Affine::rotate(rot.to_radians()) };
add_art(s, "Polygon", path_kind(live.to_path(), Some(live)), None)
}
fn star(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.star")?, f64_req(p, "cy", "shape.star")?);
let r1 = f64_req(p, "radius1", "shape.star")?.abs();
let r2 = f64_or(p, "radius2", r1 / 2.0).abs();
let n = p.get("points").and_then(Value::as_u64).unwrap_or(5).clamp(2, 1000) as u32;
let path = shapes::star(c, r1, r2, n, f64_or(p, "rotation", 0.0));
add_art(s, "Star", path_kind(path, None), None)
}
fn line(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.line")?, f64_req(p, "y1", "shape.line")?);
let b = Point::new(f64_req(p, "x2", "shape.line")?, f64_req(p, "y2", "shape.line")?);
let live = LiveShape::Line { a, b };
// Lines are stroked, never filled (Illustrator ignores the fill for new open lines).
let appearance = Appearance::basic(
drawcraft_color::Paint::None,
if s.paint.stroke.is_none() { drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK) } else { s.paint.stroke.clone() },
s.paint.stroke_width.max(0.1),
);
add_node(s, "Line", path_kind(live.to_path(), Some(live)), appearance, None)
}
fn spiral(s: &mut Session, p: &Value) -> Result<Value> {
let c = Point::new(f64_req(p, "cx", "shape.spiral")?, f64_req(p, "cy", "shape.spiral")?);
let path = shapes::spiral(
c,
f64_req(p, "radius", "shape.spiral")?,
f64_or(p, "decay", 80.0),
p.get("segments").and_then(Value::as_u64).unwrap_or(10).clamp(2, 1000) as u32,
bool_or(p, "clockwise", true),
);
add_art(s, "Spiral", path_kind(path, None), None)
}
fn arc(s: &mut Session, p: &Value) -> Result<Value> {
let a = Point::new(f64_req(p, "x1", "shape.arc")?, f64_req(p, "y1", "shape.arc")?);
let b = Point::new(f64_req(p, "x2", "shape.arc")?, f64_req(p, "y2", "shape.arc")?);
add_art(s, "Arc", path_kind(shapes::arc(a, b, 0.0, bool_or(p, "closed", false)), None), None)
}
fn grid_group(s: &mut Session, label: &str, paths: Vec<PathData>) -> Result<Value> {
let parent = s.doc()?.insertion_parent();
let ap = Appearance::basic(drawcraft_color::Paint::None, s.paint.stroke.clone(), s.paint.stroke_width);
let id = s.edit(label, |d, sel| {
let children = paths
.into_iter()
.map(|pd| {
let id = d.alloc_id();
std::sync::Arc::new(Node::path(id, pd, ap.clone()))
})
.collect();
let gid = d.alloc_id();
d.insert(parent, usize::MAX, Node::group(gid, children))?;
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": id.0 }))
}
fn rect_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.rectangularGrid")?;
let rows = p.get("rows").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let cols = p.get("columns").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Rectangular Grid", shapes::rectangular_grid(r, rows, cols, true))
}
fn polar_grid(s: &mut Session, p: &Value) -> Result<Value> {
let r = rect_of(p, "shape.polarGrid")?;
let c = p.get("concentric").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
let rad = p.get("radial").and_then(Value::as_u64).unwrap_or(5).min(999) as u32;
grid_group(s, "Polar Grid", shapes::polar_grid(r, c, rad))
}
pub(crate) fn anchor_from_json(v: &Value) -> Option<Anchor> {
let p = Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?);
let h_in = point_param(v, "in").unwrap_or(p);
let h_out = point_param(v, "out").unwrap_or(p);
let mut a = Anchor::with_handles(p, h_in, h_out);
if v.get("smooth").and_then(Value::as_bool) == Some(true) {
a.kind = AnchorKind::Smooth;
}
Some(a)
}
fn path_create(s: &mut Session, p: &Value) -> Result<Value> {
let path = if let Some(d) = str_param(p, "d") {
let bp = drawcraft_geom::BezPath::from_svg(d).map_err(|e| bad("path.create", format!("bad path data: {e}")))?;
PathData::from_bezpath(&bp)
} else {
let anchors: Vec<Anchor> = p
.get("anchors")
.and_then(Value::as_array)
.ok_or_else(|| bad("path.create", "missing anchors"))?
.iter()
.filter_map(anchor_from_json)
.collect();
if anchors.is_empty() {
return Err(bad("path.create", "need at least one anchor"));
}
PathData::single(SubPath::new(anchors, bool_or(p, "closed", false)))
};
let mut ap = Appearance::basic(s.paint.fill.clone(), s.paint.stroke.clone(), s.paint.stroke_width);
// Open paths drawn with a stroke of None would be invisible: mimic Illustrator and keep what the user set.
if !path.is_closed() && ap.stroke_paint().is_none() && ap.fill_paint().is_none() {
ap.set_stroke(drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK));
}
add_node(s, "Pen", path_kind(path, None), ap, None)
}
fn text_create(s: &mut Session, p: &Value) -> Result<Value> {
let x = f64_req(p, "x", "text.create")?;
let y = f64_req(p, "y", "text.create")?;
let text = str_param(p, "text").unwrap_or("");
let mut style = CharStyle::default();
if let Some(sz) = p.get("size").and_then(Value::as_f64) {
style.size = sz.clamp(0.1, 1296.0);
}
if let Some(f) = str_param(p, "font") {
style.font_family = f.to_string();
}
if let Some(f) = str_param(p, "style") {
style.font_style = f.to_string();
}
style.fill = match p.get("color").and_then(color_value) {
Some(c) => drawcraft_color::Paint::solid(c),
None if !s.paint.fill.is_none() && s.paint.fill != drawcraft_color::Paint::solid(drawcraft_color::Color::WHITE) => s.paint.fill.clone(),
None => drawcraft_color::Paint::solid(drawcraft_color::Color::BLACK),
};
let mut t = TextObject::point(Point::new(x, y), text, style);
if let Some(a) = p.get("area") {
let w = f64_or(a, "width", 200.0);
let h = f64_or(a, "height", 100.0);
t.kind = drawcraft_doc::TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, w, h)) };
}
let lay = drawcraft_text::layout(drawcraft_text::FontDb::global(), &t);
t.cached_bounds = Some(lay.bounds);
add_node(s, "Type", NodeKind::Text(Box::new(t)), Appearance::default(), None)
}
+283
View File
@@ -0,0 +1,283 @@
//! File (document-level) and Edit commands, plus document queries.
use std::sync::Arc;
use drawcraft_doc::{Document, Node, NodeId, Unit};
use drawcraft_geom::{Affine, Vec2};
use serde_json::{Value, json};
use super::*;
use crate::{EngineError, inspect};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"file.new",
"New…",
["File"],
Some("Cmd+N"),
"{width?: pt=612, height?: pt=792, units?: \"Points\"|\"Inches\"|\"Millimeters\"|\"Pixels\"…, title?, artboards?: n, colorMode?: \"rgb\"|\"cmyk\"}",
always,
file_new
),
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
"Units",
["File", "Document Setup"],
None,
"{units: \"Points\"|\"Picas\"|\"Inches\"|\"Millimeters\"|\"Centimeters\"|\"Pixels\"}",
has_doc,
set_units
),
cmd!("edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, undo),
cmd!("edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, redo),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{}", has_selection, cut),
cmd!("edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{}", has_selection, copy),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{dx?, dy?}", has_clipboard, |s, p| paste(s, p, PasteMode::Offset)),
cmd!("edit.pasteInFront", "Paste in Front", ["Edit"], Some("Cmd+F"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Front)),
cmd!("edit.pasteInBack", "Paste in Back", ["Edit"], Some("Cmd+B"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::Back)),
cmd!("edit.pasteInPlace", "Paste in Place", ["Edit"], Some("Cmd+Shift+V"), "{}", has_clipboard, |s, p| paste(s, p, PasteMode::InPlace)),
cmd!("edit.pasteOnAllArtboards", "Paste on All Artboards", ["Edit"], Some("Cmd+Alt+Shift+V"), "{}", has_clipboard, |s, p| paste(
s,
p,
PasteMode::AllArtboards
)),
cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?}", has_selection, clear),
cmd!("edit.duplicate", "Duplicate", [], None, "{dx?, dy?} duplicate the selection in place (offset optional)", has_selection, duplicate),
]
}
fn file_new(s: &mut Session, p: &Value) -> Result<Value> {
let w = f64_or(p, "width", 612.0);
let h = f64_or(p, "height", 792.0);
if !(w > 0.0 && h > 0.0) || w > 16383.0 * 10.0 || h > 16383.0 * 10.0 {
return Err(bad("file.new", "width/height must be positive and within the canvas"));
}
let mut d = Document::new(w, h);
d.title = str_param(p, "title").map(str::to_string).unwrap_or_else(|| s.next_untitled());
if let Some(u) = str_param(p, "units") {
d.units = parse_unit(u).ok_or_else(|| bad("file.new", format!("unknown units `{u}`")))?;
}
if str_param(p, "colorMode").is_some_and(|m| m.eq_ignore_ascii_case("cmyk")) {
d.color_mode = drawcraft_doc::ColorMode::Cmyk;
}
let n = p.get("artboards").and_then(Value::as_u64).unwrap_or(1).clamp(1, 1000) as usize;
for i in 1..n {
let r = drawcraft_geom::Rect::new(0.0, 0.0, w, h) + Vec2::new(i as f64 * (w + 20.0), 0.0);
d.artboards.push(drawcraft_doc::Artboard {
id: (i + 1) as u32,
name: format!("Artboard {}", i + 1),
rect: r,
show_center_mark: false,
show_cross_hairs: false,
});
}
let i = s.add_document(d, None);
Ok(json!({ "index": i }))
}
pub(crate) fn parse_unit(u: &str) -> Option<Unit> {
let n = u.to_ascii_lowercase();
Unit::ALL.into_iter().find(|x| x.label().to_ascii_lowercase() == n || x.suffix() == n)
}
fn file_close(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_index()).ok_or(EngineError::NoDocument)?;
if !s.close_document(i) {
return Err(bad("file.close", "no such document"));
}
ok()
}
fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("document.activate", "missing index"))? as usize;
if s.set_active(i) { ok() } else { Err(bad("document.activate", "no such document")) }
}
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
}
fn set_units(s: &mut Session, p: &Value) -> Result<Value> {
let u = str_param(p, "units").and_then(parse_unit).ok_or_else(|| bad("document.setUnits", "unknown units"))?;
s.edit("Document Setup", |d, _| {
d.units = u;
Ok(())
})?;
ok()
}
fn undo(s: &mut Session, _: &Value) -> Result<Value> {
s.cancel_interaction()?;
let st = s.doc_mut()?;
let e = st.history.undo.pop().ok_or_else(|| EngineError::Other("nothing to undo".into()))?;
let label = e.label.clone();
st.history.redo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
st.revision += 1;
st.selection.prune(&st.doc);
Ok(json!({ "undone": label }))
}
fn redo(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let e = st.history.redo.pop().ok_or_else(|| EngineError::Other("nothing to redo".into()))?;
let label = e.label.clone();
st.history.undo.push(crate::HistoryEntry {
label: e.label,
doc: std::mem::replace(&mut st.doc, e.doc),
selection: std::mem::replace(&mut st.selection, e.selection),
});
st.revision += 1;
st.selection.prune(&st.doc);
Ok(json!({ "redone": label }))
}
/// Selected top-level objects in paint order, dropping any whose ancestor is also selected.
pub(crate) fn selected_roots(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
let sel = st.selection.in_paint_order(&st.doc);
Ok(sel
.iter()
.copied()
.filter(|id| {
let anc = st.doc.ancestry(*id).unwrap_or_default();
!anc[..anc.len().saturating_sub(1)].iter().any(|a| sel.contains(a))
})
.filter(|id| st.doc.node(*id).is_some_and(|n| !n.is_layer()))
.collect())
}
fn copy(s: &mut Session, _: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let st = s.doc()?;
let nodes: Vec<Node> = roots.iter().filter_map(|id| st.doc.node(*id).cloned()).collect();
let n = nodes.len();
s.clipboard = nodes;
Ok(json!({ "copied": n }))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
copy(s, p)?;
clear(s, &json!({}))
}
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
// Direct-selected anchors: delete those anchors instead of whole objects.
if ids_param(p, "ids").is_none() && !s.doc()?.selection.anchors.is_empty() {
return super::path::delete_anchors(s, p);
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
s.edit("Clear", |d, sel| {
for id in &ids {
if d.node(*id).is_some_and(|n| n.is_layer()) {
continue;
}
let _ = d.remove(*id);
}
sel.clear();
Ok(())
})?;
ok()
}
enum PasteMode {
Offset,
Front,
Back,
InPlace,
AllArtboards,
}
fn paste(s: &mut Session, p: &Value, mode: PasteMode) -> Result<Value> {
let clip = s.clipboard.clone();
let off = s.prefs.paste_offset;
let st = s.doc()?;
let parent = st.insertion_parent();
// Front/back: relative to the selection (top-most / bottom-most selected object).
let anchor = match mode {
PasteMode::Front | PasteMode::Back => {
let order = st.selection.in_paint_order(&st.doc);
let a = if matches!(mode, PasteMode::Front) { order.last() } else { order.first() };
a.and_then(|id| st.doc.position(*id))
}
_ => None,
};
let artboards: Vec<drawcraft_geom::Rect> = st.doc.artboards.iter().map(|a| a.rect).collect();
let dx = f64_or(p, "dx", off);
let dy = f64_or(p, "dy", off);
let label = match mode {
PasteMode::Front => "Paste in Front",
PasteMode::Back => "Paste in Back",
PasteMode::InPlace => "Paste in Place",
PasteMode::AllArtboards => "Paste on All Artboards",
PasteMode::Offset => "Paste",
};
let ids = s.edit(label, |d, sel| {
let mut new_ids = vec![];
let placements: Vec<Affine> = match mode {
PasteMode::Offset => vec![Affine::translate((dx, dy))],
PasteMode::AllArtboards => {
let src = d.artboards.first().map(|a| a.rect.origin()).unwrap_or_default();
artboards.iter().map(|r| Affine::translate(r.origin() - src)).collect()
}
_ => vec![Affine::IDENTITY],
};
for xf in placements {
for (k, n) in clip.iter().enumerate() {
let mut c = d.reid(n);
if xf != Affine::IDENTITY {
c.transform(xf, false);
}
let (par, idx) = match anchor {
Some((par, i, _)) => (par, if matches!(mode, PasteMode::Front) { i + 1 + k } else { i + k }),
None => (parent, usize::MAX),
};
new_ids.push(d.insert(par, idx, c)?);
}
}
sel.set(new_ids.iter().copied());
Ok(new_ids)
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let roots = selected_roots(s)?;
let dx = f64_or(p, "dx", 0.0);
let dy = f64_or(p, "dy", 0.0);
let ids = s.edit("Duplicate", |d, sel| duplicate_in(d, sel, &roots, Affine::translate((dx, dy))))?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
/// Duplicate `roots` directly above each original, transform the copies, select them.
pub(crate) fn duplicate_in(d: &mut Document, sel: &mut drawcraft_doc::Selection, roots: &[NodeId], xf: Affine) -> Result<Vec<NodeId>> {
let mut out = vec![];
for id in roots {
let Some((par, idx, _)) = d.position(*id) else { continue };
let Some(n) = d.node(*id).cloned() else { continue };
let mut c = d.reid(&n);
c.transform(xf, true);
out.push(d.insert(par, idx + 1, c)?);
}
sel.set(out.iter().copied());
Ok(out)
}
#[allow(dead_code)]
pub(crate) fn arc_nodes(v: Vec<Node>) -> Vec<Arc<Node>> {
v.into_iter().map(Arc::new).collect()
}
+254
View File
@@ -0,0 +1,254 @@
//! Layers panel, artboards.
use drawcraft_doc::{Artboard, LayerColor, NodeId, NodeKind};
use drawcraft_geom::Rect;
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("layer.new", "New Layer…", ["Window", "Layers"], Some("Cmd+L"), "{name?} → {id} (above the current layer)", has_doc, new_layer),
cmd!("layer.newSublayer", "New Sublayer…", ["Window", "Layers"], None, "{parent?: id, name?} → {id}", has_doc, new_sublayer),
cmd!("layer.delete", "Delete Layer", ["Window", "Layers"], None, "{id?} (default: current layer)", has_doc, delete_layer),
cmd!("layer.duplicate", "Duplicate Layer", ["Window", "Layers"], None, "{id?}", has_doc, duplicate_layer),
cmd!("layer.setCurrent", "Set Current Layer", [], None, "{id}", has_doc, set_current),
cmd!(
"layer.setProps",
"Layer Options…",
["Window", "Layers"],
None,
"{id, name?, visible?, locked?, template?, printable?, color?: index 0..26}",
has_doc,
set_props
),
cmd!("layer.collectInNew", "Collect in New Layer", ["Window", "Layers"], None, "{}", has_selection, collect),
cmd!("layer.selectAll", "Select All Art on Layer", [], None, "{id}", has_doc, select_all_on),
cmd!("node.move", "Reorder", [], None, "{id, parent?: id (null = top level), index} drag-reorder in the Layers panel", has_doc, move_node),
cmd!(
"artboard.new",
"New Artboard",
["Window", "Artboards"],
None,
"{x?, y?, width?, height?, name?} (default: right of the last)",
has_doc,
artboard_new
),
cmd!("artboard.delete", "Delete Artboard", ["Window", "Artboards"], None, "{index}", has_doc, artboard_delete),
cmd!(
"artboard.setProps",
"Artboard Options…",
["Window", "Artboards"],
None,
"{index, name?, x?, y?, width?, height?}",
has_doc,
artboard_set
),
cmd!("artboard.fitToArt", "Fit to Artwork Bounds", ["Object", "Artboards"], None, "{index?}", has_doc, artboard_fit_art),
cmd!("artboard.fitToSelection", "Fit to Selected Art", ["Object", "Artboards"], None, "{index?}", has_selection, artboard_fit_sel),
]
}
fn new_layer(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").map(str::to_string);
let st = s.doc()?;
let cur = st.active_layer.and_then(|l| st.doc.position(l)).filter(|(par, _, _)| par.is_none()).map(|(_, i, _)| i);
let id = s.edit("New Layer", |d, _| {
let id = d.add_layer(name.as_deref());
if let Some(i) = cur {
d.move_node(id, None, i + 1)?;
}
Ok(id)
})?;
s.doc_mut()?.active_layer = Some(id);
Ok(json!({ "id": id.0 }))
}
fn new_sublayer(s: &mut Session, p: &Value) -> Result<Value> {
let parent = id_param(p, "parent").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.newSublayer", "no parent layer"))?;
let name = str_param(p, "name").map(str::to_string);
let id = s.edit("New Sublayer", |d, _| {
let n = d.node(parent).and_then(|n| n.children()).map(|c| c.iter().filter(|c| c.is_layer()).count()).unwrap_or(0);
let id = d.alloc_id();
let color = LayerColor::Preset(((d.layers.len() + n + 1) % 27) as u8);
let nm = name.unwrap_or_else(|| format!("Layer {}", d.node_count()));
d.insert(Some(parent), usize::MAX, drawcraft_doc::Node::layer(id, &nm, color))?;
Ok(id)
})?;
Ok(json!({ "id": id.0 }))
}
fn delete_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.delete", "no layer"))?;
let st = s.doc()?;
if st.doc.layers.len() == 1 && st.doc.layers[0].id == id {
return Err(EngineError::Other("a document needs at least one layer".into()));
}
s.edit("Delete Layer", |d, sel| {
d.remove(id)?;
sel.prune(d);
Ok(())
})?;
let st = s.doc_mut()?;
if st.active_layer == Some(id) || st.active_layer.is_some_and(|l| st.doc.node(l).is_none()) {
st.active_layer = st.doc.default_layer();
}
ok()
}
fn duplicate_layer(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").or(s.doc()?.active_layer).ok_or_else(|| bad("layer.duplicate", "no layer"))?;
let nid = s.edit("Duplicate Layer", |d, _| {
let (par, idx, _) = d.position(id).ok_or(EngineError::NoNode(id))?;
let n = d.node(id).cloned().ok_or(EngineError::NoNode(id))?;
let mut c = d.reid(&n);
c.name = Some(format!("{} copy", n.display_name()));
Ok(d.insert(par, idx + 1, c)?)
})?;
Ok(json!({ "id": nid.0 }))
}
fn set_current(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setCurrent", "missing id"))?;
let st = s.doc_mut()?;
if !st.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("layer.setCurrent", "not a layer"));
}
st.active_layer = Some(id);
st.revision += 1;
ok()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.setProps", "missing id"))?;
s.edit("Layer Options", |d, sel| {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
if let Some(v) = str_param(p, "name") {
n.name = Some(v.to_string());
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let NodeKind::Layer { template, printable, color, .. } = &mut n.kind {
if let Some(v) = p.get("template").and_then(Value::as_bool) {
*template = v;
}
if let Some(v) = p.get("printable").and_then(Value::as_bool) {
*printable = v;
}
if let Some(v) = p.get("color").and_then(Value::as_u64) {
*color = LayerColor::Preset((v % 27) as u8);
}
}
// Hidden or locked content can't stay selected.
let hidden: Vec<NodeId> = sel.objects.iter().copied().filter(|o| !d.is_editable(*o)).collect();
for h in hidden {
sel.remove(h);
}
Ok(())
})?;
ok()
}
fn collect(s: &mut Session, _: &Value) -> Result<Value> {
let ids = super::edit::selected_roots(s)?;
let lid = s.edit("Collect in New Layer", |d, _| {
let lid = d.add_layer(None);
for id in &ids {
d.move_node(*id, Some(lid), usize::MAX)?;
}
Ok(lid)
})?;
Ok(json!({ "id": lid.0 }))
}
fn select_all_on(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("layer.selectAll", "missing id"))?;
let ids: Vec<NodeId> = s
.doc()?
.doc
.node(id)
.and_then(|n| n.children())
.map(|c| c.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect())
.unwrap_or_default();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn move_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("node.move", "missing id"))?;
let parent = id_param(p, "parent");
let index = p.get("index").and_then(Value::as_u64).unwrap_or(u64::MAX) as usize;
if parent.is_none() && !s.doc()?.doc.node(id).is_some_and(|n| n.is_layer()) {
return Err(bad("node.move", "only layers can be at the top level"));
}
s.edit("Reorder", |d, _| Ok(d.move_node(id, parent, index)?))?;
ok()
}
fn rect_from(p: &Value, default: Rect) -> Rect {
let x = f64_or(p, "x", default.x0);
let y = f64_or(p, "y", default.y0);
let w = f64_or(p, "width", default.width()).max(1.0);
let h = f64_or(p, "height", default.height()).max(1.0);
Rect::new(x, y, x + w, y + h)
}
fn artboard_new(s: &mut Session, p: &Value) -> Result<Value> {
let d0 = &s.doc()?.doc;
let last = d0.artboards.last().map(|a| a.rect).unwrap_or(Rect::new(0.0, 0.0, 612.0, 792.0));
let def = Rect::new(last.x1 + 20.0, last.y0, last.x1 + 20.0 + last.width(), last.y1);
let r = rect_from(p, def);
let idx = s.edit("New Artboard", |d, _| {
let id = d.next_artboard_id();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Artboard {}", d.artboards.len() + 1));
d.artboards.push(Artboard { id, name, rect: r, show_center_mark: false, show_cross_hairs: false });
Ok(d.artboards.len() - 1)
})?;
Ok(json!({ "index": idx }))
}
fn artboard_delete(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.delete", "missing index"))? as usize;
s.edit("Delete Artboard", |d, _| {
if d.artboards.len() <= 1 {
return Err(EngineError::Other("a document needs at least one artboard".into()));
}
if i >= d.artboards.len() {
return Err(EngineError::Other("no such artboard".into()));
}
d.artboards.remove(i);
Ok(())
})?;
ok()
}
fn artboard_set(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
s.edit("Artboard Options", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = rect_from(p, a.rect);
if let Some(n) = str_param(p, "name") {
a.name = n.to_string();
}
Ok(())
})?;
ok()
}
fn artboard_fit_art(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let b = s.doc()?.doc.art_bounds().ok_or_else(|| EngineError::Other("no artwork".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
fn artboard_fit_sel(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).unwrap_or(0) as usize;
let st = s.doc()?;
let b = st.doc.bounds_of(&st.selection.objects, true).ok_or_else(|| EngineError::Other("no selection bounds".into()))?;
s.execute("artboard.setProps", &json!({"index": i, "x": b.x0, "y": b.y0, "width": b.width(), "height": b.height()}))
}
+194
View File
@@ -0,0 +1,194 @@
//! The command registry. Ids follow Illustrator's menu structure.
mod create;
mod edit;
mod layer;
mod object;
mod paint;
mod path;
mod select;
use drawcraft_color::Color;
use drawcraft_doc::NodeId;
use drawcraft_geom::{Affine, Point};
use serde::Serialize;
use serde_json::Value;
use crate::{EngineError, Result, Session};
pub type Run = fn(&mut Session, &Value) -> Result<Value>;
pub type Enabled = fn(&Session) -> std::result::Result<(), String>;
/// Metadata + implementation for one command.
pub struct CommandSpec {
pub id: &'static str,
pub label: &'static str,
/// Menu placement, e.g. `["Object", "Arrange"]`. Empty = not in menus.
pub menu: &'static [&'static str],
/// Default shortcut (`Cmd+Shift+]`), mapped per platform by the UI.
pub shortcut: Option<&'static str>,
/// Human/agent-readable parameter description.
pub params: &'static str,
pub enabled: Enabled,
pub run: Run,
/// Record in the journal (false for queries and selection-only helpers).
pub journal: bool,
}
/// Serializable command metadata.
#[derive(Clone, Debug, Serialize)]
pub struct CommandInfo {
pub id: &'static str,
pub label: &'static str,
pub menu: Vec<&'static str>,
pub shortcut: Option<&'static str>,
pub params: &'static str,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<String>,
}
impl CommandSpec {
pub fn info(&self, s: &Session) -> CommandInfo {
let e = (self.enabled)(s);
CommandInfo {
id: self.id,
label: self.label,
menu: self.menu.to_vec(),
shortcut: self.shortcut,
params: self.params,
enabled: e.is_ok(),
disabled_reason: e.err(),
}
}
}
// ---------- enablement predicates ----------
pub fn always(_: &Session) -> std::result::Result<(), String> {
Ok(())
}
pub fn has_doc(s: &Session) -> std::result::Result<(), String> {
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
}
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) }
}
pub fn has_multi(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.active().unwrap().selection.len() < 2 { Err("select at least two objects".into()) } else { Ok(()) }
}
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.undo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to undo".into())
}
pub fn can_redo(s: &Session) -> std::result::Result<(), String> {
s.active().filter(|d| !d.history.redo.is_empty()).map(|_| ()).ok_or_else(|| "nothing to redo".into())
}
pub fn has_clipboard(s: &Session) -> std::result::Result<(), String> {
has_doc(s)?;
if s.clipboard.is_empty() { Err("clipboard is empty".into()) } else { Ok(()) }
}
macro_rules! cmd {
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true }
};
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false }
};
}
pub(crate) use cmd;
pub fn command_specs() -> &'static [CommandSpec] {
static SPECS: std::sync::OnceLock<Vec<CommandSpec>> = std::sync::OnceLock::new();
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(create::specs());
v.extend(object::specs());
v.extend(path::specs());
v.extend(select::specs());
v.extend(paint::specs());
v.extend(layer::specs());
v
})
}
pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
command_specs().iter().find(|c| c.id == id)
}
// ---------- param helpers ----------
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
pub(crate) fn f64_or(p: &Value, key: &str, default: f64) -> f64 {
p.get(key).and_then(Value::as_f64).unwrap_or(default)
}
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
}
pub(crate) fn bool_or(p: &Value, key: &str, default: bool) -> bool {
p.get(key).and_then(Value::as_bool).unwrap_or(default)
}
pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
p.get(key).and_then(Value::as_str)
}
pub(crate) fn id_param(p: &Value, key: &str) -> Option<NodeId> {
p.get(key).and_then(Value::as_u64).map(NodeId)
}
pub(crate) fn ids_param(p: &Value, key: &str) -> Option<Vec<NodeId>> {
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(NodeId).collect())
}
pub(crate) fn point_param(p: &Value, key: &str) -> Option<Point> {
let a = p.get(key)?.as_array()?;
Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
}
/// `[a, b, c, d, e, f]` affine coefficients.
pub fn matrix_param(p: &Value, key: &str) -> Option<Affine> {
let a = p.get(key)?.as_array()?;
if a.len() != 6 {
return None;
}
let mut c = [0.0; 6];
for (i, v) in a.iter().enumerate() {
c[i] = v.as_f64()?;
}
Some(Affine::new(c))
}
/// A colour from `"#rrggbb"`, `[r,g,b]` (0..1), `{"c":..,"m":..,"y":..,"k":..}` or `{"gray":..}`.
pub(crate) fn color_value(v: &Value) -> Option<Color> {
match v {
Value::String(s) => Color::from_hex(s),
Value::Array(a) if a.len() >= 3 => Some(Color::rgb(a[0].as_f64()? as f32, a[1].as_f64()? as f32, a[2].as_f64()? as f32)),
Value::Object(o) => {
if let (Some(c), Some(m), Some(y), Some(k)) = (o.get("c"), o.get("m"), o.get("y"), o.get("k")) {
let f = |v: &Value| v.as_f64().map(|x| if x > 1.0 { x / 100.0 } else { x } as f32);
return Some(Color::cmyk(f(c)?, f(m)?, f(y)?, f(k)?));
}
if let Some(g) = o.get("gray") {
let g = g.as_f64()?;
return Some(Color::gray(if g > 1.0 { g / 100.0 } else { g } as f32));
}
serde_json::from_value(v.clone()).ok()
}
_ => None,
}
}
/// Objects a command targets: explicit `ids` param or the selection.
pub(crate) fn targets(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if let Some(ids) = ids_param(p, "ids") {
return Ok(ids);
}
if let Some(id) = id_param(p, "id") {
return Ok(vec![id]);
}
Ok(s.doc()?.selection.objects.clone())
}
pub(crate) fn ok() -> Result<Value> {
Ok(Value::Null)
}
+790
View File
@@ -0,0 +1,790 @@
//! Object menu: transforms, arrange, group, lock/hide, compound paths, clipping masks, isolation,
//! align & distribute, object properties.
use std::sync::Arc;
use drawcraft_color::{BlendMode, Paint};
use drawcraft_doc::{Appearance, Document, Node, NodeId, NodeKind};
use drawcraft_geom::{Affine, FillRule, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::edit::{duplicate_in, selected_roots};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"object.transform",
"Transform",
[],
None,
"{matrix: [a,b,c,d,e,f], copy?: bool, ids?} apply an affine to the selection (or ids)",
has_doc,
transform
),
cmd!("object.move", "Move…", ["Object", "Transform"], Some("Cmd+Shift+M"), "{dx, dy, copy?}", has_selection, move_cmd),
cmd!(
"object.rotate",
"Rotate…",
["Object", "Transform"],
None,
"{angle: deg (counter-clockwise), origin?: [x,y], copy?}",
has_selection,
rotate
),
cmd!("object.scale", "Scale…", ["Object", "Transform"], None, "{sx: %, sy?: %, origin?: [x,y], copy?, strokes?: bool}", has_selection, scale),
cmd!(
"object.reflect",
"Reflect…",
["Object", "Transform"],
None,
"{axis: \"vertical\"|\"horizontal\"|deg, origin?, copy?}",
has_selection,
reflect
),
cmd!(
"object.shear",
"Shear…",
["Object", "Transform"],
None,
"{angle: deg, axis?: \"horizontal\"|\"vertical\", origin?, copy?}",
has_selection,
shear
),
cmd!("object.transformAgain", "Transform Again", ["Object", "Transform"], Some("Cmd+D"), "{}", has_selection, transform_again),
cmd!(
"object.nudge",
"Nudge",
[],
None,
"{dx: -1|0|1, dy: -1|0|1, big?: bool (×10), copy?: bool} arrow-key nudge by the keyboard increment",
has_selection,
nudge
),
cmd!("object.arrange.bringToFront", "Bring to Front", ["Object", "Arrange"], Some("Cmd+Shift+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Front
)),
cmd!("object.arrange.bringForward", "Bring Forward", ["Object", "Arrange"], Some("Cmd+]"), "{}", has_selection, |s, _| arrange(
s,
Arrange::Forward
)),
cmd!("object.arrange.sendBackward", "Send Backward", ["Object", "Arrange"], Some("Cmd+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Backward
)),
cmd!("object.arrange.sendToBack", "Send to Back", ["Object", "Arrange"], Some("Cmd+Shift+["), "{}", has_selection, |s, _| arrange(
s,
Arrange::Back
)),
cmd!("object.arrange.sendToCurrentLayer", "Send to Current Layer", ["Object", "Arrange"], None, "{}", has_selection, send_to_current_layer),
cmd!("object.group", "Group", ["Object"], Some("Cmd+G"), "{} → {id}", has_selection, group),
cmd!("object.ungroup", "Ungroup", ["Object"], Some("Cmd+Shift+G"), "{}", has_selection, ungroup),
cmd!("object.lock", "Selection", ["Object", "Lock"], Some("Cmd+2"), "{}", has_selection, lock),
cmd!("object.unlockAll", "Unlock All", ["Object"], Some("Cmd+Alt+2"), "{}", has_doc, unlock_all),
cmd!("object.hide", "Selection", ["Object", "Hide"], Some("Cmd+3"), "{}", has_selection, hide),
cmd!("object.showAll", "Show All", ["Object"], Some("Cmd+Alt+3"), "{}", has_doc, show_all),
cmd!("object.compoundPath.make", "Make", ["Object", "Compound Path"], Some("Cmd+8"), "{}", has_selection, compound_make),
cmd!("object.compoundPath.release", "Release", ["Object", "Compound Path"], Some("Cmd+Alt+Shift+8"), "{}", has_selection, compound_release),
cmd!("object.clippingMask.make", "Make", ["Object", "Clipping Mask"], Some("Cmd+7"), "{}", has_multi, clip_make),
cmd!("object.clippingMask.release", "Release", ["Object", "Clipping Mask"], Some("Cmd+Alt+7"), "{}", has_selection, clip_release),
cmd!("object.isolate", "Enter Isolation Mode", [], None, "{id}", has_doc, isolate),
cmd!("object.exitIsolation", "Exit Isolation Mode", [], None, "{}", has_doc, exit_isolation),
cmd!(
"object.setProps",
"Object Properties",
[],
None,
"{ids?|id?, name?, visible?, locked?, opacity?: 0..1 or %, blend?: \"Multiply\"…, isolate?, knockout?}",
has_doc,
set_props
),
cmd!(
"object.align",
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\"}",
has_selection,
align
),
cmd!(
"object.distribute",
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\"}",
has_multi,
distribute
),
cmd!(
"object.distributeSpacing",
"Distribute Spacing",
["Window", "Align"],
None,
"{axis: \"horizontal\"|\"vertical\", spacing?: pt}",
has_multi,
distribute_spacing
),
cmd!(
"object.setBounds",
"Set Bounds",
[],
None,
"{x?, y?, width?, height?, reference?: 0..8 (9-point grid), proportional?} (Transform panel)",
has_selection,
set_bounds
),
cmd!("object.expandShape", "Expand Shape", ["Object", "Shape"], None, "{} convert live shapes to plain paths", has_selection, expand_shape),
cmd!("object.setLiveShape", "Live Shape Properties", [], None, "{id?, radius?: pt (all corners), sides?: n}", has_selection, set_live_shape),
]
}
fn origin_of(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Point> {
if let Some(o) = point_param(p, "origin") {
return Ok(o);
}
s.doc()?.doc.bounds_of(ids, false).map(|b| b.center()).ok_or_else(|| EngineError::Other("selection has no bounds".into()))
}
/// Apply `xf` to `ids` (copy = duplicate first). Records Transform Again.
pub(crate) fn apply_transform(s: &mut Session, label: &str, ids: Vec<NodeId>, xf: Affine, copy: bool) -> Result<Value> {
let scale_strokes = s.prefs.scale_strokes;
let ids = s.edit(label, |d, sel| {
let targets = if copy { duplicate_in(d, sel, &ids, Affine::IDENTITY)? } else { ids.clone() };
for id in &targets {
if let Some(n) = d.node_mut(*id) {
n.transform(xf, scale_strokes);
}
}
Ok(targets)
})?;
let st = s.doc_mut()?;
if st.interaction.is_none() {
st.last_transform = Some((xf, copy));
}
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn transform(s: &mut Session, p: &Value) -> Result<Value> {
let m = matrix_param(p, "matrix").ok_or_else(|| bad("object.transform", "missing matrix [a,b,c,d,e,f]"))?;
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
apply_transform(s, "Transform", ids, m, bool_or(p, "copy", false))
}
fn move_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0))), bool_or(p, "copy", false))
}
fn nudge(s: &mut Session, p: &Value) -> Result<Value> {
let k = s.prefs.keyboard_increment * if bool_or(p, "big", false) { 10.0 } else { 1.0 };
let dx = f64_or(p, "dx", 0.0) * k;
let dy = f64_or(p, "dy", 0.0) * k;
if !s.doc()?.selection.anchors.is_empty() {
return super::path::move_anchors(s, &json!({ "dx": dx, "dy": dy }));
}
let ids = selected_roots(s)?;
apply_transform(s, "Move", ids, Affine::translate((dx, dy)), bool_or(p, "copy", false))
}
fn about(o: Point, a: Affine) -> Affine {
Affine::translate(o.to_vec2()) * a * Affine::translate(-o.to_vec2())
}
fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
// Illustrator angles are counter-clockwise; y is down, so negate.
let a = about(o, Affine::rotate(-f64_or(p, "angle", 0.0).to_radians()));
apply_transform(s, "Rotate", ids, a, bool_or(p, "copy", false))
}
fn scale(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let sx = f64_req(p, "sx", "object.scale")? / 100.0;
let sy = f64_or(p, "sy", sx * 100.0) / 100.0;
if sx == 0.0 || sy == 0.0 {
return Err(bad("object.scale", "scale must be non-zero"));
}
let prev = s.prefs.scale_strokes;
if let Some(b) = p.get("strokes").and_then(Value::as_bool) {
s.prefs.scale_strokes = b;
}
let r = apply_transform(s, "Scale", ids, about(o, Affine::scale_non_uniform(sx, sy)), bool_or(p, "copy", false));
s.prefs.scale_strokes = prev;
r
}
fn reflect(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let m = match p.get("axis") {
Some(Value::String(a)) if a == "horizontal" => Affine::scale_non_uniform(1.0, -1.0),
Some(Value::Number(n)) => {
let t = -n.as_f64().unwrap_or(90.0).to_radians();
Affine::rotate(t) * Affine::scale_non_uniform(1.0, -1.0) * Affine::rotate(-t)
}
_ => Affine::scale_non_uniform(-1.0, 1.0),
};
apply_transform(s, "Reflect", ids, about(o, m), bool_or(p, "copy", false))
}
fn shear(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let o = origin_of(s, p, &ids)?;
let t = f64_or(p, "angle", 0.0).clamp(-89.0, 89.0).to_radians().tan();
let m =
if str_param(p, "axis") == Some("vertical") { Affine::new([1.0, t, 0.0, 1.0, 0.0, 0.0]) } else { Affine::new([1.0, 0.0, -t, 1.0, 0.0, 0.0]) };
apply_transform(s, "Shear", ids, about(o, m), bool_or(p, "copy", false))
}
fn transform_again(s: &mut Session, _: &Value) -> Result<Value> {
let (m, copy) = s.doc()?.last_transform.ok_or_else(|| EngineError::Other("no previous transform".into()))?;
let ids = selected_roots(s)?;
apply_transform(s, "Transform Again", ids, m, copy)
}
enum Arrange {
Front,
Forward,
Backward,
Back,
}
fn arrange(s: &mut Session, how: Arrange) -> Result<Value> {
let mut ids = selected_roots(s)?;
if matches!(how, Arrange::Forward | Arrange::Front) {
ids.reverse();
}
let label = match how {
Arrange::Front => "Bring to Front",
Arrange::Forward => "Bring Forward",
Arrange::Backward => "Send Backward",
Arrange::Back => "Send to Back",
};
s.edit(label, |d, _| {
let n = ids.len();
for (k, id) in ids.iter().enumerate() {
let Some((par, idx, len)) = d.position(*id) else { continue };
let to = match how {
Arrange::Front => len - 1 - k,
Arrange::Back => k,
Arrange::Forward => (idx + 1).min(len - 1 - k.min(len - 1)),
Arrange::Backward => idx.saturating_sub(1).max(k.min(n)),
};
if to != idx {
d.move_node(*id, par, to)?;
}
}
Ok(())
})?;
ok()
}
fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let layer = s.doc()?.active_layer.ok_or_else(|| EngineError::Other("no current layer".into()))?;
s.edit("Send to Current Layer", |d, _| {
for id in &ids {
d.move_node(*id, Some(layer), usize::MAX)?;
}
Ok(())
})?;
ok()
}
fn group(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let gid = s.edit("Group", |d, sel| {
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::group(gid, vec![]))?;
for id in &ids {
d.move_node(*id, Some(gid), usize::MAX)?;
}
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Ungroup", |d, sel| {
let mut new_sel = vec![];
for id in &ids {
let Some(n) = d.node(*id) else { continue };
let NodeKind::Group { children, clip } = &n.kind else {
new_sel.push(*id);
continue;
};
let children = children.clone();
let clip = *clip;
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
let opacity = n.opacity;
d.remove(*id)?;
for (k, c) in children.into_iter().enumerate() {
let mut c = (*c).clone();
if clip && k == 0 {
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = false;
}
c.appearance = Appearance::default();
}
c.opacity *= opacity;
new_sel.push(c.id);
d.insert(par, idx + k, c)?;
}
}
sel.set(new_sel);
Ok(())
})?;
ok()
}
fn lock(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Lock", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = true;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn collect_flag(d: &Document, f: impl Fn(&Node) -> bool) -> Vec<NodeId> {
let mut v = vec![];
d.walk(|n| {
if !n.is_layer() && f(n) {
v.push(n.id)
}
});
v
}
fn unlock_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| n.locked);
s.edit("Unlock All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.locked = false;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn hide(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Hide", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = false;
}
}
sel.clear();
Ok(())
})?;
ok()
}
fn show_all(s: &mut Session, _: &Value) -> Result<Value> {
let ids = collect_flag(&s.doc()?.doc, |n| !n.visible);
s.edit("Show All", |d, sel| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.visible = true;
}
}
sel.set(ids.iter().copied());
Ok(())
})?;
Ok(json!({ "count": ids.len() }))
}
fn compound_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
let id = s.edit("Make Compound Path", |d, sel| {
let appearance = d.node(top).map(|n| n.appearance.clone()).unwrap_or_default();
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let mut paths: Vec<Arc<Node>> = vec![];
for id in &ids {
let n = d.node(*id).cloned().ok_or(EngineError::NoNode(*id))?;
match &n.kind {
NodeKind::Path { .. } => paths.push(Arc::new(n)),
NodeKind::Compound { children, .. } => paths.extend(children.iter().cloned()),
NodeKind::Group { .. } => {
n.walk(&mut |c| {
if matches!(c.kind, NodeKind::Path { .. }) {
paths.push(Arc::new(c.clone()))
}
});
}
_ => return Err(EngineError::Other("compound paths can only contain paths".into())),
}
}
let cid = d.alloc_id();
let mut c = Node::new(cid, NodeKind::Compound { children: vec![], rule: FillRule::NonZero });
c.appearance = appearance;
d.insert(par, idx + 1, c)?;
for id in &ids {
d.remove(*id)?;
}
let ch = d.node_mut(cid).unwrap().children_mut().unwrap();
for mut p in paths {
let pn = Arc::make_mut(&mut p);
pn.appearance = Appearance::default();
if let NodeKind::Path { live, .. } = &mut pn.kind {
*live = None;
}
ch.push(p);
}
sel.set([cid]);
Ok(cid)
})?;
Ok(json!({ "id": id.0 }))
}
fn compound_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Release Compound Path", |d, sel| {
let mut out = vec![];
for id in &ids {
let Some(n) = d.node(*id).cloned() else { continue };
let NodeKind::Compound { children, .. } = &n.kind else { continue };
let (par, idx, _) = d.position(*id).ok_or(EngineError::NoNode(*id))?;
d.remove(*id)?;
for (k, c) in children.iter().enumerate() {
let mut c = (**c).clone();
c.appearance = n.appearance.clone();
out.push(c.id);
d.insert(par, idx + k, c)?;
}
}
sel.set(out);
Ok(())
})?;
ok()
}
fn clip_make(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let top = *ids.last().unwrap();
let gid = s.edit("Make Clipping Mask", |d, sel| {
if !matches!(d.node(top).map(|n| &n.kind), Some(NodeKind::Path { .. })) {
return Err(EngineError::Other("the top object must be a path to use as a clipping mask".into()));
}
let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?;
let gid = d.alloc_id();
d.insert(par, idx + 1, Node::new(gid, NodeKind::Group { children: vec![], clip: true }))?;
d.move_node(top, Some(gid), 0)?;
for id in &ids[..ids.len() - 1] {
d.move_node(*id, Some(gid), usize::MAX)?;
}
if let Some(c) = d.node_mut(top) {
c.appearance = Appearance::basic(Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut c.kind {
*clipping = true;
}
}
sel.set([gid]);
Ok(gid)
})?;
Ok(json!({ "id": gid.0 }))
}
fn clip_release(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Release Clipping Mask", |d, _| {
for id in &ids {
let clip_child = match d.node(*id).map(|n| &n.kind) {
Some(NodeKind::Group { children, clip: true }) => children.first().map(|c| c.id),
_ => continue,
};
if let Some(NodeKind::Group { clip, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*clip = false;
}
if let Some(c) = clip_child.and_then(|c| d.node_mut(c))
&& let NodeKind::Path { clipping, .. } = &mut c.kind
{
*clipping = false;
}
}
Ok(())
})?;
ok()
}
fn isolate(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id")
.or_else(|| s.active().and_then(|d| d.selection.objects.first().copied()))
.ok_or_else(|| bad("object.isolate", "missing id"))?;
let st = s.doc_mut()?;
if st.doc.node(id).is_none_or(|n| !n.is_container()) {
return Err(bad("object.isolate", "only groups and layers can be isolated"));
}
st.isolation = Some(id);
st.selection.clear();
st.revision += 1;
ok()
}
fn exit_isolation(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
if let Some(i) = st.isolation.take() {
st.selection.set([i]);
}
st.revision += 1;
ok()
}
fn set_props(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let opacity = p.get("opacity").and_then(Value::as_f64).map(|o| if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32);
let blend = match str_param(p, "blend") {
Some(b) => Some(BlendMode::parse(b).ok_or_else(|| bad("object.setProps", format!("unknown blend mode `{b}`")))?),
None => None,
};
s.edit("Object Properties", |d, _| {
for id in &ids {
let n = d.node_mut(*id).ok_or(EngineError::NoNode(*id))?;
if let Some(v) = str_param(p, "name") {
n.name = if v.is_empty() { None } else { Some(v.to_string()) };
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
n.visible = v;
}
if let Some(v) = p.get("locked").and_then(Value::as_bool) {
n.locked = v;
}
if let Some(v) = opacity {
n.opacity = v;
}
if let Some(b) = blend {
n.blend = b;
}
if let Some(v) = p.get("isolate").and_then(Value::as_bool) {
n.isolate = v;
}
if let Some(v) = p.get("knockout").and_then(Value::as_bool) {
n.knockout = v;
}
}
Ok(())
})?;
ok()
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId]) -> Result<Rect> {
let st = s.doc()?;
match str_param(p, "to") {
Some("artboard") => {
let b = st.doc.bounds_of(ids, false).unwrap_or_default();
let i = st.doc.artboard_at(b.center()).unwrap_or(0);
Ok(st.doc.artboards.get(i).map(|a| a.rect).unwrap_or(b))
}
Some("key") => {
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
st.doc.node(k).and_then(|n| n.geometric_bounds()).ok_or(EngineError::NoNode(k))
}
_ => st.doc.bounds_of(ids, false).ok_or_else(|| EngineError::Other("nothing to align".into())),
}
}
fn align(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let r = reference_rect(s, p, &ids)?;
let h = str_param(p, "horizontal");
let v = str_param(p, "vertical");
let key = s.doc()?.selection.key;
let moves: Vec<(NodeId, Vec2)> = {
let d = &s.doc()?.doc;
ids.iter()
.filter(|id| Some(**id) != key || str_param(p, "to") != Some("key"))
.filter_map(|id| {
let b = d.node(*id)?.geometric_bounds()?;
let dx = match h {
Some("left") => r.x0 - b.x0,
Some("center") => r.center().x - b.center().x,
Some("right") => r.x1 - b.x1,
_ => 0.0,
};
let dy = match v {
Some("top") => r.y0 - b.y0,
Some("center") => r.center().y - b.center().y,
Some("bottom") => r.y1 - b.y1,
_ => 0.0,
};
Some((*id, Vec2::new(dx, dy)))
})
.collect()
};
s.edit("Align", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let d0 = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match (str_param(p, "horizontal"), str_param(p, "vertical")) {
(Some(h), _) => (
true,
match h {
"left" => |r: &Rect, _| r.x0,
"right" => |r: &Rect, _| r.x1,
_ => |r: &Rect, _| r.center().x,
},
),
(_, Some(v)) => (
false,
match v {
"top" => |r: &Rect, _| r.y0,
"bottom" => |r: &Rect, _| r.y1,
_ => |r: &Rect, _| r.center().y,
},
),
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
};
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
if n < 3 {
return ok();
}
let first = key(&items[0].1, horiz);
let last = key(&items[n - 1].1, horiz);
let moves: Vec<(NodeId, Vec2)> = items
.iter()
.enumerate()
.map(|(i, (id, r))| {
let target = first + (last - first) * i as f64 / (n - 1) as f64;
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
})
.collect();
s.edit("Distribute", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let horiz = str_param(p, "axis") != Some("vertical");
let d0 = &s.doc()?.doc;
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|id| Some((*id, d0.node(*id)?.geometric_bounds()?))).collect();
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let n = items.len();
let size = |r: &Rect| if horiz { r.width() } else { r.height() };
let start = |r: &Rect| if horiz { r.x0 } else { r.y0 };
let gap = match p.get("spacing").and_then(Value::as_f64) {
Some(g) => g,
None => {
let span = if horiz { items[n - 1].1.x1 - items[0].1.x0 } else { items[n - 1].1.y1 - items[0].1.y0 };
(span - items.iter().map(|i| size(&i.1)).sum::<f64>()) / (n - 1) as f64
}
};
let mut pos = start(&items[0].1);
let mut moves = vec![];
for (id, r) in &items {
let delta = pos - start(r);
moves.push((*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) }));
pos += size(r) + gap;
}
s.edit("Distribute Spacing", |d, _| {
for (id, dv) in &moves {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(*dv), false);
}
}
Ok(())
})?;
ok()
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
let b = s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("no bounds".into()))?;
let refi = p.get("reference").and_then(Value::as_u64).unwrap_or(4) as usize;
let rp = drawcraft_geom::reference_point(b, refi);
let mut w = f64_or(p, "width", b.width());
let mut h = f64_or(p, "height", b.height());
if bool_or(p, "proportional", false) {
if p.get("width").is_some() && b.width() > 0.0 {
h = b.height() * w / b.width();
} else if p.get("height").is_some() && b.height() > 0.0 {
w = b.width() * h / b.height();
}
}
let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 };
let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 };
let scale = about(rp, Affine::scale_non_uniform(sx, sy));
let new_rp = scale * rp;
let tx = f64_or(p, "x", new_rp.x) - new_rp.x;
let ty = f64_or(p, "y", new_rp.y) - new_rp.y;
apply_transform(s, "Transform", ids, Affine::translate((tx, ty)) * scale, false)
}
fn expand_shape(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_roots(s)?;
s.edit("Expand Shape", |d, _| {
for id in &ids {
if let Some(NodeKind::Path { live, .. }) = d.node_mut(*id).map(|n| &mut n.kind) {
*live = None;
}
}
Ok(())
})?;
ok()
}
fn set_live_shape(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
s.edit("Live Shape", |d, _| {
for id in &ids {
let Some(NodeKind::Path { path, live: Some(live), .. }) = d.node_mut(*id).map(|n| &mut n.kind) else { continue };
match live {
drawcraft_doc::LiveShape::Rectangle { radii, .. } => {
if let Some(r) = p.get("radius").and_then(Value::as_f64) {
*radii = [r.max(0.0); 4];
}
}
drawcraft_doc::LiveShape::Polygon { sides, .. } => {
if let Some(n) = p.get("sides").and_then(Value::as_u64) {
*sides = n.clamp(3, 1000) as u32;
}
}
_ => {}
}
*path = live.to_path();
}
Ok(())
})?;
ok()
}
+494
View File
@@ -0,0 +1,494 @@
//! Fill/stroke, Stroke panel, Appearance, Transparency, Swatches, Graphic Styles.
use drawcraft_color::{BlendMode, Color, Gradient, GradientKind, GradientPaint, Paint};
use drawcraft_doc::{Appearance, AppearanceItem, Arrowhead, Dash, FillLayer, LineCap, LineJoin, NodeKind, StrokeAlign, StrokeLayer};
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"paint.setFill",
"Fill",
[],
None,
"{color?: \"#rrggbb\"|[r,g,b]|{c,m,y,k}|{gray}, none?: true, swatch?: name, gradient?: {kind, stops:[{offset,color}], angle?}, ids?} sets selection fill and the default",
has_doc,
|s, p| set_paint(s, p, true)
),
cmd!("paint.setStroke", "Stroke", [], None, "same as paint.setFill, for the stroke", has_doc, |s, p| set_paint(s, p, false)),
cmd!("paint.swap", "Swap Fill and Stroke", [], Some("Shift+X"), "{}", has_doc, swap),
cmd!("paint.default", "Default Fill and Stroke", [], Some("D"), "{}", has_doc, default_paint),
cmd!("paint.toggleActive", "Toggle Fill/Stroke Focus", [], Some("X"), "{}", always, |s, _| {
s.fill_active = !s.fill_active;
Ok(json!({ "fillActive": s.fill_active }))
}),
cmd!("paint.none", "None", [], Some("/"), "{} set the active proxy (fill or stroke) to None", has_doc, |s, _| {
let f = s.fill_active;
set_paint(s, &json!({"none": true}), f)
}),
cmd!(
"stroke.set",
"Stroke Options",
["Window", "Stroke"],
None,
"{weight?, cap?: butt|round|square, join?: miter|round|bevel, miterLimit?, align?: center|inside|outside, dash?: [d,g,…]|null, dashOffset?, alignDashes?, startArrow?, endArrow?: name|null, profile?: \"uniform\"|\"lens\"|\"taperStart\"|\"taperEnd\", ids?}",
has_doc,
stroke_set
),
cmd!("appearance.addFill", "Add New Fill", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, true)),
cmd!("appearance.addStroke", "Add New Stroke", ["Window", "Appearance"], None, "{}", has_selection, |s, _| add_item(s, false)),
cmd!("appearance.clear", "Clear Appearance", ["Window", "Appearance"], None, "{}", has_selection, clear_appearance),
cmd!("appearance.reduceToBasic", "Reduce to Basic Appearance", ["Window", "Appearance"], None, "{}", has_selection, reduce_basic),
cmd!(
"appearance.setItem",
"Appearance Item",
[],
None,
"{index, opacity?, blend?, visible?, color?|none?} edit one fill/stroke of the selection's appearance stack",
has_selection,
set_item
),
cmd!("appearance.removeItem", "Remove Item", [], None, "{index}", has_selection, remove_item),
cmd!("appearance.addEffect", "Add Effect", ["Effect"], None, "{effect: id, params?: {…}} append a live effect", has_selection, add_effect),
cmd!(
"transparency.set",
"Transparency",
["Window", "Transparency"],
None,
"{opacity?: 0..100, blend?: name, isolate?, knockout?}",
has_selection,
transparency
),
cmd!("graphicStyle.apply", "Apply Graphic Style", ["Window", "Graphic Styles"], None, "{name}", has_selection, style_apply),
cmd!("graphicStyle.new", "New Graphic Style", ["Window", "Graphic Styles"], None, "{name?} from the selection", has_selection, style_new),
cmd!("swatch.new", "New Swatch", ["Window", "Swatches"], None, "{name?, color?, global?} (default: current fill)", has_doc, swatch_new),
cmd!("swatch.delete", "Delete Swatch", ["Window", "Swatches"], None, "{name}", has_doc, swatch_delete),
]
}
/// Parse a paint from params (color / none / swatch / gradient). None = no paint keys given.
pub(crate) fn paint_from(s: &Session, p: &Value) -> Result<Option<Paint>> {
if bool_or(p, "none", false) {
return Ok(Some(Paint::None));
}
if let Some(name) = str_param(p, "swatch") {
let st = s.doc()?;
let sw = st.doc.swatch(name).ok_or_else(|| EngineError::Other(format!("no swatch `{name}`")))?;
let mut paint = sw.paint.clone();
if sw.global
&& let Paint::Solid { swatch, .. } = &mut paint
{
*swatch = Some(name.to_string());
}
return Ok(Some(paint));
}
if let Some(g) = p.get("gradient") {
let kind = match str_param(g, "kind") {
Some("radial") => GradientKind::Radial,
_ => GradientKind::Linear,
};
let mut grad = Gradient { kind, ..Default::default() };
if let Some(stops) = g.get("stops").and_then(Value::as_array) {
grad.stops = stops
.iter()
.filter_map(|st| {
Some(drawcraft_color::GradientStop {
offset: st.get("offset")?.as_f64()? as f32,
color: color_value(st.get("color")?)?,
opacity: st.get("opacity").and_then(Value::as_f64).unwrap_or(1.0) as f32,
midpoint: 0.5,
})
})
.collect();
grad.sort();
}
let mut gp = GradientPaint::new(grad);
gp.angle = f64_or(g, "angle", 0.0);
return Ok(Some(Paint::Gradient(Box::new(gp))));
}
if let Some(c) = p.get("color") {
let c = color_value(c).ok_or_else(|| bad("paint", format!("bad color {c}")))?;
return Ok(Some(Paint::solid(c)));
}
Ok(None)
}
/// Leaf objects whose appearance should change for `ids` (groups apply to children).
fn paint_targets(s: &Session, p: &Value) -> Result<Vec<drawcraft_doc::NodeId>> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let mut out = vec![];
for id in ids {
let Some(n) = d.node(id) else { continue };
match &n.kind {
NodeKind::Group { .. } | NodeKind::Layer { .. } => n.walk(&mut |c| {
if !c.is_container() || matches!(c.kind, NodeKind::Compound { .. }) {
out.push(c.id)
}
}),
_ => out.push(id),
}
}
// Don't recurse into compound children (the compound owns the appearance).
let comp: Vec<_> = out.iter().filter(|id| matches!(d.node(**id).map(|n| &n.kind), Some(NodeKind::Compound { .. }))).copied().collect();
out.retain(|id| !comp.iter().any(|c| d.parent_of(*id) == Some(*c)));
Ok(out)
}
fn set_paint(s: &mut Session, p: &Value, fill: bool) -> Result<Value> {
let paint = paint_from(s, p)?.ok_or_else(|| bad("paint.setFill", "give color, none, swatch or gradient"))?;
if fill {
s.paint.fill = paint.clone();
} else {
s.paint.stroke = paint.clone();
}
s.fill_active = fill;
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
s.edit(if fill { "Fill Color" } else { "Stroke Color" }, |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
if fill {
r.style.fill = paint.clone();
} else {
r.style.stroke = paint.clone();
if r.style.stroke_width == 0.0 {
r.style.stroke_width = 1.0;
}
}
}
continue;
}
if fill {
n.appearance.set_fill(paint.clone());
} else {
n.appearance.set_stroke(paint.clone());
}
}
Ok(())
})?;
ok()
}
fn swap(s: &mut Session, _: &Value) -> Result<Value> {
std::mem::swap(&mut s.paint.fill, &mut s.paint.stroke);
let ids = paint_targets(s, &json!({}))?;
if !ids.is_empty() {
s.edit("Swap Fill and Stroke", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke_paint();
n.appearance.set_fill(st);
n.appearance.set_stroke(f);
}
}
Ok(())
})?;
}
ok()
}
fn default_paint(s: &mut Session, _: &Value) -> Result<Value> {
s.paint.fill = Paint::solid(Color::WHITE);
s.paint.stroke = Paint::solid(Color::BLACK);
s.paint.stroke_width = 1.0;
let ids = paint_targets(s, &json!({}))?;
if !ids.is_empty() {
s.edit("Default Fill and Stroke", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& !matches!(n.kind, NodeKind::Text(_))
{
n.appearance = Appearance::default_art();
}
}
Ok(())
})?;
}
ok()
}
fn stroke_set(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
s.paint.stroke_width = w.max(0.0);
}
let ids = paint_targets(s, p)?;
if ids.is_empty() {
return ok();
}
let arrow = |k: &str| -> Result<Option<Option<Arrowhead>>> {
match p.get(k) {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(n)) if n == "none" => Ok(Some(None)),
Some(v) => {
serde_json::from_value::<Arrowhead>(v.clone()).map(|a| Some(Some(a))).map_err(|_| bad("stroke.set", format!("unknown arrowhead {v}")))
}
}
};
let (sa, ea) = (arrow("startArrow")?, arrow("endArrow")?);
s.edit("Stroke", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
if n.appearance.stroke().is_none() {
n.appearance.set_stroke(Paint::solid(Color::BLACK));
}
let st: &mut StrokeLayer = n.appearance.stroke_mut().unwrap();
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
match str_param(p, "cap") {
Some("round") => st.cap = LineCap::Round,
Some("square") | Some("projecting") => st.cap = LineCap::Square,
Some("butt") => st.cap = LineCap::Butt,
_ => {}
}
match str_param(p, "join") {
Some("round") => st.join = LineJoin::Round,
Some("bevel") => st.join = LineJoin::Bevel,
Some("miter") => st.join = LineJoin::Miter,
_ => {}
}
if let Some(m) = p.get("miterLimit").and_then(Value::as_f64) {
st.miter_limit = m.clamp(1.0, 500.0);
}
match str_param(p, "align") {
Some("inside") => st.align = StrokeAlign::Inside,
Some("outside") => st.align = StrokeAlign::Outside,
Some("center") => st.align = StrokeAlign::Center,
_ => {}
}
match p.get("dash") {
Some(Value::Null) => st.dash = None,
Some(Value::Array(a)) => {
let pattern: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
st.dash = if pattern.is_empty() {
None
} else {
Some(Dash { pattern, offset: f64_or(p, "dashOffset", 0.0), align_corners: bool_or(p, "alignDashes", false) })
};
}
_ => {}
}
if let Some(a) = sa {
st.start_arrow = a;
}
if let Some(a) = ea {
st.end_arrow = a;
}
match str_param(p, "profile") {
Some("uniform") => st.profile = None,
Some("lens") => st.profile = Some(drawcraft_doc::WidthProfile::lens()),
Some("taperStart") => st.profile = Some(drawcraft_doc::WidthProfile::taper_start()),
Some("taperEnd") => st.profile = Some(drawcraft_doc::WidthProfile::taper_end()),
_ => {}
}
}
Ok(())
})?;
ok()
}
fn add_item(s: &mut Session, fill: bool) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit(if fill { "Add New Fill" } else { "Add New Stroke" }, |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(n.appearance.fill_paint()))
} else {
AppearanceItem::Stroke(StrokeLayer::new(n.appearance.stroke_paint(), n.appearance.stroke_width().max(1.0)))
};
n.appearance.items.push(item);
}
}
Ok(())
})?;
ok()
}
fn clear_appearance(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Clear Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = Appearance::basic(Paint::None, Paint::None, 1.0);
}
}
Ok(())
})?;
ok()
}
fn reduce_basic(s: &mut Session, _: &Value) -> Result<Value> {
let ids = paint_targets(s, &json!({}))?;
s.edit("Reduce to Basic Appearance", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
let f = n.appearance.fill_paint();
let st = n.appearance.stroke().cloned();
n.appearance = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(f))], effects: vec![] };
if let Some(mut st) = st {
st.effects.clear();
st.opacity = 1.0;
st.blend = BlendMode::Normal;
n.appearance.items.push(AppearanceItem::Stroke(st));
}
}
}
Ok(())
})?;
ok()
}
fn set_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.setItem", "missing index"))? as usize;
let paint = paint_from(s, p)?;
let ids = paint_targets(s, p)?;
let blend = str_param(p, "blend").and_then(BlendMode::parse);
s.edit("Appearance", |d, _| {
for id in &ids {
let Some(n) = d.node_mut(*id) else { continue };
let Some(item) = n.appearance.items.get_mut(idx) else { continue };
let (pp, op, bl, vis) = match item {
AppearanceItem::Fill(f) => (&mut f.paint, &mut f.opacity, &mut f.blend, &mut f.visible),
AppearanceItem::Stroke(st) => {
if let Some(w) = p.get("weight").and_then(Value::as_f64) {
st.width = w.max(0.0);
}
(&mut st.paint, &mut st.opacity, &mut st.blend, &mut st.visible)
}
};
if let Some(pa) = &paint {
*pp = pa.clone();
}
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
*op = if o > 1.0 { o / 100.0 } else { o }.clamp(0.0, 1.0) as f32;
}
if let Some(b) = blend {
*bl = b;
}
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
*vis = v;
}
}
Ok(())
})?;
ok()
}
fn remove_item(s: &mut Session, p: &Value) -> Result<Value> {
let idx = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("appearance.removeItem", "missing index"))? as usize;
let ids = paint_targets(s, p)?;
s.edit("Remove Item", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id)
&& idx < n.appearance.items.len()
{
n.appearance.items.remove(idx);
}
}
Ok(())
})?;
ok()
}
fn add_effect(s: &mut Session, p: &Value) -> Result<Value> {
let id = str_param(p, "effect").ok_or_else(|| bad("appearance.addEffect", "missing effect id"))?.to_string();
let params = p.get("params").cloned().unwrap_or(json!({}));
let ids = targets(s, p)?;
s.edit("Add Effect", |d, _| {
for nid in &ids {
if let Some(n) = d.node_mut(*nid) {
n.appearance.effects.push(drawcraft_doc::Effect { id: id.clone(), params: params.clone(), visible: true });
}
}
Ok(())
})?;
ok()
}
fn transparency(s: &mut Session, p: &Value) -> Result<Value> {
let mut q = p.clone();
if let Some(o) = p.get("opacity").and_then(Value::as_f64) {
q["opacity"] = json!(o / 100.0);
}
s.execute("object.setProps", &q)
}
fn style_apply(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("graphicStyle.apply", "missing name"))?;
let ap = s
.doc()?
.doc
.graphic_styles
.iter()
.find(|g| g.name == name)
.map(|g| g.appearance.clone())
.ok_or_else(|| EngineError::Other(format!("no graphic style `{name}`")))?;
let ids = paint_targets(s, p)?;
s.edit("Apply Graphic Style", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
n.appearance = ap.clone();
}
}
Ok(())
})?;
ok()
}
fn style_new(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let id = *st.selection.objects.first().unwrap();
let ap = st.doc.node(id).map(|n| n.appearance.clone()).unwrap_or_default();
let name = str_param(p, "name").map(str::to_string).unwrap_or_else(|| format!("Graphic Style {}", st.doc.graphic_styles.len() + 1));
s.edit("New Graphic Style", |d, _| {
d.graphic_styles.push(drawcraft_doc::GraphicStyle { name: name.clone(), appearance: ap });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_new(s: &mut Session, p: &Value) -> Result<Value> {
let paint = match paint_from(s, p)? {
Some(x) => x,
None => s.paint.fill.clone(),
};
let name = match str_param(p, "name") {
Some(n) => n.to_string(),
None => match &paint {
Paint::Solid { color, .. } => {
let [r, g, b] = color.to_rgb();
format!("R={} G={} B={}", (r * 255.0).round(), (g * 255.0).round(), (b * 255.0).round())
}
_ => format!("Swatch {}", s.doc()?.doc.swatches.len() + 1),
},
};
let global = bool_or(p, "global", false);
s.edit("New Swatch", |d, _| {
d.swatches.push(drawcraft_color::Swatch { name: name.clone(), paint, global, spot: false });
Ok(())
})?;
Ok(json!({ "name": name }))
}
fn swatch_delete(s: &mut Session, p: &Value) -> Result<Value> {
let name = str_param(p, "name").ok_or_else(|| bad("swatch.delete", "missing name"))?.to_string();
s.edit("Delete Swatch", |d, _| {
d.swatches.retain(|sw| sw.name != name);
for g in &mut d.swatch_groups {
g.swatches.retain(|sw| sw.name != name);
}
Ok(())
})?;
ok()
}
+472
View File
@@ -0,0 +1,472 @@
//! Path editing commands (used by the Pen and Direct Selection tools and Object → Path).
use std::collections::BTreeSet;
use drawcraft_doc::{NodeId, NodeKind};
use drawcraft_geom::{Anchor, AnchorKind, PathData, Point, SubPath, Vec2};
use serde_json::{Value, json};
use super::create::anchor_from_json;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"path.appendAnchor",
"Add Anchor (Pen)",
[],
None,
"{id, x, y, in?: [x,y], out?: [x,y]} append to the end of the last open subpath",
has_doc,
append_anchor
),
cmd!("path.close", "Close Path", [], None, "{id, in?: [x,y] (handle into the first anchor), independent?: bool}", has_doc, close),
cmd!(
"path.moveAnchors",
"Move Anchors",
[],
None,
"{dx, dy} move direct-selected anchors (whole paths if fully selected)",
has_selection,
move_anchors
),
cmd!(
"path.setHandle",
"Move Direction Handle",
[],
None,
"{id, subpath, anchor, which: \"in\"|\"out\", x, y, independent?: bool}",
has_doc,
set_handle
),
cmd!(
"path.setAnchors",
"Set Anchors",
[],
None,
"{id, subpaths: [{anchors: [{x,y,in?,out?}], closed}]} replace a path's geometry",
has_doc,
set_anchors
),
cmd!("path.reverse", "Reverse Path Direction", ["Object", "Path"], None, "{}", has_selection, reverse),
cmd!(
"path.join",
"Join",
["Object", "Path"],
Some("Cmd+J"),
"{} join two open paths' nearest endpoints, or close a single open path",
has_selection,
join
),
cmd!(
"path.average",
"Average…",
["Object", "Path"],
Some("Cmd+Alt+J"),
"{axis?: \"horizontal\"|\"vertical\"|\"both\"}",
has_selection,
average
),
cmd!(
"path.convertAnchors",
"Convert Anchor Points",
[],
None,
"{to: \"corner\"|\"smooth\"} (Control bar convert buttons)",
has_selection,
convert_anchors
),
cmd!(
"path.deleteAnchors",
"Remove Anchor Points",
["Object", "Path"],
None,
"{} delete direct-selected anchors",
has_selection,
delete_anchors
),
cmd!("path.insertAnchor", "Add Anchor Point", [], None, "{id, subpath, segment, t: 0..1}", has_doc, insert_anchor),
cmd!("path.cutAtAnchors", "Cut Path at Selected Anchor Points", [], None, "{}", has_selection, cut_at_anchors),
]
}
fn path_mut(d: &mut drawcraft_doc::Document, id: NodeId) -> Result<&mut PathData> {
let n = d.node_mut(id).ok_or(EngineError::NoNode(id))?;
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None; // editing anchors expands a live shape
Ok(path)
}
_ => Err(EngineError::Other(format!("object {id} is not a path"))),
}
}
fn append_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.appendAnchor", "missing id"))?;
let a = anchor_from_json(p).ok_or_else(|| bad("path.appendAnchor", "missing x/y"))?;
s.edit("Pen", |d, sel| {
let path = path_mut(d, id)?;
match path.subpaths.last_mut() {
Some(sp) if !sp.closed => sp.anchors.push(a),
_ => path.subpaths.push(SubPath::new(vec![a], false)),
}
sel.set([id]);
let last = path.subpaths.len() - 1;
let ai = path.subpaths[last].anchors.len() - 1;
sel.anchors.insert(id, BTreeSet::from([(last, ai)]));
Ok(())
})?;
ok()
}
fn close(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.close", "missing id"))?;
let h_in = point_param(p, "in");
let independent = bool_or(p, "independent", false);
s.edit("Close Path", |d, sel| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().rev().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
sp.closed = true;
if let (Some(h), Some(first)) = (h_in, sp.anchors.first_mut()) {
first.h_in = h;
if !independent {
first.h_out = first.p - (h - first.p);
first.kind = AnchorKind::Smooth;
}
}
sel.set([id]);
Ok(())
})?;
ok()
}
pub(crate) fn move_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let d = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let st = s.doc()?;
let sel = st.selection.clone();
let scale_strokes = s.prefs.scale_strokes;
let _ = scale_strokes;
s.edit("Move", |doc, _| {
for id in &sel.objects {
match sel.anchors.get(id) {
Some(set) => {
let path = path_mut(doc, *id)?;
for &(si, ai) in set {
if let Some(a) = path.anchor_mut(si, ai) {
a.translate(d);
}
}
}
None => {
if let Some(n) = doc.node_mut(*id) {
n.transform(drawcraft_geom::Affine::translate(d), false);
}
}
}
}
Ok(())
})?;
ok()
}
fn set_handle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setHandle", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let ai = p.get("anchor").and_then(Value::as_u64).ok_or_else(|| bad("path.setHandle", "missing anchor"))? as usize;
let out = str_param(p, "which") != Some("in");
let pos = Point::new(f64_req(p, "x", "path.setHandle")?, f64_req(p, "y", "path.setHandle")?);
let independent = bool_or(p, "independent", false);
s.edit("Reshape", |d, _| {
let path = path_mut(d, id)?;
let a = path.anchor_mut(si, ai).ok_or_else(|| EngineError::Other("no such anchor".into()))?;
if independent {
a.kind = AnchorKind::Corner;
}
let (moved, other) = if out { (&mut a.h_out, &mut a.h_in) } else { (&mut a.h_in, &mut a.h_out) };
*moved = pos;
if a.kind == AnchorKind::Smooth {
// Keep the opposite handle collinear, preserving its length.
let len = (*other - a.p).hypot();
let dir = a.p - pos;
let l = dir.hypot();
if l > 1e-9 {
*other = a.p + dir * (len / l);
}
}
Ok(())
})?;
ok()
}
fn set_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.setAnchors", "missing id"))?;
let subs = p.get("subpaths").and_then(Value::as_array).ok_or_else(|| bad("path.setAnchors", "missing subpaths"))?;
let data = PathData::new(
subs.iter()
.map(|sp| {
SubPath::new(
sp.get("anchors").and_then(Value::as_array).map(|a| a.iter().filter_map(anchor_from_json).collect()).unwrap_or_default(),
bool_or(sp, "closed", false),
)
})
.collect(),
);
s.edit("Reshape", |d, _| {
*path_mut(d, id)? = data;
Ok(())
})?;
ok()
}
fn selected_paths(s: &Session) -> Result<Vec<NodeId>> {
let st = s.doc()?;
Ok(st.selection.objects.iter().copied().filter(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Path { .. }))).collect())
}
fn reverse(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
s.edit("Reverse Path Direction", |d, _| {
for id in &ids {
path_mut(d, *id)?.reverse();
}
Ok(())
})?;
ok()
}
fn join(s: &mut Session, _: &Value) -> Result<Value> {
let ids = selected_paths(s)?;
match ids.as_slice() {
[one] => {
let id = *one;
s.edit("Join", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.iter_mut().find(|s| !s.closed).ok_or_else(|| EngineError::Other("path is already closed".into()))?;
// Merge coincident end points.
if sp.anchors.len() > 2 && sp.anchors[0].p.distance(sp.anchors.last().unwrap().p) < 1e-6 {
let last = sp.anchors.pop().unwrap();
sp.anchors[0].h_in = last.h_in;
}
sp.closed = true;
Ok(())
})?;
}
[a, b, ..] => {
let (a, b) = (*a, *b);
s.edit("Join", |d, sel| {
let pb = d.node(b).and_then(|n| n.path_data()).cloned().ok_or(EngineError::NoNode(b))?;
let pa = path_mut(d, a)?;
let (Some(sa), Some(sb)) = (pa.subpaths.iter().position(|s| !s.closed), pb.subpaths.iter().position(|s| !s.closed)) else {
return Err(EngineError::Other("join needs open paths".into()));
};
let mut x = pa.subpaths[sa].clone();
let mut y = pb.subpaths[sb].clone();
let (xf, xl) = (x.anchors[0].p, x.anchors.last().unwrap().p);
let (yf, yl) = (y.anchors[0].p, y.anchors.last().unwrap().p);
// Pick the closest pair of ends.
let cands =
[(xl.distance(yf), false, false), (xl.distance(yl), false, true), (xf.distance(yf), true, false), (xf.distance(yl), true, true)];
let (_, rx, ry) = cands.into_iter().min_by(|p, q| p.0.total_cmp(&q.0)).unwrap();
if rx {
x.reverse();
}
if ry {
y.reverse();
}
if x.anchors.last().unwrap().p.distance(y.anchors[0].p) < 1e-6 {
let first = y.anchors.remove(0);
x.anchors.last_mut().unwrap().h_out = first.h_out;
}
x.anchors.extend(y.anchors);
pa.subpaths[sa] = x;
d.remove(b)?;
sel.set([a]);
Ok(())
})?;
}
[] => return Err(EngineError::Other("select paths to join".into())),
}
ok()
}
type AnchorTargets = Vec<(NodeId, Vec<(usize, usize)>)>;
fn anchor_targets(s: &Session) -> Result<AnchorTargets> {
let st = s.doc()?;
let mut out = vec![];
for id in &st.selection.objects {
let Some(pd) = st.doc.node(*id).and_then(|n| n.path_data()) else { continue };
let v: Vec<(usize, usize)> = match st.selection.anchors.get(id) {
Some(set) => set.iter().copied().collect(),
None => pd.anchors().map(|(s, a, _)| (s, a)).collect(),
};
out.push((*id, v));
}
Ok(out)
}
fn average(s: &mut Session, p: &Value) -> Result<Value> {
let t = anchor_targets(s)?;
let d0 = &s.doc()?.doc;
let pts: Vec<Point> = t
.iter()
.flat_map(|(id, v)| v.iter().filter_map(move |(si, ai)| d0.node(*id)?.path_data()?.subpaths.get(*si)?.anchors.get(*ai).map(|a| a.p)))
.collect();
if pts.is_empty() {
return ok();
}
let c = Point::new(pts.iter().map(|p| p.x).sum::<f64>() / pts.len() as f64, pts.iter().map(|p| p.y).sum::<f64>() / pts.len() as f64);
let axis = str_param(p, "axis").unwrap_or("both").to_string();
s.edit("Average", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
if let Some(a) = path.anchor_mut(si, ai) {
let target = match axis.as_str() {
"horizontal" => Point::new(a.p.x, c.y),
"vertical" => Point::new(c.x, a.p.y),
_ => c,
};
a.translate(target - a.p);
}
}
}
Ok(())
})?;
ok()
}
fn convert_anchors(s: &mut Session, p: &Value) -> Result<Value> {
let t = anchor_targets(s)?;
let smooth = str_param(p, "to") == Some("smooth");
s.edit("Convert Anchor Points", |d, _| {
for (id, v) in &t {
let path = path_mut(d, *id)?;
for &(si, ai) in v {
let sp = &path.subpaths[si];
let n = sp.anchors.len();
let prev = if ai > 0 {
Some(sp.anchors[ai - 1].p)
} else if sp.closed {
Some(sp.anchors[n - 1].p)
} else {
None
};
let next = if ai + 1 < n {
Some(sp.anchors[ai + 1].p)
} else if sp.closed {
Some(sp.anchors[0].p)
} else {
None
};
let a: &mut Anchor = &mut path.subpaths[si].anchors[ai];
if smooth {
// Handles parallel to prev→next, a third of the neighbour distances.
let (pp, nn) = (prev.unwrap_or(a.p), next.unwrap_or(a.p));
let dir = nn - pp;
let l = dir.hypot();
if l > 1e-9 {
let u = dir / l;
a.h_in = a.p - u * (a.p.distance(pp) / 3.0);
a.h_out = a.p + u * (a.p.distance(nn) / 3.0);
a.kind = AnchorKind::Smooth;
}
} else {
a.retract();
}
}
}
Ok(())
})?;
ok()
}
pub(crate) fn delete_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let sel = st.selection.anchors.clone();
s.edit("Clear", |d, selection| {
for (id, set) in &sel {
let remove_node = {
let path = path_mut(d, *id)?;
// Deleting an anchor from a closed path opens it there (Illustrator behaviour on Delete).
for &(si, ai) in set.iter().rev() {
if let Some(sp) = path.subpaths.get_mut(si)
&& ai < sp.anchors.len()
{
if sp.closed {
sp.anchors.rotate_left(ai + 1);
sp.anchors.pop();
sp.closed = false;
} else {
sp.anchors.remove(ai);
}
}
}
path.subpaths.retain(|s| s.anchors.len() >= 2);
path.subpaths.is_empty()
};
if remove_node {
d.remove(*id)?;
}
}
selection.clear();
Ok(())
})?;
ok()
}
fn insert_anchor(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("path.insertAnchor", "missing id"))?;
let si = p.get("subpath").and_then(Value::as_u64).unwrap_or(0) as usize;
let seg = p.get("segment").and_then(Value::as_u64).ok_or_else(|| bad("path.insertAnchor", "missing segment"))? as usize;
let t = f64_or(p, "t", 0.5).clamp(0.0, 1.0);
let idx = s.edit("Add Anchor Point", |d, _| {
let path = path_mut(d, id)?;
let sp = path.subpaths.get_mut(si).ok_or_else(|| EngineError::Other("no such subpath".into()))?;
if seg >= sp.segment_count() {
return Err(EngineError::Other("no such segment".into()));
}
Ok(sp.insert_anchor(seg, t))
})?;
Ok(json!({ "anchor": idx }))
}
fn cut_at_anchors(s: &mut Session, _: &Value) -> Result<Value> {
let sel = s.doc()?.selection.anchors.clone();
s.edit("Cut Path", |d, _| {
for (id, set) in &sel {
let path = path_mut(d, *id)?;
let mut out = vec![];
for (si, sp) in path.subpaths.iter().enumerate() {
let cuts: Vec<usize> = set.iter().filter(|(s, _)| *s == si).map(|(_, a)| *a).collect();
if cuts.is_empty() {
out.push(sp.clone());
continue;
}
let mut anchors = sp.anchors.clone();
if sp.closed {
anchors.rotate_left(cuts[0]);
anchors.push(anchors[0]);
}
let base = if sp.closed { cuts[0] } else { 0 };
let n = sp.anchors.len();
let rel: Vec<usize> = cuts.iter().map(|c| (c + n - base) % n).collect();
let mut cur = vec![];
for (i, a) in anchors.iter().enumerate() {
cur.push(*a);
if rel.contains(&(i % n)) && cur.len() > 1 {
out.push(SubPath::new(std::mem::take(&mut cur), false));
cur.push(*a);
}
}
if cur.len() > 1 {
out.push(SubPath::new(cur, false));
}
}
path.subpaths = out;
}
Ok(())
})?;
ok()
}
+267
View File
@@ -0,0 +1,267 @@
//! Select menu.
use std::collections::BTreeSet;
use drawcraft_doc::{Document, Node, NodeId, NodeKind};
use serde_json::{Value, json};
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("select.all", "All", ["Select"], Some("Cmd+A"), "{}", has_doc, all),
cmd!("select.allOnArtboard", "All on Active Artboard", ["Select"], Some("Cmd+Alt+A"), "{artboard?: index}", has_doc, all_on_artboard),
cmd!("select.none", "Deselect", ["Select"], Some("Cmd+Shift+A"), "{}", has_doc, none),
cmd!("select.reselect", "Reselect", ["Select"], Some("Cmd+6"), "{}", has_doc, reselect),
cmd!("select.inverse", "Inverse", ["Select"], None, "{}", has_doc, inverse),
cmd!("select.nextAbove", "Next Object Above", ["Select"], Some("Cmd+Alt+]"), "{}", has_selection, |s, _| step(s, 1)),
cmd!("select.nextBelow", "Next Object Below", ["Select"], Some("Cmd+Alt+["), "{}", has_selection, |s, _| step(s, -1)),
cmd!("select.set", "Select Objects", [], None, "{ids: [id…]}", has_doc, set),
cmd!("select.add", "Add to Selection", [], None, "{ids: [id…]}", has_doc, add),
cmd!("select.toggle", "Toggle Selection", [], None, "{id}", has_doc, toggle),
cmd!("select.key", "Set Key Object", [], None, "{id?} (none clears)", has_doc, key),
cmd!("select.anchors", "Select Anchors", [], None, "{id, anchors: [[subpath, anchor]…], mode: \"set\"|\"add\"|\"toggle\"}", has_doc, anchors),
cmd!("select.anchorsMany", "Select Anchors", [], None, "{items: [{id, anchors}], add?: bool}", has_doc, anchors_many),
cmd!("select.same.fillColor", "Fill Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.fillColor",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint()
)),
cmd!("select.same.strokeColor", "Stroke Color", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.strokeColor",
|a, b| a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
cmd!("select.same.strokeWeight", "Stroke Weight", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.strokeWeight",
|a, b| (a.appearance.stroke_width() - b.appearance.stroke_width()).abs() < 1e-9
)),
cmd!("select.same.fillAndStroke", "Fill & Stroke", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.fillAndStroke",
|a, b| a.appearance.fill_paint() == b.appearance.fill_paint() && a.appearance.stroke_paint() == b.appearance.stroke_paint()
)),
cmd!("select.same.opacity", "Opacity", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.opacity", |a, b| (a
.opacity
- b.opacity)
.abs()
< 1e-6)),
cmd!("select.same.blendingMode", "Blending Mode", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.blendingMode",
|a, b| a.blend == b.blend
)),
cmd!("select.same.appearance", "Appearance", ["Select", "Same"], None, "{}", has_selection, |s, _| same(
s,
"select.same.appearance",
|a, b| a.appearance == b.appearance && a.opacity == b.opacity && a.blend == b.blend
)),
cmd!("select.same.shapeType", "Shape", ["Select", "Same"], None, "{}", has_selection, |s, _| same(s, "select.same.shapeType", |a, b| a
.kind_label()
== b.kind_label())),
cmd!("select.object.allOnSameLayers", "All on Same Layers", ["Select", "Object"], None, "{}", has_selection, same_layers),
cmd!("select.object.clippingMasks", "Clipping Masks", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
n.kind,
NodeKind::Path { clipping: true, .. }
))),
cmd!("select.object.textObjects", "All Text Objects", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| matches!(
n.kind,
NodeKind::Text(_)
))),
cmd!("select.object.strayPoints", "Stray Points", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
.path_data()
.is_some_and(|p| p.anchor_count() == 1))),
cmd!("select.object.openPaths", "Open Paths", ["Select", "Object"], None, "{}", has_doc, |s, _| by_kind(s, |n| n
.path_data()
.is_some_and(|p| !p.is_closed()))),
]
}
/// Selectable objects: children of visible, unlocked layers (or of the isolation container).
fn selectable(d: &Document, iso: Option<NodeId>) -> Vec<NodeId> {
match iso.and_then(|i| d.node(i)) {
Some(c) => c.children().map(|v| v.iter().filter(|n| n.visible && !n.locked).map(|n| n.id).collect()).unwrap_or_default(),
None => d
.layers
.iter()
.filter(|l| l.visible && !l.locked)
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect(),
}
}
fn all(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let ids = selectable(&st.doc, st.isolation);
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "count": s.doc()?.selection.len() }))
}
fn all_on_artboard(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?;
let i = p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize;
let r = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| bad("select.allOnArtboard", "no such artboard"))?;
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation)
.into_iter()
.filter(|id| st.doc.node(*id).and_then(|n| n.geometric_bounds()).is_some_and(|b| b.intersect(r).area() > 0.0 || r.contains(b.center())))
.collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn none(s: &mut Session, _: &Value) -> Result<Value> {
s.select(|_, sel| sel.clear())?;
ok()
}
fn reselect(s: &mut Session, _: &Value) -> Result<Value> {
let Some((c, p)) = s.doc()?.last_selection_cmd.clone() else { return ok() };
s.execute(&c, &p)
}
fn inverse(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let ids: Vec<NodeId> = selectable(&st.doc, st.isolation).into_iter().filter(|id| !st.selection.contains(*id)).collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn step(s: &mut Session, dir: i64) -> Result<Value> {
let st = s.doc()?;
let Some(cur) = st.selection.objects.first().copied() else { return ok() };
let Some((par, idx, n)) = st.doc.position(cur) else { return ok() };
let j = idx as i64 + dir;
if j < 0 || j >= n as i64 {
return ok();
}
let id = st.doc.children(par).unwrap()[j as usize].id;
s.select(|_, sel| sel.set([id]))?;
ok()
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.set", "missing ids"))?;
s.select(|d, sel| sel.set(ids.iter().copied().filter(|i| d.node(*i).is_some())))?;
ok()
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = ids_param(p, "ids").ok_or_else(|| bad("select.add", "missing ids"))?;
s.select(|_, sel| {
for i in ids {
sel.add(i);
}
})?;
ok()
}
fn toggle(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.toggle", "missing id"))?;
s.select(|_, sel| sel.toggle(id))?;
ok()
}
fn key(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id");
s.select(|_, sel| sel.key = id.filter(|i| sel.contains(*i)))?;
ok()
}
fn parse_refs(v: Option<&Value>) -> Vec<(usize, usize)> {
v.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|x| Some((x.get(0)?.as_u64()? as usize, x.get(1)?.as_u64()? as usize))).collect())
.unwrap_or_default()
}
fn anchors(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("select.anchors", "missing id"))?;
let refs = parse_refs(p.get("anchors"));
let mode = str_param(p, "mode").unwrap_or("set").to_string();
s.select(|_, sel| {
if mode == "set" {
sel.clear();
}
sel.add(id);
let e = sel.anchors.entry(id).or_default();
for r in refs {
if mode == "toggle" && e.contains(&r) {
e.remove(&r);
} else {
e.insert(r);
}
}
})?;
ok()
}
fn anchors_many(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<(NodeId, BTreeSet<(usize, usize)>)> = p
.get("items")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|it| Some((NodeId(it.get("id")?.as_u64()?), parse_refs(it.get("anchors")).into_iter().collect()))).collect())
.unwrap_or_default();
let add = bool_or(p, "add", false);
s.select(|d, sel| {
if !add {
sel.clear();
}
for (id, refs) in items {
let total = d.node(id).and_then(|n| n.path_data()).map(|p| p.anchor_count()).unwrap_or(0);
sel.add(id);
if refs.len() < total {
sel.anchors.entry(id).or_default().extend(refs);
} else {
sel.anchors.remove(&id);
}
}
})?;
ok()
}
fn same(s: &mut Session, cmd: &str, eq: fn(&Node, &Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let refn = st.selection.objects.first().and_then(|id| st.doc.node(*id)).cloned().ok_or_else(|| bad(cmd, "nothing selected"))?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if !n.is_container() && n.visible && !n.locked && eq(n, &refn) {
ids.push(n.id);
}
});
}
let c = cmd.to_string();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
s.doc_mut()?.last_selection_cmd = Some((c, json!({})));
Ok(json!({ "count": ids.len() }))
}
fn same_layers(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc()?;
let layers: BTreeSet<NodeId> = st.selection.objects.iter().filter_map(|id| st.doc.layer_of(*id)).collect();
let ids: Vec<NodeId> = layers
.iter()
.filter_map(|l| st.doc.node(*l))
.flat_map(|l| l.children().into_iter().flatten())
.filter(|n| n.visible && !n.locked)
.map(|n| n.id)
.collect();
s.select(|_, sel| sel.set(ids.iter().copied()))?;
ok()
}
fn by_kind(s: &mut Session, f: fn(&Node) -> bool) -> Result<Value> {
let st = s.doc()?;
let mut ids = vec![];
for l in st.doc.layers.iter().filter(|l| l.visible && !l.locked) {
l.walk(&mut |n| {
if f(n) {
ids.push(n.id)
}
});
}
s.select(|_, sel| sel.set(ids.iter().copied()))?;
Ok(json!({ "count": ids.len() }))
}