diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index beffe4e0f..813d6cd34 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "Collect for Export", ["Object", "Collect for Export"], None, - "{ids?: [id…] (default: the selected objects), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset", + "{ids?: [id…] (default: the selected objects; a layer id collects the art under it, including in its sublayers), multiple?: true (an asset per object; false: one asset of them all), name?: (the asset's name, when it makes one)} collect art as assets for Window › Asset Export and Export for Screens' Assets tab, named after the object (else Asset 1, Asset 2…); art already collected as such an asset keeps it → {assets: [asset id…] (in order), added: n}. Assets follow their art: moving it moves the crop, deleting it removes it from the asset", has_doc, add ), @@ -78,13 +78,32 @@ fn clean_name(name: &str) -> Option { (!n.is_empty()).then_some(n) } +/// Ids of art under `id`: the id itself when it is not a layer, else every non-layer under it +/// (sublayers unfolded). Layers alone have nothing to collect. +fn art_under(doc: &vectorcraft_doc::Document, id: NodeId, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if n.is_layer() { + if let Some(kids) = n.children() { + for c in kids { + art_under(doc, c.id, out); + } + } + return; + } + out.push(id); +} + fn add(s: &mut Session, p: &Value) -> Result { const C: &str = "assets.add"; let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { // An explicit list is authoritative: never silently drop malformed or stale ids. let ids = checked_ids_param(s, p, "ids", C)?; let d = &s.doc()?.doc; - roots_of(d, d.paint_order(ids)) + let mut art = Vec::new(); + for id in &ids { + art_under(d, *id, &mut art); + } + roots_of(d, d.paint_order(art)) } else { selected_roots(s)? }; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs index 5df4edfba..17fd41b65 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests_assets.rs @@ -68,14 +68,37 @@ fn multiple_gives_an_asset_per_object_and_single_one_of_them_all() { let again = run(&mut s, "assets.add", json!({"ids": [b, a]})); assert_eq!((again["added"].clone(), again["assets"].clone()), (json!(0), json!([list[0]["id"], list[1]["id"]]))); assert_eq!(undo_len(&s), steps); - // Layers aren't art; nothing to collect is an error. + // A layer id collects the art under it (Layers panel Collect for Export / MCP). let layer = s.doc().unwrap().doc.layers[0].id.0; - assert!(s.execute("assets.add", &json!({"ids": [layer]})).is_err()); + let from_layer = run(&mut s, "assets.add", json!({"ids": [layer]})); + assert_eq!(from_layer["added"], 0, "those objects are assets already"); + assert_eq!(from_layer["assets"], json!([list[0]["id"], list[1]["id"]])); + // An empty layer has nothing to collect. + let empty = run(&mut s, "layer.new", json!({}))["id"].as_u64().unwrap(); + assert!(s.execute("assets.add", &json!({"ids": [empty]})).is_err()); run(&mut s, "select.set", json!({"ids": []})); assert!(s.execute("assets.add", &json!({})).is_err()); assert!(s.execute("assets.add", &json!({"ids": [a, b], "name": "Two"})).is_err(), "a name names one asset"); } +#[test] +fn a_layer_id_collects_the_art_under_it_including_sublayers() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 20.0, 20.0); + let sub = run(&mut s, "layer.newSublayer", json!({}))["id"].as_u64().unwrap(); + run(&mut s, "layer.setCurrent", json!({"id": sub})); + let b = rect(&mut s, 50.0, 10.0, 30.0, 20.0); + let layer = s.doc().unwrap().doc.layers[0].id.0; + let r = run(&mut s, "assets.add", json!({"ids": [layer]})); + assert_eq!(r["added"], 2); + let list = assets(&mut s); + assert_eq!(list.len(), 2); + assert_eq!((list[0]["nodes"].clone(), list[1]["nodes"].clone()), (json!([a]), json!([b]))); + let one = run(&mut s, "assets.add", json!({"ids": [layer], "multiple": false})); + assert_eq!(one["added"], 1); + assert_eq!(assets(&mut s).last().unwrap()["nodes"], json!([a, b])); +} + #[test] fn deleting_the_object_prunes_it_and_undo_brings_it_back() { let mut s = session(); diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs index cc7b6f13b..64069a530 100644 --- a/crates/ui-egui/src/panels/layers.rs +++ b/crates/ui-egui/src/panels/layers.rs @@ -994,6 +994,13 @@ fn row(ui: &mut Ui, view: &View, item: &Row, out: &mut Out) { out.actions.push(("ui.layerOptions".into(), json!({"ids": [n.id.0]}))); } } + // Row menu: Collect for Export on this row (or every highlighted row when this is one of them). + let collect_ids: Vec = if view.rows.contains(&n.id) { view.rows.iter().map(|id| id.0).collect() } else { vec![n.id.0] }; + resp.context_menu(|ui| { + collect_for_export_menu(ui, true, |multiple| { + out.actions.push(("assets.add".into(), json!({ "ids": collect_ids.clone(), "multiple": multiple }))); + }); + }); // Dragging rows: the highlighted rows when this is one of them, else this row. if resp.drag_started() && view.column.is_none() { let ids = if view.rows.contains(&n.id) { view.rows.clone() } else { vec![n.id] }; @@ -1486,6 +1493,8 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { Some(("Merge Selected", "layer.merge", json!({}), rows.len() >= 2, false)), Some(("Flatten Artwork", "layer.flatten", json!({}), true, false)), Some(("Collect in New Layer", "layer.collectInNew", json!({}), !rows.is_empty() || has_sel, false)), + // Sentinel: rendered as Collect for Export › As Single / As Multiple (assets.add). + Some(("Collect for Export", "", json!({}), !targets.is_empty() || has_sel, false)), Some(("Release to Layers (Sequence)", "layer.releaseToLayers", json!({}), one_container, false)), Some(("Release to Layers (Build)", "layer.releaseToLayersBuild", json!({}), one_container, false)), Some(("Reverse Order", "layer.reverse", json!({}), rows.len() >= 2, false)), @@ -1525,6 +1534,15 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { None => { ui.separator(); } + Some(("Collect for Export", _, _, enabled, _)) => { + collect_for_export_menu(ui, enabled, |multiple| { + let mut p = json!({ "multiple": multiple }); + if !targets.is_empty() { + p["ids"] = json!(targets.iter().map(|id| id.0).collect::>()); + } + app.run("assets.add", p).ok(); + }); + } Some((label, cmd, p, enabled, checked)) => { if widgets::menu_item(ui, label, enabled, checked) { app.run(cmd, p).ok(); @@ -1534,6 +1552,23 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Collect for Export › As Single Asset / As Multiple Assets (`assets.add`). +fn collect_for_export_menu(ui: &mut Ui, enabled: bool, mut run: impl FnMut(bool)) { + ui.add_enabled_ui(enabled, |ui| { + // English labels match the rest of this panel menu (and Object › Collect for Export). + ui.menu_button("Collect for Export", |ui| { + if widgets::menu_item(ui, "As Single Asset", true, false) { + run(false); + ui.close(); + } + if widgets::menu_item(ui, "As Multiple Assets", true, false) { + run(true); + ui.close(); + } + }); + }); +} + /// `ui.layersExpand {ids?, open?}`: open (or close) rows of the active document's Layers panel; /// without `ids`, every row that holds others. pub fn expand(app: &mut VectorcraftApp, p: &Value) -> Result { @@ -2500,6 +2535,7 @@ mod tests { "Merge Selected", "Flatten Artwork", "Collect in New Layer", + "Collect for Export", "Release to Layers (Sequence)", "Release to Layers (Build)", "Reverse Order", diff --git a/docs/mcp.md b/docs/mcp.md index 39410f8ff..5ee1fe0d5 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2766,13 +2766,14 @@ defaults); presets and remembered settings are preferences. ## Asset Export -Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, File › -Export Selection…). +Assets are pieces of art collected for export on their own (Window › Asset Export, Object › Collect for Export, the +Layers panel ≡ menu and row menu, File › Export Selection…). `assets.add {ids?, multiple?}` collects the selected objects (or `ids`): one asset per object, or with -`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; art already collected keeps -its asset. Assets name their objects, so they follow edits: moving the art moves the crop, and deleting it -removes it from its asset (an asset left with nothing goes). `assets.list` gives each asset's id, name, objects and -crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are undo steps. +`multiple: false` one asset of them all, named after the object or `Asset 1`, `Asset 2`…; a layer id collects the art +under it (sublayers included). Art already collected keeps its asset. Assets name their objects, so they follow edits: +moving the art moves the crop, and deleting it removes it from its asset (an asset left with nothing goes). +`assets.list` gives each asset's id, name, objects and crop (`bounds`); `assets.rename {asset, name}` and +`assets.remove {assets}` are undo steps. `assets.export {assets?, folder?, zip?}` writes each asset's art alone, cropped to it and named after it, in every format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any @@ -2783,9 +2784,12 @@ renderer measures for what the art draws, its effects included. ```json {"name":"run_command","arguments":{"command":"assets.add","params":{"multiple":true}}} +{"name":"run_command","arguments":{"command":"assets.add","params":{"ids":[12],"multiple":true}}} {"name":"run_command","arguments":{"command":"assets.settings.set","params":{"formats":[{"format":"png","scale":"1x"},{"format":"png","scale":"2x"},{"format":"svg"}]}}} {"name":"run_command","arguments":{"command":"assets.export","params":{"zip":true}}} ``` +A layer id in `ids` (12 above) is the same as Collect for Export on that Layers row: every object under the layer +becomes an asset (or one asset with `multiple: false`). ## CSS Properties