mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-10 09:59:47 -06:00
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:
co-authored by
Claude Opus 5.5
parent
d0007a604d
commit
6d21d36d16
+1126
-3
File diff suppressed because it is too large
Load Diff
@@ -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, ¶ms).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) = (¶ms, &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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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 }))
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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<_>>() }))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user