Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc0837867 | ||
|
|
2ba0e1f6e5 |
No files matched your search
@@ -23,7 +23,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Apply Effect",
|
||||
[],
|
||||
None,
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow) → {ids, index, item}",
|
||||
"{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.<id>\" for an installed effect plug-in), params?: {…} (missing keys take the dialog defaults), item?: appearance item index|null (apply to that fill/stroke only; omitted: the Appearance panel's active item, else the whole object), ids?: [..] (layers too), target?: \"object\"|\"contents\" (contents: the objects inside groups and layers)} append a live effect to each selected object's appearance (a group's or layer's apply to its members as one piece: one combined shadow; a Pathfinder effect, which combines a group's contents, groups several loose selected objects first, in the same undo step) → {ids, index, item, grouped?}",
|
||||
has_doc,
|
||||
apply
|
||||
),
|
||||
@@ -130,6 +130,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let effect = effects::new_effect(id, ¶ms).ok_or_else(|| bad(C, format!("unknown effect `{id}`")))?;
|
||||
let label = effects::effect_info(id).map(|e| e.label.trim_end_matches('…').to_string()).unwrap_or_default();
|
||||
if let Some(loose) = loose_for_pathfinder(s, p, id)? {
|
||||
let gid = s.edit(&label, |d, sel| {
|
||||
let gid = super::object::group_nodes(d, &loose)?;
|
||||
d.node_mut(gid).ok_or(EngineError::NoNode(gid))?.appearance.effects.push(effect.clone());
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
@@ -143,6 +152,19 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({ "ids": ids_json(&ids), "index": index, "item": landed }))
|
||||
}
|
||||
|
||||
/// The selected objects a Pathfinder effect should group before it applies: it combines the
|
||||
/// contents of a group or layer, so on several loose objects (none a group) it would do nothing.
|
||||
/// `None` when the effect isn't one, objects are named (`ids`) or another target is asked for.
|
||||
fn loose_for_pathfinder(s: &Session, p: &Value, id: &str) -> Result<Option<Vec<NodeId>>> {
|
||||
if !effects::is_pathfinder(id) || ["ids", "id", "item", "target"].iter().any(|k| p.get(k).is_some()) {
|
||||
return Ok(None);
|
||||
}
|
||||
let roots = super::appearance::subject_roots(s)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let grouped = roots.iter().any(|r| d.node(*r).is_some_and(|n| matches!(n.kind, NodeKind::Group { clip: false, .. } | NodeKind::Layer { .. })));
|
||||
Ok((roots.len() >= 2 && !grouped).then_some(roots))
|
||||
}
|
||||
|
||||
fn list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let catalog: Vec<Value> = effects::effect_catalog()
|
||||
.into_iter()
|
||||
@@ -507,6 +529,34 @@ mod tests {
|
||||
assert!((b.x1 - 320.0).abs() < 1e-6, "{b:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_loose_objects_groups_them_first() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
let b = NodeId(s.execute("shape.rectangle", &json!({"x": 150, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert_eq!(r["grouped"], true);
|
||||
let g = NodeId(r["ids"][0].as_u64().unwrap());
|
||||
let gn = node(&s, g);
|
||||
assert_eq!(gn.children().unwrap().iter().map(|c| c.id).collect::<Vec<_>>(), [a, b], "stacking order kept");
|
||||
assert!(vectorcraft_render::effects::has_pathfinder(&gn));
|
||||
assert_eq!(s.doc().unwrap().selection.objects, [g]);
|
||||
// Live: one united shape, so expanding leaves a single path.
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, g).children().unwrap().len(), 1);
|
||||
// Grouping and applying are one undo step.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let d = &s.doc().unwrap().doc;
|
||||
assert!(d.node(g).is_none());
|
||||
assert!(d.node(a).is_some_and(|n| n.appearance.effects.is_empty()) && d.parent_of(a) == d.parent_of(b));
|
||||
// A selection holding a group applies to each object as before; so does a single object.
|
||||
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
|
||||
let r = s.execute("effect.apply", &json!({"effect": "pathfinder.add"})).unwrap();
|
||||
assert!(r.get("grouped").is_none());
|
||||
assert_eq!(node(&s, a).appearance.effects.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pathfinder_effect_on_a_group_renders_live_and_expands() {
|
||||
let (mut s, a) = session_with_rect();
|
||||
|
||||
@@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
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)?;
|
||||
}
|
||||
let gid = group_nodes(d, &ids)?;
|
||||
sel.set([gid]);
|
||||
Ok(gid)
|
||||
})?;
|
||||
Ok(json!({ "id": gid.0 }))
|
||||
}
|
||||
|
||||
/// Put `ids` (top-level objects, in paint order) in a new group where the front-most one was.
|
||||
pub(crate) fn group_nodes(d: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let Some(top) = ids.last().copied() else { return Err(EngineError::Other("nothing selected".into())) };
|
||||
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)?;
|
||||
}
|
||||
Ok(gid)
|
||||
}
|
||||
|
||||
fn ungroup(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let ids = selected_roots(s)?;
|
||||
s.edit("Ungroup", |d, sel| {
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `psd` (layered), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group (`effect.apply` groups several loose selected objects first). |
|
||||
| `transform` | `{ids?, dx?, dy?, rotate?, scale?, scaleX?, scaleY?, reflect?, shear?, origin?, copy?}` | Runs move, rotate, scale, reflect, shear in that order. With `copy`, the first step duplicates. |
|
||||
| `create_graph` | `{type?, x, y, width, height, csv? \| series?, categories?, rows?}` | The nine Illustrator graph types: column, stacked column, bar, stacked bar, line, area, scatter, pie and radar. Edit later with `graph.setData` / `graph.setType` via `run_command`. |
|
||||
| `text_wrap` | `{ids?, offset?, invert?, release?}` | Area type below the objects (same layer) flows around them. |
|
||||
|
||||
Reference in new issue
Block a user