diff --git a/crates/engine/src/cmd/effectcmd.rs b/crates/engine/src/cmd/effectcmd.rs index e07ac02f..1d3c66a7 100644 --- a/crates/engine/src/cmd/effectcmd.rs +++ b/crates/engine/src/cmd/effectcmd.rs @@ -23,7 +23,7 @@ pub fn specs() -> Vec { "Apply Effect", [], None, - "{effect: id (see effect.list, e.g. \"stylize.dropShadow\", \"distort.roughen\", \"warp.arc\", or \"plugin.\" 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.\" 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 { } 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 { 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>> { + 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 { let catalog: Vec = 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::>(), [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(); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 0b80ea93..35c5bf06 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -375,20 +375,26 @@ fn send_to_current_layer(s: &mut Session, _: &Value) -> Result { fn group(s: &mut Session, _: &Value) -> Result { 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 { + 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 { let ids = selected_roots(s)?; s.edit("Ungroup", |d, sel| { diff --git a/docs/mcp.md b/docs/mcp.md index a7d7d456..7d36612d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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. |