M2/M8/M9/M4.4: drawing + transform tools, live effects, PDF export/import, file/batch commands; UI restyled to measured Illustrator metrics (Medium Dark, categorized toolbar, hint bar, task bar, Layers)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-09-30 06:55:49 -04:00
co-authored by Claude Opus 5.5
parent d0007a604d
commit 6d21d36d16
64 changed files with 11318 additions and 865 deletions
File diff suppressed because it is too large Load Diff
+314 -3
View File
@@ -1,7 +1,318 @@
//! Effect menu commands (live effects).
//! Effect menu commands (live effects) and Object → Expand Appearance.
//!
//! Effects are stored on the object's appearance (`appearance.effects`) and evaluated at render
//! time by `drawcraft-effects` (re-exported by the renderer).
use super::CommandSpec;
use std::sync::Arc;
use drawcraft_doc::{Node, NodeKind};
use drawcraft_geom::{FillRule, PathData};
use drawcraft_render::effects;
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
pub fn specs() -> Vec<CommandSpec> {
vec![]
vec![
cmd!(
"effect.apply",
"Apply Effect",
[],
None,
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\"), params?: {…} (missing keys take the dialog defaults), ids?: [..]} append a live effect to each selected object's appearance → {ids, index}",
has_selection,
apply
),
cmd!(query "effect.list", "Effects", [], None, "{} → {catalog: [{id, label, menu, params, defaults, raster}], applied: [{id, effects}]} for the selection", always, list),
cmd!(
"effect.remove",
"Remove Effect",
[],
None,
"{index: int (position in the object's effect list), ids?: [..]} → {ids}",
has_selection,
remove
),
cmd!(
"effect.setParams",
"Effect Options",
[],
None,
"{index: int, params?: {…} (merged into the current parameters), visible?: bool, ids?: [..]} → {ids}",
has_selection,
set_params
),
cmd!(
"effect.expandAppearance",
"Expand Appearance",
["Object"],
None,
"{ids?: [..]} bake geometry effects into the paths and drop them (raster effects stay live) → {ids}",
has_selection,
expand_appearance
),
]
}
fn target_roots(s: &Session, p: &Value) -> Result<Vec<NodeId>> {
if p.get("ids").is_some() || p.get("id").is_some() {
return targets(s, p);
}
selected_roots(s)
}
fn ids_json(ids: &[NodeId]) -> Value {
json!(ids.iter().map(|i| i.0).collect::<Vec<_>>())
}
fn apply(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.apply";
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let effect = effects::new_effect(id, &params).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
let roots = target_roots(s, p)?;
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
let (ids, index) = s.edit(&label, |d, _| {
let mut done = vec![];
let mut index = 0;
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if n.is_layer() {
continue;
}
n.appearance.effects.push(effect.clone());
index = n.appearance.effects.len() - 1;
done.push(*id);
}
if done.is_empty() {
return Err(EngineError::Other("Apply Effect: select objects".into()));
}
Ok((done, index))
})?;
Ok(json!({ "ids": ids_json(&ids), "index": index }))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let catalog: Vec<Value> = effects::effect_catalog()
.into_iter()
.map(|e| json!({"id": e.id, "label": e.label, "menu": e.menu, "params": e.params, "defaults": e.defaults, "raster": e.raster}))
.collect();
let mut applied = vec![];
if s.active().is_some() {
for id in target_roots(s, p)? {
if let Some(n) = s.doc()?.doc.node(id) {
applied.push(json!({"id": id.0, "effects": serde_json::to_value(&n.appearance.effects).unwrap_or(Value::Null)}));
}
}
}
Ok(json!({ "catalog": catalog, "applied": applied }))
}
fn index_param(p: &Value, cmd: &str) -> Result<usize> {
p.get("index").and_then(Value::as_u64).map(|i| i as usize).ok_or_else(|| bad(cmd, "missing integer `index`"))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.remove";
let index = index_param(p, C)?;
let roots = target_roots(s, p)?;
let ids = s.edit("Remove Effect", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
if index < n.appearance.effects.len() {
n.appearance.effects.remove(index);
done.push(*id);
}
}
if done.is_empty() {
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, C)?;
let params = p.get("params").cloned().unwrap_or(Value::Null);
if !params.is_null() && !params.is_object() {
return Err(bad(C, "`params` must be an object"));
}
let visible = p.get("visible").and_then(Value::as_bool);
let roots = target_roots(s, p)?;
let ids = s.edit("Effect Options", |d, _| {
let mut done = vec![];
for id in &roots {
let Some(n) = d.node_mut(*id) else { continue };
let Some(e) = n.appearance.effects.get_mut(index) else { continue };
if let (Value::Object(new), cur) = (&params, &mut e.params) {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
if let Value::Object(m) = cur {
for (k, v) in new {
m.insert(k.clone(), v.clone());
}
}
}
if let Some(v) = visible {
e.visible = v;
}
done.push(*id);
}
if done.is_empty() {
return Err(bad(C, format!("no effect at index {index}")));
}
Ok(done)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
/// Geometry of a path or compound path node.
fn node_geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, .. } => Some((path.clone(), *rule)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule))
}
_ => None,
}
}
/// Bake the object-level geometry effects of `id` (and, recursively, of group members).
fn expand_node(d: &mut drawcraft_doc::Document, id: NodeId, out: &mut Vec<NodeId>) {
let Some(n) = d.node(id).cloned() else { return };
if let Some(children) = n.children()
&& matches!(n.kind, NodeKind::Group { .. })
{
for c in children.iter().map(|c| c.id).collect::<Vec<_>>() {
expand_node(d, c, out);
}
return;
}
if !effects::has_geometry(&n.appearance.effects) {
return;
}
let Some((path, rule)) = node_geometry(&n) else { return };
let Some(bounds) = path.bounds() else { return };
let w = n.appearance.stroke_width();
let ctx = effects::GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let baked = effects::apply_geometry_with(&n.appearance.effects, &path, bounds, &ctx);
let kind = if matches!(n.kind, NodeKind::Compound { .. }) || baked.subpaths.len() > 1 {
let children = baked
.subpaths
.into_iter()
.map(|sp| {
let cid = d.alloc_id();
Arc::new(Node::path(cid, PathData::single(sp), Default::default()))
})
.collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path: baked, rule, live: None, clipping: false, guide: false }
};
let Some(m) = d.node_mut(id) else { return };
m.kind = kind;
m.appearance.effects.retain(|e| !effects::is_geometry(&e.id));
out.push(id);
}
fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
let roots = target_roots(s, p)?;
let ids = s.edit("Expand Appearance", |d, _| {
let mut out = vec![];
for id in &roots {
expand_node(d, *id, &mut out);
}
if out.is_empty() {
return Err(EngineError::Other("Expand Appearance: no geometry effects to expand".into()));
}
Ok(out)
})?;
Ok(json!({ "ids": ids_json(&ids) }))
}
#[cfg(test)]
mod tests {
use drawcraft_doc::NodeKind;
use serde_json::json;
use crate::{NodeId, Session};
fn session_with_rect() -> (Session, NodeId) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap();
(s, NodeId(r["id"].as_u64().unwrap()))
}
fn node(s: &Session, id: NodeId) -> drawcraft_doc::Node {
s.doc().unwrap().doc.node(id).cloned().unwrap()
}
#[test]
fn apply_uses_catalog_defaults_and_undoes() {
let (mut s, id) = session_with_rect();
let r = s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "params": {"x": 3}})).unwrap();
assert_eq!(r["index"], json!(0));
let n = node(&s, id);
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].params["x"], json!(3));
assert_eq!(n.appearance.effects[0].params["opacity"], json!(75.0));
s.execute("edit.undo", &json!({})).unwrap();
assert!(node(&s, id).appearance.effects.is_empty());
assert!(s.execute("effect.apply", &json!({"effect": "nope"})).is_err());
}
#[test]
fn list_remove_and_set_params() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.twist"})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.outerGlow"})).unwrap();
let l = s.execute("effect.list", &json!({})).unwrap();
assert!(l["catalog"].as_array().unwrap().len() >= 34);
assert_eq!(l["applied"][0]["effects"].as_array().unwrap().len(), 2);
s.execute("effect.setParams", &json!({"index": 0, "params": {"angle": 45}, "visible": false})).unwrap();
let e = &node(&s, id).appearance.effects[0];
assert_eq!(e.params["angle"], json!(45));
assert!(!e.visible);
s.execute("effect.remove", &json!({"index": 0})).unwrap();
let n = node(&s, id);
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].id, "stylize.outerGlow");
assert!(s.execute("effect.remove", &json!({"index": 5})).is_err());
}
#[test]
fn expand_appearance_bakes_geometry_and_keeps_raster() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "path.offsetPath", "params": {"offset": 10}})).unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow"})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
let b = n.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.1, "{b:?}");
assert_eq!(n.appearance.effects.len(), 1);
assert_eq!(n.appearance.effects[0].id, "stylize.dropShadow");
assert!(matches!(n.kind, NodeKind::Path { live: None, .. }));
// Nothing left to expand.
assert!(s.execute("effect.expandAppearance", &json!({})).is_err());
}
#[test]
fn expand_transform_copies_makes_compound() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": "distort.transform", "params": {"moveH": 120, "copies": 1}})).unwrap();
s.execute("effect.expandAppearance", &json!({})).unwrap();
let n = node(&s, id);
assert!(matches!(n.kind, NodeKind::Compound { .. }));
let b = n.geometric_bounds().unwrap();
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
}
}
+130
View File
@@ -0,0 +1,130 @@
//! Document I/O and transactions: open/save/export by path or bytes (base64), and `command.batch`.
//!
//! These live in the engine so every frontend (UI, CLI, MCP headless) shares them.
use drawcraft_doc::Document;
use serde_json::{Value, json};
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"document.open",
"Open Document",
[],
None,
"{path} or {name, dataBase64} → {index}; .drawcraft, .svg (PDF/.ai via drawcraft-pdf when available)",
always,
open
),
cmd!("document.save", "Save Document", [], None, "{path?} native .drawcraft (default: the document's path) → {path}", has_doc, save),
cmd!(query "document.serialize", "Serialize Document", [], None, "{format: drawcraft|svg} → {dataBase64 | text}", has_doc, serialize),
cmd!("document.export", "Export Document", [], None, "{format: svg|png|drawcraft, path, scale?: 1, artboard?: 0}", has_doc, export),
cmd!(query "command.batch", "Batch", [], None, "{label?, commands: [{command, params}]} run several commands as ONE undo step; stops at the first error and rolls back", has_doc, batch),
]
}
fn load(name: &str, bytes: &[u8]) -> Result<Document> {
let lower = name.to_ascii_lowercase();
if lower.ends_with(".drawcraft") || drawcraft_format::sniff(bytes) {
return drawcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string()));
}
if lower.ends_with(".svg") || bytes.starts_with(b"<?xml") || bytes.starts_with(b"<svg") {
let s = std::str::from_utf8(bytes).map_err(|_| EngineError::Other("SVG is not UTF-8".into()))?;
let mut d = drawcraft_svg::import(s).map_err(|e| EngineError::Other(e.to_string()))?;
d.title = std::path::Path::new(name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
return Ok(d);
}
Err(EngineError::Other(format!("unsupported file `{name}`")))
}
fn open(s: &mut Session, p: &Value) -> Result<Value> {
let (name, bytes, path) = match (str_param(p, "path"), str_param(p, "dataBase64")) {
(Some(path), _) => (path.to_string(), std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?, Some(path.to_string())),
(None, Some(b64)) => (
str_param(p, "name").unwrap_or("untitled.drawcraft").to_string(),
drawcraft_format::base64_decode(b64).ok_or_else(|| bad("document.open", "bad base64"))?,
None,
),
_ => return Err(bad("document.open", "give path or dataBase64")),
};
let doc = load(&name, &bytes)?;
let native = name.to_ascii_lowercase().ends_with(".drawcraft");
let i = s.add_document(doc, if native { path } else { None });
Ok(json!({ "index": i }))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path")
.map(str::to_string)
.or_else(|| s.active().and_then(|d| d.path.clone()))
.ok_or_else(|| bad("document.save", "no path"))?;
let bytes = drawcraft_format::save(&s.doc()?.doc, true);
std::fs::write(&path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.saved_revision = st.revision;
Ok(json!({ "path": path }))
}
pub(crate) fn encode(s: &Session, format: &str, scale: f64, artboard: usize) -> Result<Vec<u8>> {
let doc = &s.doc()?.doc;
Ok(match format {
"drawcraft" => drawcraft_format::save(doc, true),
"svg" => drawcraft_svg::export(doc, &drawcraft_svg::ExportOptions { artboard: Some(artboard), ..Default::default() }).into_bytes(),
"png" => {
let r = doc.artboards.get(artboard).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
drawcraft_render::Renderer::new().render_region(doc, r, scale.clamp(0.01, 64.0), false).to_png()
}
other => return Err(EngineError::Other(format!("unknown format `{other}`"))),
})
}
fn serialize(s: &mut Session, p: &Value) -> Result<Value> {
let f = str_param(p, "format").unwrap_or("drawcraft");
let bytes = encode(s, f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
if f == "svg" {
return Ok(json!({ "text": String::from_utf8_lossy(&bytes) }));
}
Ok(json!({ "dataBase64": drawcraft_format::base64_encode(&bytes) }))
}
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let path = str_param(p, "path").ok_or_else(|| bad("document.export", "missing path"))?;
let f = str_param(p, "format")
.map(str::to_string)
.unwrap_or_else(|| std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_else(|| "png".into()));
let bytes = encode(s, &f, f64_or(p, "scale", 1.0), p.get("artboard").and_then(Value::as_u64).unwrap_or(0) as usize)?;
std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({ "path": path, "bytes": bytes.len() }))
}
fn batch(s: &mut Session, p: &Value) -> Result<Value> {
let cmds = p.get("commands").and_then(Value::as_array).ok_or_else(|| bad("command.batch", "missing commands"))?.clone();
let label = str_param(p, "label").unwrap_or("Batch").to_string();
s.begin_interaction(&label)?;
let mut results = vec![];
for c in &cmds {
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
if id == "command.batch" {
s.cancel_interaction()?;
return Err(bad("command.batch", "batches cannot nest"));
}
let params = c.get("params").cloned().unwrap_or(json!({}));
match s.execute(id, &params) {
Ok(v) => results.push(v),
Err(e) => {
s.cancel_interaction()?;
return Err(EngineError::Other(format!("batch step {} (`{id}`) failed: {e}", results.len())));
}
}
}
// Commit as one undo step (the interaction's "preview" is the batch itself).
if let Some(it) = s.doc_mut()?.interaction.as_mut() {
it.preview = Some(("command.batch".into(), p.clone()));
}
s.commit_interaction()?;
Ok(json!({ "results": results }))
}
+4 -2
View File
@@ -2,9 +2,9 @@
mod create;
mod draw2;
mod effectcmd;
mod xform;
mod edit;
mod effectcmd;
mod file;
mod layer;
mod object;
mod paint;
@@ -12,6 +12,7 @@ mod path;
mod pathops;
mod select;
mod typecmd;
mod xform;
use drawcraft_color::Color;
use drawcraft_doc::NodeId;
@@ -110,6 +111,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
SPECS.get_or_init(|| {
let mut v = Vec::new();
v.extend(edit::specs());
v.extend(file::specs());
v.extend(create::specs());
v.extend(object::specs());
v.extend(path::specs());
+351 -3
View File
@@ -1,7 +1,355 @@
//! Commands backing transform/utility tools (eyedropper, gradient, artboard…).
//! Commands backing transform/utility tools: free distort, eyedropper, gradient vector, artboard move.
use super::CommandSpec;
use std::sync::Arc;
use drawcraft_color::{Gradient, GradientGeom, GradientPaint, Paint};
use drawcraft_doc::{Appearance, Node, NodeId, NodeKind};
use drawcraft_geom::{Affine, Point, Rect, Vec2};
use serde_json::{Value, json};
use super::edit::selected_roots;
use super::*;
use crate::EngineError;
pub fn specs() -> Vec<CommandSpec> {
vec![]
vec![
cmd!(
"object.distort",
"Free Distort",
[],
None,
"{corners: [[x,y]×4] (TL, TR, BR, BL), from?: [x0,y0,x1,y1] (default: selection bounds), ids?} projective warp of anchors and handles",
has_doc,
distort
),
cmd!(
"appearance.copyFrom",
"Eyedropper",
[],
None,
"{source: id, ids?} copy fill, stroke, weight, opacity and blend from `source` to ids (default: selection) and to the paint defaults",
has_doc,
copy_from
),
cmd!(
"paint.sampleColor",
"Sample Color",
[],
None,
"{color, stroke?: bool (default: whichever proxy is active), ids?} sample a colour into the active fill/stroke (and the selection)",
has_doc,
sample_color
),
cmd!(
"paint.setGradientGeom",
"Gradient Vector",
[],
None,
"{start: [x,y], end: [x,y], ids?, stroke?: bool} set the gradient vector (solid paints become the default gradient)",
has_doc,
set_gradient_geom
),
cmd!(
"artboard.move",
"Move Artboard",
[],
None,
"{index, dx, dy, moveArt?: bool} move an artboard (and the unlocked art fully inside it)",
has_doc,
artboard_move
),
]
}
// ---------- projective distort ----------
/// A 2D projective transform (homography) mapping the unit square to a quad.
#[derive(Clone, Copy, Debug)]
pub struct Projective {
m: [f64; 8],
src: Rect,
}
impl Projective {
/// Map `src`'s corners (TL, TR, BR, BL) onto `q`.
pub fn from_rect(src: Rect, q: [Point; 4]) -> Self {
let [p0, p1, p2, p3] = q;
let (dx1, dx2, dx3) = (p1.x - p2.x, p3.x - p2.x, p0.x - p1.x + p2.x - p3.x);
let (dy1, dy2, dy3) = (p1.y - p2.y, p3.y - p2.y, p0.y - p1.y + p2.y - p3.y);
let (g, h) = if dx3.abs() < 1e-12 && dy3.abs() < 1e-12 {
(0.0, 0.0)
} else {
let den = dx1 * dy2 - dx2 * dy1;
if den.abs() < 1e-12 { (0.0, 0.0) } else { ((dx3 * dy2 - dx2 * dy3) / den, (dx1 * dy3 - dx3 * dy1) / den) }
};
let a = p1.x - p0.x + g * p1.x;
let b = p3.x - p0.x + h * p3.x;
let d = p1.y - p0.y + g * p1.y;
let e = p3.y - p0.y + h * p3.y;
Self { m: [a, b, p0.x, d, e, p0.y, g, h], src }
}
pub fn apply(&self, p: Point) -> Point {
let [a, b, c, d, e, f, g, h] = self.m;
let u = if self.src.width().abs() > 1e-12 { (p.x - self.src.x0) / self.src.width() } else { 0.0 };
let v = if self.src.height().abs() > 1e-12 { (p.y - self.src.y0) / self.src.height() } else { 0.0 };
let w = g * u + h * v + 1.0;
let w = if w.abs() < 1e-9 { 1e-9_f64.copysign(w) } else { w };
Point::new((a * u + b * v + c) / w, (d * u + e * v + f) / w)
}
/// Affine approximation at a point (for objects that can't be warped: text, images, symbols).
fn affine_near(&self, p: Point) -> Affine {
let eps = 1.0;
let o = self.apply(p);
let ex = self.apply(p + Vec2::new(eps, 0.0)) - o;
let ey = self.apply(p + Vec2::new(0.0, eps)) - o;
let lin = Affine::new([ex.x / eps, ex.y / eps, ey.x / eps, ey.y / eps, 0.0, 0.0]);
Affine::translate(o.to_vec2()) * lin * Affine::translate(-p.to_vec2())
}
}
fn warp_paints(ap: &mut Appearance, pr: &Projective) {
for it in &mut ap.items {
let p = match it {
drawcraft_doc::AppearanceItem::Fill(f) => &mut f.paint,
drawcraft_doc::AppearanceItem::Stroke(s) => &mut s.paint,
};
if let Paint::Gradient(g) = p
&& let Some(geom) = &mut g.geom
{
geom.start = pr.apply(geom.start);
geom.end = pr.apply(geom.end);
}
}
}
fn warp_node(n: &mut Node, pr: &Projective) {
warp_paints(&mut n.appearance, pr);
match &mut n.kind {
NodeKind::Path { path, live, .. } => {
*live = None;
for sp in &mut path.subpaths {
for a in &mut sp.anchors {
a.p = pr.apply(a.p);
a.h_in = pr.apply(a.h_in);
a.h_out = pr.apply(a.h_out);
}
}
}
NodeKind::Layer { children, .. } | NodeKind::Group { children, .. } | NodeKind::Compound { children, .. } => {
for c in children.iter_mut() {
warp_node(Arc::make_mut(c), pr);
}
}
_ => {
if let Some(b) = n.geometric_bounds() {
n.transform(pr.affine_near(b.center()), false);
}
}
}
}
fn distort(s: &mut Session, p: &Value) -> Result<Value> {
let corners: Vec<Point> = p
.get("corners")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| Some(Point::new(c.get(0)?.as_f64()?, c.get(1)?.as_f64()?))).collect())
.unwrap_or_default();
if corners.len() != 4 {
return Err(bad("object.distort", "corners must be 4 [x,y] points (TL, TR, BR, BL)"));
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let src = match p.get("from").and_then(Value::as_array) {
Some(a) if a.len() == 4 => {
let f: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
if f.len() != 4 {
return Err(bad("object.distort", "from must be [x0,y0,x1,y1]"));
}
Rect::new(f[0], f[1], f[2], f[3])
}
_ => s.doc()?.doc.bounds_of(&ids, false).ok_or_else(|| EngineError::Other("nothing to distort".into()))?,
};
if src.width().abs() < 1e-9 || src.height().abs() < 1e-9 {
return Err(EngineError::Other("cannot distort a zero-size bounding box".into()));
}
let pr = Projective::from_rect(src, [corners[0], corners[1], corners[2], corners[3]]);
s.edit("Free Distort", |d, _| {
for id in &ids {
if let Some(n) = d.node_mut(*id) {
warp_node(n, &pr);
}
}
Ok(())
})?;
Ok(json!({ "ids": ids.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- eyedropper ----------
/// Leaves whose appearance a paint command changes (groups expand to their contents; compound
/// paths own their children's appearance).
fn leaf_targets(s: &Session, ids: &[NodeId]) -> Result<Vec<NodeId>> {
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),
}
}
let comp: Vec<NodeId> = 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 copy_from(s: &mut Session, p: &Value) -> Result<Value> {
let src_id = id_param(p, "source").ok_or_else(|| bad("appearance.copyFrom", "missing `source` id"))?;
let src = s.doc()?.doc.node(src_id).cloned().ok_or(EngineError::NoNode(src_id))?;
let appearance = match &src.kind {
NodeKind::Text(t) => match t.runs.first() {
Some(r) => Appearance::basic(r.style.fill.clone(), r.style.stroke.clone(), r.style.stroke_width),
None => src.appearance.clone(),
},
_ => src.appearance.clone(),
};
s.paint.fill = appearance.fill_paint();
s.paint.stroke = appearance.stroke_paint();
if appearance.stroke().is_some() {
s.paint.stroke_width = appearance.stroke_width();
}
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let mut targets = leaf_targets(s, &ids)?;
targets.retain(|id| *id != src_id);
if targets.is_empty() {
return Ok(json!({ "ids": [] }));
}
let (opacity, blend) = (src.opacity, src.blend);
s.edit("Eyedropper", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
n.opacity = opacity;
n.blend = blend;
if let NodeKind::Text(t) = &mut n.kind {
for r in &mut t.runs {
r.style.fill = appearance.fill_paint();
r.style.stroke = appearance.stroke_paint();
r.style.stroke_width = appearance.stroke_width();
}
continue;
}
n.appearance = appearance.clone();
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
fn sample_color(s: &mut Session, p: &Value) -> Result<Value> {
let stroke = p.get("stroke").and_then(Value::as_bool).unwrap_or(!s.fill_active);
let c = p.get("color").ok_or_else(|| bad("paint.sampleColor", "missing color"))?;
let mut q = json!({ "color": c });
if let Some(ids) = p.get("ids") {
q["ids"] = ids.clone();
}
let id = if stroke { "paint.setStroke" } else { "paint.setFill" };
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
(spec.run)(s, &q)
}
// ---------- gradient tool ----------
fn set_gradient_geom(s: &mut Session, p: &Value) -> Result<Value> {
let start = point_param(p, "start").ok_or_else(|| bad("paint.setGradientGeom", "missing start [x,y]"))?;
let end = point_param(p, "end").ok_or_else(|| bad("paint.setGradientGeom", "missing end [x,y]"))?;
let stroke = bool_or(p, "stroke", false);
let ids = match ids_param(p, "ids") {
Some(v) => v,
None => selected_roots(s)?,
};
let targets = leaf_targets(s, &ids)?;
if targets.is_empty() {
return Err(EngineError::Other("nothing selected".into()));
}
s.edit("Gradient", |d, _| {
for id in &targets {
let Some(n) = d.node_mut(*id) else { continue };
if matches!(n.kind, NodeKind::Text(_)) {
continue;
}
let cur = if stroke { n.appearance.stroke_paint() } else { n.appearance.fill_paint() };
let mut gp = match cur {
Paint::Gradient(g) => *g,
_ => GradientPaint::new(Gradient::default()),
};
let aspect = gp.geom.map(|g| g.aspect).unwrap_or(1.0);
gp.geom = Some(GradientGeom { start, end, aspect });
gp.angle = GradientGeom { start, end, aspect }.angle_deg();
let paint = Paint::Gradient(Box::new(gp));
if stroke {
n.appearance.set_stroke(paint);
} else {
n.appearance.set_fill(paint);
}
}
Ok(())
})?;
Ok(json!({ "ids": targets.iter().map(|i| i.0).collect::<Vec<_>>() }))
}
// ---------- artboards ----------
fn artboard_move(s: &mut Session, p: &Value) -> Result<Value> {
let i = p.get("index").and_then(Value::as_u64).ok_or_else(|| bad("artboard.move", "missing index"))? as usize;
let dv = Vec2::new(f64_or(p, "dx", 0.0), f64_or(p, "dy", 0.0));
let move_art = bool_or(p, "moveArt", false);
let st = s.doc()?;
let rect = st.doc.artboards.get(i).map(|a| a.rect).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
// Top-level objects (children of layers) lying entirely inside the artboard.
let mut art = vec![];
if move_art {
fn collect(n: &Node, rect: Rect, out: &mut Vec<NodeId>) {
for c in n.children().into_iter().flatten() {
if c.locked {
continue;
}
if c.is_layer() {
collect(c, rect, out);
} else if let Some(b) = c.geometric_bounds()
&& rect.contains(Point::new(b.x0, b.y0))
&& rect.contains(Point::new(b.x1, b.y1))
{
out.push(c.id);
}
}
}
for l in &st.doc.layers {
if !l.locked {
collect(l, rect, &mut art);
}
}
}
let scale_strokes = s.prefs.scale_strokes;
s.edit("Move Artboard", |d, _| {
let a = d.artboards.get_mut(i).ok_or_else(|| EngineError::Other("no such artboard".into()))?;
a.rect = a.rect + dv;
for id in &art {
if let Some(n) = d.node_mut(*id) {
n.transform(Affine::translate(dv), scale_strokes);
}
}
Ok(())
})?;
Ok(json!({ "index": i, "moved": art.iter().map(|i| i.0).collect::<Vec<_>>() }))
}