mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
fa1689bfd8
commit
bfee80a22c
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()}))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }))
|
||||
}
|
||||
Reference in New Issue
Block a user