From 025da4d8f2e8e9c865a57c2f16b20d5c62e4db73 Mon Sep 17 00:00:00 2001 From: MaxNTN Date: Tue, 20 Oct 2026 16:52:04 +0000 Subject: [PATCH 1/9] Initial commit --- crates/engine/src/vector_cmds.rs | 34 ++++++++++++++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 31 +++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 15c14e5a1c..d7fe9fd674 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1127,6 +1127,18 @@ fn path_to_selection(s: &mut Session, p: &Value) -> Result { Ok(json!({ "selected": selected })) } +/// Runs `path.toSelection` with a forced combine mode (Ctrl+Shift+Enter / Ctrl+Alt+Enter). +/// Any `mode` passed by the caller is overridden; the other params (`name`, `feather`, +/// `antiAlias`) are forwarded unchanged and validated by `path_to_selection`. +fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = match p { + Value::Object(_) => p.clone(), + _ => json!({}), + }; + q["mode"] = json!(mode); + path_to_selection(s, &q) +} + fn select_to_work_path(s: &mut Session, p: &Value) -> Result { let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); let d = s.active().ok_or(EngineError::NoDocument)?; @@ -1810,6 +1822,28 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, + // Ctrl+Shift+Enter: add the path to the active selection. + CommandSpec { + id: "path.addToSelection", + label: "Add Path to Selection", + menu: &[], + shortcut: Some("Cmd+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "add")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "add"), + journal: true, + }, + // Ctrl+Alt+Enter: subtract the path from the active selection. + CommandSpec { + id: "path.subtractFromSelection", + label: "Subtract Path from Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "subtract")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "subtract"), + journal: true, + }, spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), spec!( "path.fill", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 1ad8d6d92a..d3ccf6d52e 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -546,6 +546,37 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // reaches Edit › Clear while a polygon is being drawn. if !app.ui.polygon.is_empty() { let mods = ctx.input(|i| i.modifiers); + //Mahefa ajout + // Ctrl + Shift + Entrée -> Ajout à la sélection active (mode "add") + // Ctrl + Alt + Entrée -> Soustraction de la sélection active (mode "subtract") + if focus == Focus::None && !ctx.text_edit_focused() { + let mods = ctx.input(|i| i.modifiers); + if mods.command && mods.shift && !mods.alt && ctx.input_mut(|i| i.consume_key(mods, Key::Enter)) { + if let Err(e) = app.run("path.toSelection", json!({"mode": "add"})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + if mods.command && mods.alt && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Enter)) { + if let Err(e) = app.run("path.toSelection", json!({"mode": "subtract"})) { + app.ui.status = e; + app.ui.status_error = true; + } + return; + } + } + + // Ligne que vous avez repérée : + if !app.ui.polygon.is_empty() { + let mods = ctx.input(|i| i.modifiers); + if !mods.command && !mods.ctrl && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) { + crate::canvas::polygon_retract(app); + return; + } + } + //Mahefa ajout + if !mods.command && !mods.ctrl && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) { crate::canvas::polygon_retract(app); return; From f86bd55c4a4da8e38a204af96093eb286a277466 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Wed, 21 Oct 2026 09:13:46 +0000 Subject: [PATCH 2/9] refactor: address review comments on shortcuts and command mapping --- crates/ui-egui/src/shortcuts.rs | 109 +++++++++++++++++++++++--------- 1 file changed, 78 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index d3ccf6d52e..bff06f030b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -546,37 +546,6 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) { // reaches Edit › Clear while a polygon is being drawn. if !app.ui.polygon.is_empty() { let mods = ctx.input(|i| i.modifiers); - //Mahefa ajout - // Ctrl + Shift + Entrée -> Ajout à la sélection active (mode "add") - // Ctrl + Alt + Entrée -> Soustraction de la sélection active (mode "subtract") - if focus == Focus::None && !ctx.text_edit_focused() { - let mods = ctx.input(|i| i.modifiers); - if mods.command && mods.shift && !mods.alt && ctx.input_mut(|i| i.consume_key(mods, Key::Enter)) { - if let Err(e) = app.run("path.toSelection", json!({"mode": "add"})) { - app.ui.status = e; - app.ui.status_error = true; - } - return; - } - if mods.command && mods.alt && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Enter)) { - if let Err(e) = app.run("path.toSelection", json!({"mode": "subtract"})) { - app.ui.status = e; - app.ui.status_error = true; - } - return; - } - } - - // Ligne que vous avez repérée : - if !app.ui.polygon.is_empty() { - let mods = ctx.input(|i| i.modifiers); - if !mods.command && !mods.ctrl && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) { - crate::canvas::polygon_retract(app); - return; - } - } - //Mahefa ajout - if !mods.command && !mods.ctrl && !mods.shift && ctx.input_mut(|i| i.consume_key(mods, Key::Backspace) || i.consume_key(mods, Key::Delete)) { crate::canvas::polygon_retract(app); return; @@ -908,6 +877,84 @@ mod tests { assert!(app.ui.pen.is_none()); } + /// A document with a work path, and a `press` helper that feeds one key press to `handle`. + fn selection_app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); + app + } + + fn set_work_rect(app: &mut PhotocraftApp, x: i32, y: i32, w: i32, h: i32) { + let knots = serde_json::json!([[x, y], [x + w, y], [x + w, y + h], [x, y + h]]); + app.run("path.set", serde_json::json!({"path": {"subpaths": [{"closed": true, "op": "combine", "knots": knots}]}})).unwrap(); + } + + fn selected(app: &PhotocraftApp) -> bool { + app.session.active().unwrap().doc.selection.is_some() + } + + fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { + ctx.begin_pass(egui::RawInput { + events: vec![ + egui::Event::ModifiersChanged(modifiers), + egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + ], + ..Default::default() + }); + handle(app, ctx); + ctx.end_pass().textures_delta.clear(); + } + + #[test] + fn ctrl_shift_enter_adds_the_path_to_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + let history = app.session.active().unwrap().history.past_len(); + + set_work_rect(&mut app, 100, 100, 40, 40); + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 2, "path.set + selection"); + assert!(selected(&app)); + + // Removing the second rectangle leaves the first one: the key added, it did not replace. + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); + assert!(selected(&app)); + } + + #[test] + fn ctrl_alt_enter_subtracts_the_path_from_the_selection() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + assert!(selected(&app)); + + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); + assert!(!selected(&app), "the only selected area was subtracted"); + } + + #[test] + fn add_and_subtract_path_keys_are_ignored_while_a_text_field_has_focus() { + let mut app = selection_app(); + let ctx = egui::Context::default(); + set_work_rect(&mut app, 10, 10, 40, 40); + app.run("path.toSelection", serde_json::json!({})).unwrap(); + + // Give a text field the keyboard focus, as a panel's numeric field would have it. + let mut text = String::new(); + ctx.begin_pass(egui::RawInput::default()); + egui::CentralPanel::default().show(&ctx, |ui| ui.text_edit_singleline(&mut text).request_focus()); + ctx.end_pass().textures_delta.clear(); + + let history = app.session.active().unwrap().history.past_len(); + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); + press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::SHIFT); + assert_eq!(app.session.active().unwrap().history.past_len(), history); + assert!(selected(&app)); + } + #[test] fn parses_registry_shortcuts() { let sc = parse("Cmd+Shift+N").unwrap(); From 3d4c205881b9d9794d52288398406926ef0e57f1 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Mon, 26 Oct 2026 11:25:44 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Fix:=20r=C3=A9solution=20du=20conflit=20dan?= =?UTF-8?q?s=20vector=5Fcmds.rs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/vector_cmds.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index d7fe9fd674..67690a6e41 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1130,6 +1130,8 @@ fn path_to_selection(s: &mut Session, p: &Value) -> Result { /// Runs `path.toSelection` with a forced combine mode (Ctrl+Shift+Enter / Ctrl+Alt+Enter). /// Any `mode` passed by the caller is overridden; the other params (`name`, `feather`, /// `antiAlias`) are forwarded unchanged and validated by `path_to_selection`. +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// a `mode` in the params is overridden, the rest is forwarded and validated as usual. fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { let mut q = match p { Value::Object(_) => p.clone(), @@ -1822,24 +1824,39 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, +<<<<<<< HEAD // Ctrl+Shift+Enter: add the path to the active selection. +======= + // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. +>>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) CommandSpec { id: "path.addToSelection", label: "Add Path to Selection", menu: &[], shortcut: Some("Cmd+Shift+Enter"), +<<<<<<< HEAD params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "add")"##, +======= + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, +>>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "add"), journal: true, }, +<<<<<<< HEAD // Ctrl+Alt+Enter: subtract the path from the active selection. +======= +>>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) CommandSpec { id: "path.subtractFromSelection", label: "Subtract Path from Selection", menu: &[], shortcut: Some("Cmd+Alt+Enter"), +<<<<<<< HEAD params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "subtract")"##, +======= + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, +>>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, From df91a8555b47102dcd1b7701fe910b0711c616f3 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Mon, 26 Oct 2026 11:44:01 +0000 Subject: [PATCH 4/9] =?UTF-8?q?Fix:=20ajout=20des=20derni=C3=A8res=20modif?= =?UTF-8?q?ications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/vector_cmds.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 67690a6e41..a5803f7d00 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1824,39 +1824,29 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, -<<<<<<< HEAD + // Ctrl+Shift+Enter: add the path to the active selection. -======= // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. ->>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) CommandSpec { id: "path.addToSelection", label: "Add Path to Selection", menu: &[], shortcut: Some("Cmd+Shift+Enter"), -<<<<<<< HEAD params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "add")"##, -======= + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, ->>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "add"), journal: true, }, -<<<<<<< HEAD // Ctrl+Alt+Enter: subtract the path from the active selection. -======= ->>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) CommandSpec { id: "path.subtractFromSelection", label: "Subtract Path from Selection", menu: &[], shortcut: Some("Cmd+Alt+Enter"), -<<<<<<< HEAD params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "subtract")"##, -======= params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, ->>>>>>> 49aabe92a (Fix: résolution du conflit de configuration) enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "subtract"), journal: true, From 8c2e269cf93142682e7e26e59a7d09e994f2c43c Mon Sep 17 00:00:00 2001 From: mahefanante Date: Mon, 26 Oct 2026 12:26:27 +0000 Subject: [PATCH 5/9] =?UTF-8?q?Fix:=20ajout=20des=20derni=C3=A8res=20modif?= =?UTF-8?q?ications?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/engine/src/vector_cmds.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index be782273a6..9b65102a57 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1934,8 +1934,6 @@ pub fn specs() -> Vec { label: "Add Path to Selection", menu: &[], shortcut: Some("Cmd+Shift+Enter"), - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "add")"##, - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "add"), @@ -1947,7 +1945,6 @@ pub fn specs() -> Vec { label: "Subtract Path from Selection", menu: &[], shortcut: Some("Cmd+Alt+Enter"), - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (same as path.toSelection with mode "subtract")"##, params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, enabled: has_doc, run: |s, p| path_to_selection_with_mode(s, p, "subtract"), From 0c951ffdb5db6bae9981173a4e499e2da5dfce42 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Tue, 27 Oct 2026 07:02:21 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Fix:=20selection=20de=20trac=C3=A9=20autre?= =?UTF-8?q?=20que=20celle=20active?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/lib.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index adf7b4f959..5d036f0fe3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1411,6 +1411,19 @@ impl PhotocraftApp { // Long commands become background jobs when enabled (`jobs_ui`); the rest run inline. let params = self.with_mask_target(id, params); let params = vector_ui::with_active_path(self, id, params); + // ⌘⇧↩ / ⌘⌥↩ load the path selected in the Paths panel, as ⌘↩ does, rather than always the work path. + let params = if matches!(id, "path.addToSelection" | "path.subtractFromSelection") && params.get("name").is_none() { + match vector_ui::active_path_name(self) { + Some(name) => { + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("name".into(), json!(name)); + Value::Object(p) + } + None => params, + } + } else { + params + }; let path_mask = vector_ui::takes_path_mask(id) && params.get("path").is_some_and(|v| !v.is_null()); let on_active_layer = self .session @@ -1475,6 +1488,8 @@ impl PhotocraftApp { | "select.loadSelection" | "select.loadLuminosity" | "path.toSelection" + | "path.addToSelection" + | "path.subtractFromSelection" ) && self.session.active().is_some_and(|st| st.doc.selection.is_some()) { From 779424dade954fee2ab947e4ded681577402e3f7 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Tue, 27 Oct 2026 07:37:21 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Fix:=20selection=20de=20trac=C3=A9=20autre?= =?UTF-8?q?=20que=20celle=20active=20sans=20shortcut=20modifi=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/ui-egui/src/vector_cmds.rs | 2071 +++++++++++++++++++++++++++++ 1 file changed, 2071 insertions(+) create mode 100644 crates/ui-egui/src/vector_cmds.rs diff --git a/crates/ui-egui/src/vector_cmds.rs b/crates/ui-egui/src/vector_cmds.rs new file mode 100644 index 0000000000..b7a77e6ed8 --- /dev/null +++ b/crates/ui-egui/src/vector_cmds.rs @@ -0,0 +1,2071 @@ +//! Vector commands: shape layers (`shape.*`), document paths (`path.*`, Paths panel), selection ⇄ +//! path, and layer vector masks (`layer.vectorMask.*`). +//! +//! Coordinates are document pixels. Paths in parameters and results use a compact JSON form: +//! +//! ```json +//! {"subpaths":[{"closed":true,"op":"combine|subtract|intersect|exclude", +//! "knots":[[x,y], {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":true}, …]}], +//! "fillRule":"nonzero|evenodd", "inverted":false} +//! ``` +//! +//! A knot given as `[x, y]` is a corner with retracted handles; `in`/`out` default to the +//! anchor. The serde form of `photocraft_doc::Path` is accepted too. Colours are `"#rrggbb"`, +//! `"#rrggbbaa"` or `[r,g,b,a]` (0..1); opacities are percentages (0..100). +//! +//! Every edit re-renders the shape layer's cache with `photocraft-vector` in one undoable step. +//! PSD blocks stay byte-identical while they still decode to the model (see `photocraft-io`). + +use photocraft_algo::selection::{self as sel, SelectionMode}; +use photocraft_color::{BlendMode, Color}; +use photocraft_doc::{ + ClippingPath, Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, + ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask, +}; +use photocraft_geom::{Affine, Point, Rect}; +use photocraft_vector as vector; +use serde_json::{Value, json}; + +use crate::commands::{CommandSpec, blend_from_str}; +use crate::{EngineError, Result, Session}; + +/// Keys PSD uses for a shape layer's vector data; dropped when a shape is rasterized. +const SHAPE_BLOCKS: [&[u8; 4]; 8] = [b"vmsk", b"vsms", b"vogk", b"vstk", b"vscg", b"SoCo", b"GdFl", b"PtFl"]; + +pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } +} + +pub(crate) fn has_doc(s: &Session) -> std::result::Result<(), String> { + s.active().map(|_| ()).ok_or_else(|| "no document open".into()) +} + +fn has_layer(s: &Session) -> std::result::Result<(), String> { + let d = s.active().ok_or("no document open")?; + d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into()) +} + +fn has_selection(s: &Session) -> std::result::Result<(), String> { + s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into()) +} + +fn active_shape(s: &Session) -> std::result::Result<&ShapeLayer, String> { + let d = s.active().ok_or("no document open")?; + let id = d.active_layer.ok_or("no active layer")?; + let layer = d.doc.layer(id).ok_or("no active layer")?; + match &layer.content { + LayerContent::Shape(shape) => Ok(shape), + _ => Err("active layer is not a shape layer".into()), + } +} + +fn has_shape_fill(s: &Session) -> std::result::Result<(), String> { + active_shape(s)?.fill.as_ref().map(|_| ()).ok_or_else(|| "shape has no fill".into()) +} + +fn has_shape_stroke(s: &Session) -> std::result::Result<(), String> { + active_shape(s)?.stroke.as_ref().map(|_| ()).ok_or_else(|| "shape has no stroke".into()) +} + +fn can_paste_shape_fill(s: &Session) -> std::result::Result<(), String> { + active_shape(s)?; + s.path_fill_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape fill copied".into()) +} + +fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), String> { + active_shape(s)?; + s.path_stroke_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape stroke copied".into()) +} + +fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result { + let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?; + s.path_fill_clipboard = Some(fill); + Ok(Value::Null) +} + +fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result { + let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?; + s.path_stroke_clipboard = Some(stroke); + Ok(Value::Null) +} + +fn paste_shape_fill(s: &mut Session, p: &Value) -> Result { + let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?; + let id = layer_id(s, p)?; + with_shape(s, id, "Paste Fill", |shape, _| { + shape.fill = Some(fill); + Ok(()) + })?; + shape_info(s, id) +} + +fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result { + let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?; + let id = layer_id(s, p)?; + with_shape(s, id, "Paste Complete Stroke", |shape, _| { + shape.stroke = Some(stroke); + Ok(()) + })?; + shape_info(s, id) +} + +pub(crate) fn layer_id(s: &Session, p: &Value) -> Result { + match p.get("layer").and_then(Value::as_u64) { + Some(id) => Ok(LayerId(id)), + None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), + } +} + +fn f64p(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64) +} + +pub(crate) fn pt(v: &Value) -> Option { + match v { + Value::Array(a) if a.len() >= 2 => Some(Point::new(a[0].as_f64()?, a[1].as_f64()?)), + Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)), + _ => None, + } +} + +pub(crate) fn nums(p: &Value, k: &str) -> Option<[f64; N]> { + let a = p.get(k)?.as_array()?; + if a.len() < N { + return None; + } + let mut out = [0.0; N]; + for (o, v) in out.iter_mut().zip(a) { + *o = v.as_f64()?; + } + Some(out) +} + +/// `[tl, tr, br, bl]` from an array or a single number. +fn radii(p: &Value) -> Option<[f64; 4]> { + match p.get("radii").or_else(|| p.get("radius"))? { + Value::Number(n) => n.as_f64().map(|r| [r; 4]), + _ => nums::<4>(p, "radii"), + } +} + +pub(crate) fn color(v: &Value) -> Option { + match v { + Value::Array(a) if a.len() >= 3 => { + let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); + Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) + } + Value::String(s) => { + let s = s.trim_start_matches('#'); + let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f32::from(v) / 255.0); + match s.len() { + 6 => Some(Color::rgb(h(0)?, h(2)?, h(4)?)), + 8 => Some(Color::rgba(h(0)?, h(2)?, h(4)?, h(6)?)), + _ => None, + } + } + // A stored `Color` (mode + components), so a CMYK or Lab gradient stop survives a + // read-modify-write through the shape swatches. + Value::Object(_) => serde_json::from_value::(v.clone()) + .ok() + .filter(|c| c.c.iter().all(|x| x.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)), + _ => None, + } +} + +fn hex(c: &Color) -> String { + let [r, g, b] = c.to_rgb(); + let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + if c.alpha < 1.0 { format!("#{:02x}{:02x}{:02x}{:02x}", q(r), q(g), q(b), q(c.alpha)) } else { format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b)) } +} + +fn op_from(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "combine" | "add" | "union" => PathOp::Combine, + "subtract" | "minus" => PathOp::Subtract, + "intersect" => PathOp::Intersect, + "exclude" | "xor" => PathOp::Exclude, + "join" => PathOp::Join, + _ => return None, + }) +} + +fn op_name(op: PathOp) -> &'static str { + match op { + PathOp::Combine => "combine", + PathOp::Subtract => "subtract", + PathOp::Intersect => "intersect", + PathOp::Exclude => "exclude", + PathOp::Join => "join", + } +} + +/// Parses a path from the compact JSON form (or the serde form). +pub fn parse_path(v: &Value) -> std::result::Result { + let subs = v.get("subpaths").and_then(Value::as_array).ok_or("path needs `subpaths`")?; + let mut out = Vec::with_capacity(subs.len()); + for (i, s) in subs.iter().enumerate() { + let knots_v = s.get("knots").and_then(Value::as_array).ok_or(format!("subpath {i} needs `knots`"))?; + let mut knots = Vec::with_capacity(knots_v.len()); + for (j, k) in knots_v.iter().enumerate() { + let knot = if k.is_array() { + let a = pt(k).ok_or(format!("knot {i}.{j}: expected [x, y]"))?; + Knot::corner(a.x, a.y) + } else { + let a = k.get("anchor").and_then(pt).ok_or(format!("knot {i}.{j}: missing anchor"))?; + let get = |keys: &[&str]| keys.iter().find_map(|key| k.get(*key).and_then(pt)); + Knot { + anchor: a, + in_ctrl: get(&["in", "inCtrl", "in_ctrl"]).unwrap_or(a), + out_ctrl: get(&["out", "outCtrl", "out_ctrl"]).unwrap_or(a), + smooth: k.get("smooth").and_then(Value::as_bool).unwrap_or(false), + } + }; + knots.push(knot); + } + let op = match s.get("op").and_then(Value::as_str) { + Some(o) => op_from(o).ok_or(format!("subpath {i}: unknown op `{o}`"))?, + None => PathOp::Combine, + }; + out.push(Subpath { closed: s.get("closed").and_then(Value::as_bool).unwrap_or(true), knots, op }); + } + let fill_rule = match v.get("fillRule").or_else(|| v.get("fill_rule")).and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { + Some("evenodd" | "even-odd" | "even_odd") => FillRule::EvenOdd, + _ => FillRule::NonZero, + }; + Ok(Path { subpaths: out, fill_rule, inverted: v.get("inverted").and_then(Value::as_bool).unwrap_or(false) }) +} + +/// The compact JSON form of a path. +pub fn path_json(p: &Path) -> Value { + let a = |q: Point| json!([q.x, q.y]); + json!({ + "subpaths": p.subpaths.iter().map(|s| json!({ + "closed": s.closed, + "op": op_name(s.op), + "knots": s.knots.iter().map(|k| json!({ "anchor": a(k.anchor), "in": a(k.in_ctrl), "out": a(k.out_ctrl), "smooth": k.smooth })).collect::>(), + })).collect::>(), + "fillRule": if p.fill_rule == FillRule::EvenOdd { "evenodd" } else { "nonzero" }, + "inverted": p.inverted, + }) +} + +fn fill_json(f: &Fill) -> Value { + match f { + Fill::Solid(c) => json!(hex(c)), + Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { + "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::>(), + "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, + "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }), + Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }), + } +} + +/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop +/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. +const MAX_GRADIENT_STOPS: usize = 1024; + +/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", +/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. +/// `null` → `Ok(None)` (no fill). +fn parse_fill(v: &Value) -> std::result::Result, String> { + if v.is_null() { + return Ok(None); + } + if let Some(c) = color(v) { + return Ok(Some(Fill::Solid(c))); + } + if let Some(g) = v.get("gradient") { + // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut. + let list = |key: &str, what: &str| match g.get(key) { + None | Some(Value::Null) => Ok(None), + Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")), + Some(Value::Array(a)) => Ok(Some(a)), + Some(_) => Err(format!("gradient `{key}` must be an array")), + }; + let stops: Vec<(f32, Color)> = list("stops", "colour stops")? + .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect()) + .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); + let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") { + "radial" => GradientStyle::Radial, + "angle" => GradientStyle::Angle, + "reflected" => GradientStyle::Reflected, + "diamond" => GradientStyle::Diamond, + _ => GradientStyle::Linear, + }; + let mut fill = Fill::gradient( + stops, + f64p(g, "angle").unwrap_or(90.0) as f32, + f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0, + style, + g.get("reverse").and_then(Value::as_bool).unwrap_or(false), + ); + // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. + if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { + let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); + if let Some(a) = list("opacityStops", "opacity stops")? { + *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); + opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + } + if let Some(a) = list("midpoints", "midpoints")? { + *midpoints = a.iter().filter_map(unit).collect(); + } + if let Some(o) = g.get("offset") { + let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32); + *offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?); + } + if let Some(d) = g.get("dither").and_then(Value::as_bool) { + *dither = d; + } + if let Some(a) = g.get("align").and_then(Value::as_bool) { + *align = a; + } + } + return Ok(Some(fill)); + } + if let Some(name) = v.get("pattern").and_then(Value::as_str) { + return Ok(Some(Fill::Pattern { + name: name.into(), + scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0, + id: String::new(), + angle: 0.0, + link: true, + phase: (0.0, 0.0), + })); + } + Err(format!("unrecognised fill {v}")) +} + +fn stroke_json(s: &ShapeStroke) -> Value { + json!({ + "width": s.width, "fill": fill_json(&s.paint), "opacity": s.opacity * 100.0, + "align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(), + "join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset, + }) +} + +/// Applies stroke keys onto `base` (`null` → no stroke). +pub fn parse_stroke(v: &Value, base: Option, fg: Color) -> std::result::Result, String> { + if v.is_null() || v.as_bool() == Some(false) { + return Ok(None); + } + if !v.is_object() { + return Err("stroke must be an object or null".into()); + } + for (key, lo, hi) in [("width", 0.0, 1_000_000.0), ("opacity", 0.0, 100.0), ("miterLimit", 1.0, 500.0), ("dashOffset", -1_000_000.0, 1_000_000.0)] { + if let Some(value) = v.get(key) + && !value.as_f64().is_some_and(|n| n.is_finite() && (lo..=hi).contains(&n)) + { + return Err(format!("`{key}` must be a finite number between {lo} and {hi}")); + } + } + for key in ["align", "cap", "join"] { + if v.get(key).is_some_and(|value| !value.is_string()) { + return Err(format!("`{key}` must be a string")); + } + } + let mut s = base.unwrap_or(ShapeStroke { paint: Fill::Solid(fg), ..Default::default() }); + if let Some(w) = f64p(v, "width") { + s.width = w as f32; + } + if let Some(c) = v.get("color").or_else(|| v.get("fill")) { + s.paint = parse_fill(c)?.ok_or("stroke colour cannot be null")?; + } + if let Some(o) = f64p(v, "opacity") { + s.opacity = (o / 100.0) as f32; + } + if let Some(a) = v.get("align").and_then(Value::as_str) { + s.align = match a { + "inside" => StrokeAlign::Inside, + "outside" => StrokeAlign::Outside, + "center" | "centre" => StrokeAlign::Center, + o => return Err(format!("unknown align `{o}`")), + }; + } + if let Some(c) = v.get("cap").and_then(Value::as_str) { + s.cap = match c { + "butt" => LineCap::Butt, + "round" => LineCap::Round, + "square" => LineCap::Square, + o => return Err(format!("unknown cap `{o}`")), + }; + } + if let Some(j) = v.get("join").and_then(Value::as_str) { + s.join = match j { + "miter" => LineJoin::Miter, + "round" => LineJoin::Round, + "bevel" => LineJoin::Bevel, + o => return Err(format!("unknown join `{o}`")), + }; + } + if let Some(m) = f64p(v, "miterLimit") { + s.miter_limit = m as f32; + } + if let Some(d) = v.get("dashes") { + s.dashes = match d { + Value::Null => Vec::new(), + Value::Array(a) if a.len() <= 32 => a + .iter() + .map(|x| { + x.as_f64() + .filter(|n| n.is_finite() && (0.0..=10000.0).contains(n)) + .map(|x| x as f32) + .ok_or_else(|| "dash lengths must be finite numbers between 0 and 10000 widths".to_string()) + }) + .collect::, _>>()?, + _ => return Err("`dashes` must be an array (multiples of the width)".into()), + }; + } + if let Some(o) = f64p(v, "dashOffset") { + s.dash_offset = o as f32; + } + s.validate()?; + Ok(Some(s)) +} + +fn live_json(l: &LiveShape) -> Value { + serde_json::to_value(l).unwrap_or(Value::Null) +} + +/// Builds a live shape from `kind` + geometry params. +fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) -> Result { + let prev_rect = match prev { + Some(LiveShape::Rect { rect, .. } | LiveShape::Ellipse { rect } | LiveShape::Polygon { rect, .. }) => Some(*rect), + _ => None, + }; + let rect = || nums::<4>(p, "rect").or(prev_rect).ok_or_else(|| bad(cmd, "`rect`: [x, y, w, h] is required")); + Ok(match kind { + "rect" | "rectangle" => { + let r = match prev { + Some(LiveShape::Rect { radii, .. }) => *radii, + _ => [0.0; 4], + }; + LiveShape::Rect { rect: rect()?, radii: radii(p).unwrap_or(r) } + } + "roundedRect" | "roundRect" => { + let r = match prev { + Some(LiveShape::Rect { radii, .. }) if radii.iter().any(|v| *v > 0.0) => *radii, + _ => [10.0; 4], + }; + LiveShape::Rect { rect: rect()?, radii: radii(p).unwrap_or(r) } + } + "ellipse" => LiveShape::Ellipse { rect: rect()? }, + "polygon" | "star" => { + let (ps, pr, pc) = match prev { + Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), + _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), + }; + let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); + let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); + let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); + LiveShape::Polygon { + rect: rect()?, + sides, + star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, + corner_radius, + } + } + "line" => { + let (pf, pto, pw) = match prev { + Some(LiveShape::Line { from, to, weight }) => (Some(*from), Some(*to), *weight), + _ => (None, None, 1.0), + }; + LiveShape::Line { + from: nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?, + to: nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?, + weight: f64p(p, "weight").unwrap_or(pw).max(0.0), + } + } + o => return Err(bad(cmd, format!("unknown kind `{o}`"))), + }) +} + +/// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging). +pub fn shape_path(p: &Value) -> Result { + let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); + Ok(vector::shapes::live_path(&live_from_params("shape.create", kind, p, None)?)) +} + +fn live_kind(l: &LiveShape) -> &'static str { + match l { + LiveShape::Rect { radii, .. } if radii.iter().any(|r| *r > 0.0) => "roundedRect", + LiveShape::Rect { .. } => "rect", + LiveShape::Ellipse { .. } => "ellipse", + LiveShape::Polygon { star_ratio, .. } if *star_ratio < 1.0 => "star", + LiveShape::Polygon { .. } => "polygon", + LiveShape::Line { .. } => "line", + } +} + +/// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format). +pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) { + sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds())); +} + +/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`. +/// The caller owns this snapshot; the session and its history remain untouched. +pub fn preview_shape_stroke(doc: &Document, id: LayerId, params: &Value) -> Result { + let mut result = doc.clone(); + let layer = result.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Shape(shape) = &mut layer.content else { + return Err(EngineError::Other("preview target is not a shape layer".into())); + }; + shape.stroke = parse_stroke(params, shape.stroke.clone(), Color::BLACK).map_err(|e| bad("shape.edit", e))?; + vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(|e| bad("shape.edit", e))?; + refresh_shape(doc, shape); + Ok(result) +} + +/// Moves a layer's vector content by `(dx, dy)`: shape paths (re-rendered) and linked vector +/// masks, recursing into groups. For the Move tool / `layer.move`. +pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { + let a = Affine::translate(dx, dy); + if let Some(vm) = l.vector_mask.as_mut() + && vm.linked + { + vm.path = vm.path.transform(&a); + } + match &mut l.content { + LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), + LayerContent::Group(g) => { + for c in &mut g.children { + translate_vectors(doc, c, dx, dy); + } + } + _ => {} + } +} + +/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a +/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at +/// the canvas, so a shape reaching past it renders again, as does a fractional move. +pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { + transform_shape(sh, &Affine::translate(dx, dy)); + let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); + let canvas = doc.bounds(); + let inside = sh.cache.as_ref().is_some_and(|c| { + let b = photocraft_compose::bounds::content_bounds(c); + // An empty cache can be an off-canvas shape moving back into view. + !b.is_empty() && b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1 + }); + let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); + let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { + (x0 - stroke_pad) > canvas.x0 as f64 + && (y0 - stroke_pad) > canvas.y0 as f64 + && (x1 + stroke_pad) < canvas.x1 as f64 + && (y1 + stroke_pad) < canvas.y1 as f64 + }); + match (&mut sh.cache, whole(dx), whole(dy)) { + (Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), + _ => refresh_shape(doc, sh), + } +} + +/// Applies an affine transform to a shape's path, keeping the live shape when the transform +/// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). +pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { + sh.path = sh.path.transform(a); + let [m0, m1, m2, m3, tx, ty] = a.m; + let axis_aligned = m1.abs() < 1e-12 && m2.abs() < 1e-12 && m0 > 0.0 && m3 > 0.0; + sh.live = match (sh.live.take(), axis_aligned) { + (Some(l), true) => { + let r = |r: [f64; 4]| [r[0] * m0 + tx, r[1] * m3 + ty, r[2] * m0, r[3] * m3]; + let pt = |p: [f64; 2]| [p[0] * m0 + tx, p[1] * m3 + ty]; + Some(match l { + LiveShape::Rect { rect, radii } => { + let k = m0.min(m3); + LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } + } + LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, + LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { + LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } + } + LiveShape::Line { from, to, weight } => { + // Live-shape edits regenerate the line's filled bar from its parameters. + // Keep the weight in sync with a positive scale of the path, or an + // unchanged endpoint edit unexpectedly restores the old thickness (#3063). + // A line's width scales perpendicular to its direction. + let (dx, dy) = (to[0] - from[0], to[1] - from[1]); + let old_length = dx.hypot(dy); + let new_length = (dx * m0).hypot(dy * m3); + let width_scale = if old_length > 0.0 && new_length > 0.0 { m0 * m3 * old_length / new_length } else { m0.min(m3) }; + LiveShape::Line { from: pt(from), to: pt(to), weight: weight * width_scale } + } + }) + } + // Flips and 90° turns keep rectangles, ellipses and lines live (Image Rotation): the new + // live box is the mapped box. Polygons/stars would change orientation, so they don't. + (Some(l), false) if (m1.abs() < 1e-12 && m2.abs() < 1e-12) || (m0.abs() < 1e-12 && m3.abs() < 1e-12) => { + let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty]; + let r = |r: [f64; 4]| { + let (p, q) = (pt([r[0], r[1]]), pt([r[0] + r[2], r[1] + r[3]])); + [p[0].min(q[0]), p[1].min(q[1]), (q[0] - p[0]).abs(), (q[1] - p[1]).abs()] + }; + let k = (m0.abs() + m1.abs()).min(m2.abs() + m3.abs()); + match l { + LiveShape::Rect { rect, radii } if radii.iter().all(|v| (v - radii[0]).abs() < 1e-9) => { + Some(LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) }) + } + LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }), + LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }), + _ => None, + } + } + _ => None, + }; + sh.psd_raw = None; +} + +/// Is every anchor and control point of `path` a finite point? +fn finite_path(path: &Path) -> bool { + path.subpaths.iter().flat_map(|s| &s.knots).all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|p| p.x.is_finite() && p.y.is_finite())) +} + +/// `path` through Distort / Perspective (`h`, a projective map of its points: exact for straight +/// segments, the end tangents of curves exact) or, with `warp`, Warp (split where the warp bends +/// it). Refused when the map sends a point to infinity (past a perspective's horizon). +pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result { + let out = match (h, warp) { + (_, Some(w)) => vector::map::map_path( + path, + |p| { + let (x, y) = w.map(p.x, p.y); + Point::new(x, y) + }, + 0.1, + ), + (Some(h), None) => vector::map::map_points(path, |p| { + let (x, y) = h.apply(p.x, p.y); + Point::new(x, y) + }), + (None, None) => path.clone(), + }; + if !finite_path(&out) { + return Err(EngineError::Other("the distortion sends part of the path out of bounds".into())); + } + Ok(out) +} + +/// Distort, Perspective or Warp of a shape layer (#3009): the shape stays a shape, its path +/// mapped ([`map_path`]), as in Photoshop. A live rectangle, ellipse, polygon or line becomes a +/// plain path, as its parameters can't describe the distorted outline. +pub(crate) fn map_shape(sh: &mut ShapeLayer, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<()> { + sh.path = map_path(&sh.path, h, warp)?; + sh.live = None; + sh.psd_raw = None; + Ok(()) +} + +fn shape_info(s: &Session, id: LayerId) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Shape(sh) = &l.content else { + return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name()))); + }; + let bounds = sh.cache.as_ref().map(|c| c.content_bounds()).filter(|r| !r.is_empty()).map(|r| json!([r.x0, r.y0, r.width(), r.height()])); + Ok(json!({ + "layer": id.0, + "name": l.name, + "path": path_json(&sh.path), + "fill": sh.fill.as_ref().map(fill_json), + "stroke": sh.stroke.as_ref().map(stroke_json), + "live": sh.live.as_ref().map(live_json), + "kind": sh.live.as_ref().map_or("path", live_kind), + "bounds": bounds, + })) +} + +pub(crate) fn with_shape(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result) -> Result { + if let Some(st) = s.active() { + let locks = st.doc.effective_locks(id); + if locks.all || locks.pixels { + return Err(EngineError::Other("shape layer is locked".into())); + } + } + s.edit(label, |doc, _| { + let snapshot = doc.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if !matches!(l.content, LayerContent::Shape(_)) { + return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name()))); + } + let LayerContent::Shape(mut sh) = std::mem::replace(&mut l.content, LayerContent::Fill(Fill::Solid(Color::BLACK))) else { + return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); + }; + let r = f(&mut sh, l); + if r.is_ok() { + refresh_shape(&snapshot, &mut sh); + } + l.content = LayerContent::Shape(sh); + r + }) +} + +fn default_name(kind: &str) -> &'static str { + match kind { + "rect" | "rectangle" => "Rectangle", + "roundedRect" | "roundRect" => "Rounded Rectangle", + "ellipse" => "Ellipse", + "polygon" => "Polygon", + "star" => "Star", + "line" => "Line", + _ => "Shape", + } +} + +fn shape_create(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "shape.create"; + let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); + let (path, live) = if kind == "path" { + let v = p.get("path").ok_or_else(|| bad(CMD, "kind \"path\" needs `path`"))?; + (parse_path(v).map_err(|e| bad(CMD, e))?, None) + } else { + let l = live_from_params(CMD, kind, p, None)?; + (vector::shapes::live_path(&l), Some(l)) + }; + let fg = s.tools.foreground; + let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); + let fill = match p.get("fill") { + Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, + None => Some(Fill::Solid(fgc)), + }; + let stroke = match p.get("stroke") { + Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, + None => None, + }; + vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; + // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). + if let Some(target) = p.get("addTo").and_then(Value::as_u64) { + let op = p.get("op").and_then(Value::as_str).map_or(Some(PathOp::Combine), op_from).ok_or_else(|| bad(CMD, "unknown `op`"))?; + let id = LayerId(target); + with_shape(s, id, "Add Shape", |sh, _| { + for mut sp in path.subpaths { + sp.op = op; + sh.path.subpaths.push(sp); + } + sh.live = None; + sh.psd_raw = None; + vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?; + Ok(()) + })?; + return shape_info(s, id); + } + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() }; + let id = s.edit("New Shape Layer", |doc, active| { + refresh_shape(doc, &mut sh); + let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind))); + let id = doc.insert_above(*active, Layer::new(name, LayerContent::Shape(sh))); + *active = Some(id); + Ok(id) + })?; + shape_info(s, id) +} + +fn shape_edit(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "shape.edit"; + let id = layer_id(s, p)?; + let fg = s.tools.foreground; + let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + with_shape(s, id, "Edit Shape", |sh, layer| { + if let Some(v) = p.get("path") { + sh.path = parse_path(v).map_err(|e| bad(CMD, e))?; + sh.live = None; + sh.psd_raw = None; + } + let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; + if geom_keys.iter().any(|k| p.get(*k).is_some()) { + let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); + let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; + // Explicit radii on a plain rectangle keep it a (now rounded) rectangle. + let kind = if kind == "rect" && radii(p).is_some_and(|r| r.iter().any(|v| *v > 0.0)) { "roundedRect".to_string() } else { kind }; + let l = live_from_params(CMD, &kind, p, sh.live.as_ref())?; + // A transformed live shape's path may carry nonuniform corner scaling + // that its scalar radii cannot represent. Re-entering unchanged values + // must not regenerate the outline and silently discard that geometry. + if sh.live.as_ref() != Some(&l) { + sh.path = vector::shapes::live_path(&l); + sh.live = Some(l); + sh.psd_raw = None; + } + } + if let Some(m) = nums::<6>(p, "transform") { + transform_shape(sh, &Affine { m }); + } + if let Some([dx, dy]) = nums::<2>(p, "move") { + transform_shape(sh, &Affine::translate(dx, dy)); + } + if let Some(op) = p.get("op").and_then(Value::as_str) { + // Operation of the selected subpath(s) (`subpath`: index, default all but the first). + let op = op_from(op).ok_or_else(|| bad(CMD, format!("unknown op `{op}`")))?; + match p.get("subpath").and_then(Value::as_u64) { + Some(i) => sh.path.subpaths.get_mut(i as usize).ok_or_else(|| bad(CMD, "no such subpath"))?.op = op, + None => sh.path.subpaths.iter_mut().skip(1).for_each(|sp| sp.op = op), + } + sh.live = None; + } + if let Some(r) = p.get("fillRule").and_then(Value::as_str) { + sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }; + } + if let Some(v) = p.get("fill") { + sh.fill = parse_fill(v).map_err(|e| bad(CMD, e))?; + } + if let Some(v) = p.get("stroke") { + sh.stroke = parse_stroke(v, sh.stroke.clone(), fgc).map_err(|e| bad(CMD, e))?; + } + if let Some(n) = name { + layer.name = n; + } + vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?; + Ok(()) + })?; + shape_info(s, id) +} + +fn shape_rasterize(s: &mut Session, p: &Value) -> Result { + let id = layer_id(s, p)?; + s.edit("Rasterize Shape", |doc, _| { + let snapshot = doc.clone(); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let LayerContent::Shape(sh) = &mut l.content else { + return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); + }; + if sh.cache.is_none() { + refresh_shape(&snapshot, sh); + } + let fmt = snapshot.pixel_format(); + let surface = sh.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(fmt)); + let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) }; + l.content = LayerContent::Raster(surface); + l.psd_blocks.retain(|(k, _)| !SHAPE_BLOCKS.contains(&k)); + Ok(()) + })?; + Ok(json!({ "layer": id.0 })) +} + +// --------------------------------------------------------------------------- +// Document paths +// --------------------------------------------------------------------------- + +pub(crate) fn is_work(name: Option<&str>) -> bool { + name.is_none_or(|n| n.is_empty() || n.eq_ignore_ascii_case("work") || n == "Work Path") +} + +/// The path a command targets: `"work"`/absent → the work path (falling back to the active +/// layer's shape path or vector mask when there is none), `"layer"` → the active layer's, +/// otherwise a saved path by name. +fn resolve_path(s: &Session, name: Option<&str>) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let layer_path = || -> Option { + let l = d.doc.layer(d.active_layer?)?; + match &l.content { + LayerContent::Shape(sh) => Some(sh.path.clone()), + _ => l.vector_mask.as_ref().map(|v| v.path.clone()), + } + }; + if name == Some("layer") { + return layer_path().ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into())); + } + if is_work(name) { + return d.doc.work_path.clone().or_else(layer_path).ok_or_else(|| EngineError::Other("no work path".into())); + } + let n = name.unwrap_or_default(); + d.doc.paths.iter().find(|p| p.name == n).map(|p| p.path.clone()).ok_or_else(|| EngineError::Other(format!("no path named \"{n}\""))) +} + +/// The path an edit targets, except a shape layer's (edit those with [`with_shape`], which +/// re-renders them): `layer`'s vector mask when given, else the work path or a saved path. +pub(crate) fn path_mut<'a>(doc: &'a mut Document, name: &str, layer: Option) -> Result<&'a mut Path> { + if let Some(id) = layer { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + return l.vector_mask.as_mut().map(|vm| &mut vm.path).ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into())); + } + if is_work(Some(name)) { + return doc.work_path.as_mut().ok_or_else(|| EngineError::Other("no work path".into())); + } + doc.paths.iter_mut().find(|q| q.name == name).map(|q| &mut q.path).ok_or_else(|| EngineError::Other(format!("no path named \"{name}\""))) +} + +fn paths_list(s: &Session) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let doc = &d.doc; + let knots = |p: &Path| p.subpaths.iter().map(|s| s.knots.len()).sum::(); + let layer = d.active_layer.and_then(|id| doc.layer(id)).and_then(|l| match &l.content { + LayerContent::Shape(sh) => Some(json!({ "kind": "shape", "layer": l.id.0, "name": format!("{} Shape Path", l.name), "knots": knots(&sh.path) })), + _ => { + l.vector_mask.as_ref().map(|v| json!({ "kind": "vectorMask", "layer": l.id.0, "name": format!("{} Vector Mask", l.name), "knots": knots(&v.path) })) + } + }); + Ok(json!({ + "paths": doc.paths.iter().map(|p| json!({ "name": p.name, "knots": knots(&p.path), "subpaths": p.path.subpaths.len() })).collect::>(), + "workPath": doc.work_path.as_ref().map(|p| json!({ "knots": knots(p), "subpaths": p.subpaths.len() })), + "clippingPath": doc.clipping_path.as_ref().map(|c| json!({ "name": c.name, "flatness": c.flatness })), + "layerPath": layer, + })) +} + +fn path_set(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.set"; + let path = parse_path(p.get("path").ok_or_else(|| bad(CMD, "missing `path`"))?).map_err(|e| bad(CMD, e))?; + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?; + s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| { + let merge = |old: Option<&Path>, new: Path| match (old, append) { + (Some(o), Some(op)) => { + let mut o = o.clone(); + o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { + sp.op = op; + sp + })); + o + } + _ => new, + }; + if is_work(name.as_deref()) { + doc.work_path = Some(merge(doc.work_path.as_ref(), path)); + } else { + let n = name.clone().unwrap_or_default(); + match doc.paths.iter_mut().find(|q| q.name == n) { + Some(q) => { + q.path = merge(Some(&q.path), path); + } + None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }), + } + } + Ok(()) + })?; + let path = resolve_path(s, name.as_deref())?; + Ok(json!({ "name": name.unwrap_or_else(|| "work".into()), "path": path_json(&path) })) +} + +fn path_delete(s: &mut Session, p: &Value) -> Result { + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + s.edit("Delete Path", |doc, _| { + if is_work(name.as_deref()) { + return doc.work_path.take().map(|_| ()).ok_or_else(|| EngineError::Other("no work path".into())); + } + let n = name.clone().unwrap_or_default(); + let before = doc.paths.len(); + doc.paths.retain(|q| q.name != n); + if doc.paths.len() == before { + return Err(EngineError::Other(format!("no path named \"{n}\""))); + } + if doc.clipping_path.as_ref().is_some_and(|c| c.name == n) { + doc.clipping_path = None; + } + Ok(()) + })?; + Ok(Value::Null) +} + +fn clipping_path_set(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.clippingPath.set"; + let name = p.get("name").and_then(Value::as_str).filter(|name| !name.is_empty()).ok_or_else(|| bad(CMD, "missing saved path `name`"))?.to_owned(); + let flatness = match p.get("flatness") { + Some(value) => { + value.as_f64().filter(|value| value.is_finite() && (0.0..=100.0).contains(value)).ok_or_else(|| bad(CMD, "`flatness` must be between 0 and 100"))? + as f32 + } + None => 0.0, + }; + s.edit("Clipping Path", |doc, _| { + if !doc.paths.iter().any(|path| path.name == name) { + return Err(EngineError::Other(format!("no saved path named \"{name}\""))); + } + doc.clipping_path = Some(ClippingPath { name: name.clone(), flatness }); + Ok(()) + })?; + Ok(json!({ "name": name, "flatness": flatness })) +} + +fn clipping_path_clear(s: &mut Session, _p: &Value) -> Result { + s.edit("Clear Clipping Path", |doc, _| { + doc.clipping_path.take().ok_or_else(|| EngineError::Other("no clipping path".into()))?; + Ok(()) + })?; + Ok(Value::Null) +} + +/// The path `path.transform` edits: `layer`'s shape path or vector mask, else the work path or a +/// saved path. +fn transform_target(s: &Session, name: &str, layer: Option) -> Result { + let Some(id) = layer else { return resolve_path(s, Some(name)) }; + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + match &l.content { + LayerContent::Shape(sh) => Ok(sh.path.clone()), + _ => l.vector_mask.as_ref().map(|v| v.path.clone()).ok_or_else(|| EngineError::Other("target layer has no shape path or vector mask".into())), + } +} + +/// `n` numbers at `key` (`[x0, y0, x1, y1]` or `[[x, y] × 4]`, flattened): finite, magnitude at +/// most 1000000. `None` when `key` is absent. +fn coords(p: &Value, key: &str, n: usize) -> Result>> { + const CMD: &str = "path.transform"; + let Some(v) = p.get(key) else { return Ok(None) }; + let flat: Vec<&Value> = match v.as_array() { + Some(a) if a.iter().all(Value::is_array) => a.iter().filter_map(Value::as_array).flatten().collect(), + Some(a) => a.iter().collect(), + None => Vec::new(), + }; + let nums: Vec = flat.iter().filter_map(|v| v.as_f64()).filter(|x| x.is_finite() && x.abs() <= 1_000_000.0).collect(); + if flat.len() != n || nums.len() != n { + return Err(bad(CMD, format!("`{key}` must hold {n} finite numbers with magnitude at most 1000000"))); + } + Ok(Some(nums)) +} + +/// How `path.transform` moves the path. +enum PathMap { + Affine(Affine), + /// Distort / Perspective: the box's corners go to a quad. + Projective(photocraft_algo::transform::Homography), + Warp(Box), +} + +fn path_transform(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.transform"; + let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned(); + let layer_id = if name == "layer" { Some(layer_id(s, p)?) } else { None }; + // The box Distort, Perspective and Warp frame: `rect`, else the path's control bounds. + let frame = |s: &Session| -> Result<[f64; 4]> { + let r = match coords(p, "rect", 4)? { + Some(r) => [r[0], r[1], r[2], r[3]], + None => { + let (x0, y0, x1, y1) = transform_target(s, &name, layer_id)?.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?; + [x0, y0, x1, y1] + } + }; + if r[2] - r[0] <= 0.0 || r[3] - r[1] <= 0.0 { + return Err(bad(CMD, "`rect` must have a positive width and height (a straight path needs a `rect`)")); + } + Ok(r) + }; + let map = if let Some(values) = p.get("matrix") { + let values = values.as_array().filter(|values| values.len() == 6).ok_or_else(|| bad(CMD, "`matrix` must contain six numbers"))?; + let mut m = [0.0; 6]; + for (slot, value) in m.iter_mut().zip(values) { + *slot = value + .as_f64() + .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) + .ok_or_else(|| bad(CMD, "matrix entries must be finite numbers with magnitude at most 1000000"))?; + } + PathMap::Affine(Affine { m }) + } else if let Some(q) = coords(p, "quad", 8)? { + // Distort and Perspective (#3027): where the frame's corners go, clockwise from top-left. + let quad = [[q[0], q[1]], [q[2], q[3]], [q[4], q[5]], [q[6], q[7]]]; + let h = photocraft_algo::transform::Homography::rect_to_quad(frame(s)?, quad) + .filter(|h| h.0.iter().all(|v| v.is_finite()) && h.inverse().is_some()) + .ok_or_else(|| bad(CMD, "degenerate quad"))?; + let [a, b, c, d, e, f, g, hh, i] = h.0; + if g.abs() < 1e-12 && hh.abs() < 1e-12 && i.abs() > 1e-12 { + // A parallelogram is affine: a live shape stays live. + PathMap::Affine(Affine { m: [a / i, d / i, b / i, e / i, c / i, f / i] }) + } else { + PathMap::Projective(h) + } + } else if ["warp", "style", "mesh"].iter().any(|key| p.get(*key).is_some()) { + let w = crate::warp_cmds::warp_from_params(CMD, p, frame(s)?)?; + if [w.bend, w.h_distort, w.v_distort].iter().chain(w.bounds.iter()).any(|v| !v.is_finite() || v.abs() > 1_000_000.0) { + return Err(bad(CMD, "warp values must be finite numbers with magnitude at most 1000000")); + } + PathMap::Warp(Box::new(w)) + } else { + let number = |key: &str, default: f64| -> Result { + match p.get(key) { + Some(value) => value + .as_f64() + .filter(|n| n.is_finite() && n.abs() <= 1_000_000.0) + .ok_or_else(|| bad(CMD, format!("`{key}` must be a finite number with magnitude at most 1000000"))), + None => Ok(default), + } + }; + if !["translateX", "translateY", "scaleX", "scaleY", "angle"].iter().any(|key| p.get(*key).is_some()) { + return Err(bad(CMD, "pass `matrix`, `quad`, a warp (`style`, `mesh` or `warp`) or translate/scale/angle parameters")); + } + let (tx, ty) = (number("translateX", 0.0)?, number("translateY", 0.0)?); + let (sx, sy) = (number("scaleX", 1.0)?, number("scaleY", 1.0)?); + let angle = number("angle", 0.0)?; + // An explicit `layer` parameter determines both the transformed path and its pivot (#3062). + let path = transform_target(s, &name, layer_id)?; + let (x0, y0, x1, y1) = path.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?; + let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5); + PathMap::Affine( + Affine::translate(-cx, -cy) + .then(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }) + .then(&Affine::rotate(angle.to_radians())) + .then(&Affine::translate(cx + tx, cy + ty)), + ) + }; + let out = match &map { + PathMap::Affine(a) => json!({ "name": name, "matrix": a.m }), + PathMap::Projective(h) => json!({ "name": name, "homography": h.0 }), + PathMap::Warp(_) => json!({ "name": name, "warp": true }), + }; + if let Some(id) = layer_id + && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|layer| matches!(layer.content, LayerContent::Shape(_))) + { + with_shape(s, id, "Transform Path", |shape, _| { + match &map { + PathMap::Affine(a) => transform_shape(shape, a), + PathMap::Projective(h) => map_shape(shape, Some(h), None)?, + PathMap::Warp(w) => map_shape(shape, None, Some(w))?, + } + shape.psd_raw = None; + Ok(()) + })?; + return Ok(out); + } + s.edit("Transform Path", |doc, _| { + let path = path_mut(doc, &name, layer_id)?; + *path = match &map { + PathMap::Affine(a) => path.transform(a), + PathMap::Projective(h) => map_path(path, Some(h), None)?, + PathMap::Warp(w) => map_path(path, None, Some(w))?, + }; + Ok(()) + })?; + Ok(out) +} + +fn path_rename(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.rename"; + let name = p.get("name").and_then(Value::as_str).map(str::to_string); + let to = p.get("to").and_then(Value::as_str).filter(|t| !t.is_empty()).ok_or_else(|| bad(CMD, "missing `to`"))?.to_string(); + if is_work(Some(&to)) { + return Err(bad(CMD, "`to` cannot name the work path")); + } + s.edit(if is_work(name.as_deref()) { "Save Path" } else { "Rename Path" }, |doc, _| { + if doc.paths.iter().any(|q| q.name == to) { + return Err(EngineError::Other(format!("a path named \"{to}\" already exists"))); + } + if is_work(name.as_deref()) { + // Photoshop: saving the work path turns it into a named path. + let path = doc.work_path.take().ok_or_else(|| EngineError::Other("no work path".into()))?; + doc.paths.push(NamedPath { name: to.clone(), path, psd_raw: None }); + return Ok(()); + } + let n = name.clone().unwrap_or_default(); + let q = doc.paths.iter_mut().find(|q| q.name == n).ok_or_else(|| EngineError::Other(format!("no path named \"{n}\"")))?; + q.name = to.clone(); + if let Some(c) = doc.clipping_path.as_mut() + && c.name == n + { + c.name = to.clone(); + } + Ok(()) + })?; + Ok(json!({ "name": to })) +} + +fn path_to_selection(s: &mut Session, p: &Value) -> Result { + let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; + // Validate before rasterizing or touching selection history. An unchecked feather value + // could silently become zero (negative/string), or expand into an excessive blur. + let feather = match p.get("feather") { + None => 0.0, + Some(v) => v + .as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, + }; + let aa = match p.get("antiAlias") { + None => true, + Some(Value::Bool(v)) => *v, + _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), + }; + let mode = match p.get("mode") { + None => SelectionMode::Replace, + Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), + _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), + }; + let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); + let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); + if !aa { + for v in &mut mask { + *v = if *v >= 0.5 { 1.0 } else { 0.0 }; + } + } + if feather > 0.0 { + mask = sel::feather(&mask, area.width() as usize, area.height() as usize, feather); + } + let selected = s.edit("Make Selection", |doc, _| { + doc.selection = sel::combine(doc.selection.as_ref(), &mask, area, mode); + Ok(doc.selection.is_some()) + })?; + Ok(json!({ "selected": selected })) +} + +/// Runs `path.toSelection` with a forced combine mode (Ctrl+Shift+Enter / Ctrl+Alt+Enter). +/// Any `mode` passed by the caller is overridden; the other params (`name`, `feather`, +/// `antiAlias`) are forwarded unchanged and validated by `path_to_selection`. +/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); +/// a `mode` in the params is overridden, the rest is forwarded and validated as usual. +fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { + let mut q = match p { + Value::Object(_) => p.clone(), + _ => json!({}), + }; + q["mode"] = json!(mode); + path_to_selection(s, &q) +} + +fn select_to_work_path(s: &mut Session, p: &Value) -> Result { + let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); + let d = s.active().ok_or(EngineError::NoDocument)?; + let area = d.doc.bounds(); + let sel_surface = d.doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?; + let mask = sel::mask_from_surface(Some(sel_surface), area); + let path = vector::trace::trace_mask(&mask, area, 0.0, tol); + let knots: usize = path.subpaths.iter().map(|s| s.knots.len()).sum(); + let subpaths = path.subpaths.len(); + s.edit("Make Work Path", |doc, _| { + doc.work_path = Some(path); + Ok(()) + })?; + Ok(json!({ "subpaths": subpaths, "knots": knots })) +} + +fn path_fill(s: &mut Session, p: &Value) -> Result { + let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; + let fg = s.tools.foreground; + let c = p.get("color").and_then(color).unwrap_or(Color::rgba(fg[0], fg[1], fg[2], fg[3])); + let rgb = c.to_rgb(); + let src = [rgb[0], rgb[1], rgb[2], c.alpha]; + let opacity = (f64p(p, "opacity").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; + let mode = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(BlendMode::Normal); + let feather = f64p(p, "feather").unwrap_or(0.0) as f32; + let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); + let id = layer_id(s, p)?; + s.edit("Fill Path", |doc, _| { + let area = doc.bounds(); + let r = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE); + // The feather reaches 3σ = 3 × radius (σ = radius, as in Photoshop), plus a pixel. + let feather_pad = (feather.ceil() as i32).saturating_mul(3).saturating_add(1); + let area = r.pixel_bounds().map_or(area, |b| b.inflate(feather_pad).intersect(&area)); + if area.is_empty() { + return Ok(()); + } + let mut cov = r.render(area); + if !aa { + cov.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); + } + if feather > 0.0 { + cov = sel::feather(&cov, area.width() as usize, area.height() as usize, feather); + } + // An active selection limits the fill, soft edges included, as every other fill does (#2387). + if let Some(selection) = doc.selection.as_ref() { + let mask = sel::mask_from_surface(Some(selection), area); + cov.iter_mut().zip(&mask).for_each(|(c, m)| *c *= m); + } + let locks = doc.effective_locks(id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if locks.pixels || locks.all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); + } + let lock = locks.transparency; + let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Fill Path needs a pixel layer".into()))?; + paint_coverage(surf, area, &cov, src, opacity, mode, lock); + Ok(()) + })?; + Ok(Value::Null) +} + +/// Composites `src` × coverage onto a surface over `area` (any pixel format). +fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32], src: [f32; 4], opacity: f32, mode: BlendMode, lock_alpha: bool) { + let fmt = surf.format(); + let ch = fmt.channels(); + let mut vals = surf.read_region(area); + for (px, c) in vals.chunks_exact_mut(ch).zip(cov) { + if *c <= 0.0 { + continue; + } + let dst = photocraft_raster::to_rgba(&fmt, px); + let mut out = photocraft_compose::psblend::composite(mode, dst, [src[0], src[1], src[2], src[3] * c], opacity); + if lock_alpha { + out[3] = dst[3]; + } + photocraft_raster::from_rgba_into(&fmt, out, px); + } + surf.write_region(area, &vals); + surf.prune(); +} + +/// Stroke Path's brush-based tools besides the Brush, Pencil and Eraser it renders itself: each +/// paints the path with its own `paint.*` command (Photoshop's Stroke Path dialog Tool list). +pub const STROKE_PATH_TOOLS: &[(&str, &str)] = &[ + ("backgroundEraser", "paint.backgroundEraser"), + ("cloneStamp", "paint.cloneStamp"), + ("patternStamp", "paint.patternStamp"), + ("healingBrush", "paint.healingBrush"), + ("spotHealingBrush", "paint.spotHealing"), + ("historyBrush", "paint.historyBrush"), + ("blur", "paint.blur"), + ("sharpen", "paint.sharpen"), + ("smudge", "paint.smudge"), + ("dodge", "paint.dodge"), + ("burn", "paint.burn"), + ("sponge", "paint.sponge"), + ("colorReplacement", "paint.colorReplacement"), + ("mixerBrush", "paint.mixerBrush"), +]; + +/// At most this many points per subpath when Simulate Pressure resamples it. +const MAX_PRESSURE_POINTS: f64 = 200_000.0; + +/// The stroke points along one flattened subpath. With `simulate` (Stroke Path's Simulate +/// Pressure) the subpath is resampled about every pixel and the pressure rises from 0 at its start +/// to full at the middle and falls back to 0 at its end, so a brush whose size or opacity follows +/// pen pressure tapers at both ends, as in Photoshop. Otherwise every point has full pressure. +fn stroke_points(pts: &[(f64, f64)], closed: bool, simulate: bool) -> Vec { + use photocraft_paint::StrokePoint; + let mut line = pts.to_vec(); + if closed && let Some(&first) = pts.first() { + line.push(first); + } + let full = |line: Vec<(f64, f64)>| line.into_iter().map(|(x, y)| StrokePoint::new(x, y, 1.0)).collect(); + if !simulate { + return full(line); + } + let seg = |a: &(f64, f64), b: &(f64, f64)| (b.0 - a.0).hypot(b.1 - a.1); + let total: f64 = line.iter().zip(line.iter().skip(1)).map(|(a, b)| seg(a, b)).sum(); + if !(total.is_finite() && total > 0.0) { + return full(line); + } + let step = (total / MAX_PRESSURE_POINTS).max(1.0); + let pressure = |d: f64| (d / total * std::f64::consts::PI).sin().clamp(0.0, 1.0) as f32; + let mut out = Vec::new(); + let mut walked = 0.0; + for (a, b) in line.iter().zip(line.iter().skip(1)) { + let len = seg(a, b); + let n = (len / step).ceil().clamp(1.0, MAX_PRESSURE_POINTS) as usize; + for i in 0..n { + let t = i as f64 / n as f64; + out.push(StrokePoint::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, pressure(walked + len * t))); + } + walked += len; + } + if let Some(&(x, y)) = line.last() { + out.push(StrokePoint::new(x, y, 0.0)); + } + out +} + +/// Stroke Path with a tool that has its own `paint.*` command: the command runs once per subpath, +/// with the caller's params (size, opacity, the tool's options) and the path's points, all in one +/// history step. Only `path.stroke` itself is journalled, so a recorded action replays it once. +fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::Polyline], simulate: bool) -> Result { + let mut params = p.as_object().cloned().unwrap_or_default(); + for k in ["name", "tool", "simulatePressure", "coalesce"] { + params.remove(k); + } + let journal = s.journal.len(); + let r = crate::analysis_cmds::compound(s, "Stroke Path", |s| { + // The union of what each subpath's command changed; None when one of them didn't say. + let mut dmg = Some(Rect::EMPTY); + for pl in lines { + let pts = stroke_points(&pl.pts, pl.closed, simulate); + if pts.is_empty() { + continue; + } + let mut call = params.clone(); + call.insert("points".into(), Value::Array(pts.iter().map(|q| json!([q.x, q.y, q.pressure])).collect())); + s.execute(cmd, Value::Object(call))?; + let r = s.active().and_then(|d| d.last_damage); + dmg = dmg.zip(r).map(|(a, b)| { + if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + a.union(&b) + } + }); + } + Ok(dmg) + }); + s.journal.truncate(journal); + let dmg = r?; + if let Some(st) = s.active_mut() { + st.last_damage = dmg; + } + let dmg = dmg.unwrap_or_else(|| s.active().map_or(Rect::EMPTY, |d| d.doc.bounds())); + Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) +} + +fn path_stroke(s: &mut Session, p: &Value) -> Result { + let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; + let tool = p.get("tool").and_then(Value::as_str).unwrap_or("brush"); + let simulate = match p.get("simulatePressure") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("path.stroke", "`simulatePressure` must be true or false"))?, + }; + if let Some(&(_, cmd)) = STROKE_PATH_TOOLS.iter().find(|(t, _)| *t == tool) { + let lines = vector::flatten_path(&path, 0.1); + return stroke_with_command(s, p, cmd, &lines, simulate); + } + let base = s.tools.brush.clone(); + let fg = s.tools.foreground; + let mut brush = photocraft_paint::BrushSettings { + size: f64p(p, "size").map_or(base.size, |v| v as f32), + hardness: f64p(p, "hardness").map_or(base.hardness, |v| v as f32), + opacity: f64p(p, "opacity").map_or(base.opacity, |v| (v / 100.0) as f32), + color: p.get("color").and_then(color).map_or(fg, |c| { + let v = c.to_rgb(); + [v[0], v[1], v[2], c.alpha] + }), + erase: tool == "eraser", + ..base + }; + // The path is the exact geometry: stroke smoothing (a hand-drawing aid) would cut its corners. + brush.smoothing.amount = 0.0; + match tool { + "pencil" => brush.hardness = 1.0, + "brush" | "eraser" => {} + o => { + let tools: Vec<&str> = ["brush", "pencil", "eraser"].into_iter().chain(STROKE_PATH_TOOLS.iter().map(|(t, _)| *t)).collect(); + return Err(bad("path.stroke", format!("unknown tool `{o}` ({})", tools.join("|")))); + } + } + crate::brush_cmds::validate_brush(&brush, "path.stroke")?; + let lines = vector::flatten_path(&path, 0.1); + let id = layer_id(s, p)?; + let bg = s.tools.background; + let dmg = s.edit("Stroke Path", |doc, _| { + let sel = doc.selection.clone(); + let locks = doc.effective_locks(id); + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + // Pixel and full locks (the layer's own or a parent group's) refuse the stroke, as Fill Path does (#2388). + if locks.pixels || locks.all { + return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); + } + let lock = locks.transparency; + let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?; + let mut brush = brush.clone(); + if brush.erase && lock { + // Locked transparency (e.g. the Background): the Eraser paints the background colour (#76). + brush.erase = false; + brush.color = bg; + } + let mut dmg = Rect::EMPTY; + for pl in &lines { + let pts = stroke_points(&pl.pts, pl.closed, simulate); + if pts.is_empty() { + continue; + } + let r = photocraft_paint::apply_stroke(surf, &photocraft_paint::Stroke { brush: brush.clone(), points: pts }, sel.as_ref(), lock); + dmg = if dmg.is_empty() { r } else { dmg.union(&r) }; + } + Ok(dmg) + })?; + Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) +} + +// --------------------------------------------------------------------------- +// Vector masks +// --------------------------------------------------------------------------- + +fn vector_mask_info(s: &Session, id: LayerId) -> Result { + let d = s.active().ok_or(EngineError::NoDocument)?; + let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; + Ok(match (&l.vector_mask, &l.content) { + (Some(v), _) => { + json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) + } + // A shape layer's path is its vector mask. + (None, LayerContent::Shape(sh)) => { + json!({ "layer": id.0, "path": path_json(&sh.path), "enabled": true, "linked": true, "shape": true, "density": sh.density * 100.0, "feather": sh.feather }) + } + (None, _) => json!({ "layer": id.0, "vectorMask": null }), + }) +} + +fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result { + const CMD: &str = "layer.vectorMask.add"; + let id = layer_id(s, p)?; + let mut path = match p.get("path") { + Some(v) => parse_path(v).map_err(|e| bad(CMD, e))?, + None if from_path || p.get("name").is_some() => { + let name = p.get("name").and_then(Value::as_str); + // Never fall back to the layer's own path here. + if is_work(name) { + s.active().and_then(|d| d.doc.work_path.clone()).ok_or_else(|| EngineError::Other("no work path".into()))? + } else { + resolve_path(s, name)? + } + } + None => Path::default(), + }; + if p.get("hide").and_then(Value::as_bool).unwrap_or(false) { + path.inverted = !path.inverted; + } + s.edit("Add Vector Mask", |doc, _| { + let locked = doc.effective_locks(id).all; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + if matches!(l.content, LayerContent::Shape(_)) { + return Err(EngineError::Other("a shape layer's path is already its vector mask".into())); + } + if locked { + return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); + } + crate::extra_cmds::background_to_layer(doc, id); + doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.vector_mask = Some(VectorMask::new(path)); + Ok(()) + })?; + vector_mask_info(s, id) +} + +fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layer.vectorMask.edit"; + let id = layer_id(s, p)?; + let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?; + // Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201). + let feather = p + .get("feather") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) + .map(|n| n as f32) + .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) + }) + .transpose()?; + let density = p + .get("density") + .map(|v| { + v.as_f64() + .filter(|n| n.is_finite() && (0.0..=100.0).contains(n)) + .map(|n| (n / 100.0) as f32) + .ok_or_else(|| bad(CMD, "`density` must be a finite number within 0..100 (%)")) + }) + .transpose()?; + // A shape layer's path is its vector mask (Photoshop's Properties › Masks): Density and + // Feather soften its fill; the path itself is edited with the shape and path commands. + if s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)) && l.vector_mask.is_none()) { + if let Some(k) = ["path", "enabled", "linked", "invert"].into_iter().find(|k| p.get(*k).is_some()) { + return Err(bad(CMD, format!("`{k}`: a shape layer's vector mask is its path; only `density` and `feather` apply"))); + } + if density.is_none() && feather.is_none() { + return vector_mask_info(s, id); + } + with_shape(s, id, "Edit Vector Mask", |sh, _| { + if let Some(d) = density { + sh.density = d; + } + if let Some(f) = feather { + sh.feather = f; + } + Ok(()) + })?; + return vector_mask_info(s, id); + } + s.edit("Edit Vector Mask", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; + if let Some(pth) = path { + vm.path = pth; + } + if let Some(b) = p.get("enabled").and_then(Value::as_bool) { + vm.enabled = b; + } + if let Some(b) = p.get("linked").and_then(Value::as_bool) { + vm.linked = b; + } + if let Some(d) = density { + vm.density = d; + } + if let Some(f) = feather { + vm.feather = f; + } + if let Some(b) = p.get("invert").and_then(Value::as_bool) + && b + { + vm.path.inverted = !vm.path.inverted; + } + Ok(()) + })?; + vector_mask_info(s, id) +} + +fn vector_mask_delete(s: &mut Session, p: &Value) -> Result { + let id = layer_id(s, p)?; + s.edit("Delete Vector Mask", |doc, _| { + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + l.vector_mask.take().map(|_| ()).ok_or_else(|| EngineError::Other("layer has no vector mask".into())) + })?; + Ok(Value::Null) +} + +/// Layer › Rasterize › Vector Mask: multiplies the vector mask into the pixel mask. When a feather +/// (either mask's) or the pixel mask's density shapes the rendered edge, the pixel mask instead +/// becomes exactly what the compositor showed, with those settings baked in (#992). +fn vector_mask_rasterize(s: &mut Session, p: &Value) -> Result { + use photocraft_compose::masks::{combined_mask, feather_sigma}; + let id = layer_id(s, p)?; + s.edit("Rasterize Vector Mask", |doc, _| { + let area = doc.bounds(); + let depth = doc.depth; + let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; + let soft = l.vector_mask.as_ref().is_some_and(|v| v.enabled) + && (l.vector_mask.as_ref().is_some_and(|v| feather_sigma(v.feather) > 0.0) + || l.mask.as_ref().is_some_and(|m| m.enabled && (feather_sigma(m.feather) > 0.0 || m.density < 1.0))); + let shown = if soft { combined_mask(l, area) } else { None }; + let vm = l.vector_mask.take().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; + let mut mask = l.mask.take().unwrap_or_else(|| { + let mut m = photocraft_doc::LayerMask::reveal_all(); + m.surface = + photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, depth, false), &[1.0]); + m + }); + if let Some(shown) = shown { + // Past the canvas the pixel mask keeps its own (density-applied) default, as below. + let d = mask.surface.default_pixel().first().copied().unwrap_or(1.0); + let mut surface = photocraft_raster::Surface::with_default(mask.surface.format(), &[1.0 - mask.density * (1.0 - d)]); + surface.write_region(area, &shown.read_region(area)); + surface.prune(); + (mask.surface, mask.feather, mask.density) = (surface, 0.0, 1.0); + l.mask = Some(mask); + return Ok(()); + } + let vals = vector::vector_mask_values(&vm, area); + // Outside the canvas the vector mask is 0 unless it is empty/inverted; keep the old default there. + let old = mask.surface.read_region(area); + let merged: Vec = old.iter().zip(&vals).map(|(a, b)| a * b).collect(); + mask.surface.write_region(area, &merged); + mask.surface.prune(); + l.mask = Some(mask); + Ok(()) + })?; + Ok(json!({ "layer": id.0 })) +} + +/// `p` with `key` set (for alias commands). +fn with(p: &Value, key: &str, v: Value) -> Value { + let mut o = if p.is_object() { p.clone() } else { json!({}) }; + o[key] = v; + o +} + +fn toggle_vector_mask(s: &mut Session, p: &Value, key: &str) -> Result { + let id = layer_id(s, p)?; + let cur = { + let d = s.active().ok_or(EngineError::NoDocument)?; + let vm = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.vector_mask.as_ref().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; + if key == "enabled" { vm.enabled } else { vm.linked } + }; + let v = p.get(key).and_then(Value::as_bool).unwrap_or(!cur); + vector_mask_edit(s, &json!({ "layer": id.0, key: v })) +} + +/// Traces the exact coverage of the shape's path back into plain outlines. +fn merge_components(s: &mut Session, p: &Value) -> Result { + let tol = f64p(p, "tolerance").unwrap_or(0.1).clamp(0.05, 10.0); + if let Some(ids) = combine_targets(s, p, &["layer"])? { + return merge_shape_layers(s, &ids, None, "Merge Shape Components", Some(tol)); + } + let id = layer_id(s, p)?; + with_shape(s, id, "Merge Shape Components", |sh, _| bake_components(sh, tol))?; + shape_info(s, id) +} + +/// Bakes `sh`'s path operations into plain combined outlines, traced from the exact coverage. +fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { + // Trace at 4× resolution so the fitted outline follows the anti-aliased edge closely. + const K: f64 = 4.0; + let up = sh.path.transform(&Affine::scale(K)); + let r = vector::fill_rasterizer(&up, vector::DEFAULT_TOLERANCE); + let Some(b) = r.pixel_bounds() else { + sh.path.subpaths.clear(); + return Ok(()); + }; + let b = b.inflate(2); + if u64::from(b.width()) * u64::from(b.height()) > 400_000_000 { + return Err(EngineError::Other("shape too large to merge".into())); + } + let cov = r.render(b); + let traced = vector::trace::trace_mask(&cov, b, 0.0, tol * K); + sh.path = traced.transform(&Affine::scale(1.0 / K)); + sh.live = None; + sh.psd_raw = None; + Ok(()) +} + +/// The shape layers Layer › Combine Shapes merges into one: with none of the `narrow` params +/// given and two or more shape layers selected, those layers bottom-to-top (other selected layers +/// are left alone). `None` keeps the single-layer behaviour (acting on one layer's components). +fn combine_targets(s: &Session, p: &Value, narrow: &[&str]) -> Result>> { + if narrow.iter().any(|k| p.get(*k).is_some()) { + return Ok(None); + } + let d = s.active().ok_or(EngineError::NoDocument)?; + let sel = d.selected_layers(); + let ids = crate::layer_multi_cmds::top_level(&d.doc, &sel); + let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))).collect(); + Ok((ids.len() > 1).then_some(ids)) +} + +/// Layer › Combine Shapes with several shape layers selected: Photoshop merges them into the +/// top-most one. The bottom layer's components keep their operations; every component of the +/// layers above takes `op` (front shapes subtract, intersect… in stacking order), or, with no +/// `op` (Merge Shape Components), keeps its own with each layer's first component combined, and +/// the result is then baked with tolerance `bake`. The other layers are removed; one history step. +fn merge_shape_layers(s: &mut Session, ids: &[LayerId], op: Option, label: &str, bake: Option) -> Result { + let (&keep, _) = ids.split_last().ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?; + if let Some(st) = s.active() { + for id in ids { + let locks = st.doc.effective_locks(*id); + if locks.all || locks.pixels { + return Err(EngineError::Other("a selected shape layer is locked".into())); + } + } + } + s.edit(label, |doc, active| { + let mut path: Option = None; + for id in ids { + let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?; + let LayerContent::Shape(sh) = &l.content else { + return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); + }; + let Some(acc) = path.as_mut() else { + path = Some(sh.path.clone()); + continue; + }; + for (j, sp) in sh.path.subpaths.iter().enumerate() { + let mut sp = sp.clone(); + sp.op = match op { + Some(op) => op, + None if j == 0 => PathOp::Combine, + None => sp.op, + }; + acc.subpaths.push(sp); + } + } + let path = path.ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?; + let snapshot = doc.clone(); + let l = doc.layer_mut(keep).ok_or(EngineError::NoLayer(keep))?; + let LayerContent::Shape(sh) = &mut l.content else { + return Err(EngineError::Other(format!("layer {} is not a shape layer", keep.0))); + }; + let mut merged = ShapeLayer { path, live: None, psd_raw: None, cache: None, ..sh.clone() }; + if let Some(tol) = bake { + bake_components(&mut merged, tol)?; + } + vector::flatten::validate_shape(&merged.path, merged.stroke.as_ref()).map_err(|e| bad(label, e))?; + refresh_shape(&snapshot, &mut merged); + *sh = merged; + for id in ids.iter().filter(|id| **id != keep) { + doc.remove(*id); + } + *active = Some(keep); + Ok(()) + })?; + crate::layer_multi_cmds::reselect(s, vec![keep], Some(keep)); + shape_info(s, keep) +} + +/// Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect / Exclude: merges the +/// selected shape layers with `op` ([`merge_shape_layers`]), or with one layer (or an explicit +/// `layer`/`subpath`) sets the operation of that layer's components (`shape.edit`'s `op`). +fn combine_shapes(s: &mut Session, p: &Value, op: &str, label: &str) -> Result { + if let Some(ids) = combine_targets(s, p, &["layer", "subpath"])? { + let op = op_from(op).ok_or_else(|| EngineError::Other(format!("unknown op `{op}`")))?; + return merge_shape_layers(s, &ids, Some(op), label, None); + } + shape_edit(s, &with(p, "op", json!(op))) +} + +fn selection_to_shape(s: &mut Session, p: &Value) -> Result { + let tol = f64p(p, "tolerance").or_else(|| f64p(p, "simplifyTolerance")).unwrap_or(2.0).clamp(0.5, 10.0); + let d = s.active().ok_or(EngineError::NoDocument)?; + let area = d.doc.bounds(); + let mask = sel::mask_from_surface(d.doc.selection.as_ref(), area); + let path = vector::trace::trace_mask(&mask, area, 0.0, tol); + let mut q = json!({ "kind": "path", "path": path_json(&path) }); + for k in ["fill", "name", "stroke"] { + if let Some(v) = p.get(k) { + q[k] = v.clone(); + } + } + shape_create(s, &q) +} + +// --------------------------------------------------------------------------- +// Specs +// --------------------------------------------------------------------------- + +/// Params of the Layer › Combine Shapes operations. +const COMBINE_PARAMS: &str = r##"{"layer":id?,"subpath":index? (default all but the first)} (no layer/subpath and two or more shape layers selected: merges them into the top-most, whose components above the bottom layer's take the operation)"##; + +pub(crate) const PATH_FORM: &str = r##"path: {"subpaths":[{"closed":bool=true,"op":"combine|subtract|intersect|exclude","knots":[[x,y] | {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":bool}]}],"fillRule":"nonzero|evenodd","inverted":bool}"##; + +macro_rules! spec { + ($id:literal, $label:literal, [$($m:literal),*], $params:expr, $en:expr, $run:expr) => { + CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: $en, run: $run, journal: true } + }; +} + +pub(crate) fn leak(s: String) -> &'static str { + Box::leak(s.into_boxed_str()) +} + +pub fn specs() -> Vec { + let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; + let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##; + vec![ + spec!( + "shape.create", + "New Shape Layer", + ["Layer", "New"], + leak(format!( + r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## + )), + has_doc, + shape_create + ), + spec!( + "shape.edit", + "Edit Shape", + [], + leak(format!( + r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## + )), + has_layer, + shape_edit + ), + CommandSpec { + id: "shape.info", + label: "Shape Layer Info", + menu: &[], + shortcut: None, + params: r##"{"layer":id?} → {layer,name,kind,path,fill,stroke,live,bounds:[x,y,w,h]}"##, + enabled: has_layer, + run: |s, p| { + let id = layer_id(s, p)?; + shape_info(s, id) + }, + journal: false, + }, + spec!("shape.rasterize", "Rasterize Shape", ["Layer", "Rasterize"], r##"{"layer":id?}"##, has_layer, shape_rasterize), + CommandSpec { + id: "path.list", + label: "List Paths", + menu: &[], + shortcut: None, + params: r##"{} → {paths:[{name,knots,subpaths}],workPath,clippingPath,layerPath (active layer's shape path / vector mask)}"##, + enabled: has_doc, + run: |s, _| paths_list(s), + journal: false, + }, + CommandSpec { + id: "path.info", + label: "Path Info", + menu: &[], + shortcut: None, + params: r##"{"name":str|"work"|"layer"="work"} → {name,path}"##, + enabled: has_doc, + run: |s, p| { + let name = p.get("name").and_then(Value::as_str); + let path = resolve_path(s, name)?; + Ok(json!({ "name": name.unwrap_or("work"), "path": path_json(&path) })) + }, + journal: false, + }, + spec!( + "path.set", + "Set Path", + [], + leak(format!( + r##"{{"name":str|"work"="work","path":{{…}},"op":"combine|subtract|intersect|exclude"? (append to the existing path with this op instead of replacing)}}. {PATH_FORM}"## + )), + has_doc, + path_set + ), + spec!("path.delete", "Delete Path", [], r##"{"name":str|"work"="work"}"##, has_doc, path_delete), + spec!( + "path.transform", + "Free Transform Path", + [], + r##"{"name":str|"work"|"layer"="work","layer":id? (with layer target),"matrix":[a,b,c,d,e,f] | "quad":[[x,y]×4] (Distort/Perspective: where the frame's corners go, clockwise from top-left) | "style"|"mesh"|"warp":… (Warp, as edit.transform.warp) | "translateX":px?,"translateY":px?,"scaleX":factor?,"scaleY":factor?,"angle":degrees?,"rect":[x0,y0,x1,y1]? (the quad's or warp's frame; default = path control bounds)} (numeric transforms pivot on path bounds center)"##, + has_doc, + path_transform + ), + spec!( + "path.clippingPath.set", + "Clipping Path", + [], + r##"{"name":savedPathName,"flatness":0..100=0} (PSD export clipping path)"##, + has_doc, + clipping_path_set + ), + spec!("path.clippingPath.clear", "Clear Clipping Path", [], r##"{}"##, has_doc, clipping_path_clear), + spec!("path.style.copyFill", "Copy Fill", [], r##"{} (copies active shape layer fill)"##, has_shape_fill, copy_shape_fill), + spec!("path.style.copyStroke", "Copy Complete Stroke", [], r##"{} (copies active shape layer stroke)"##, has_shape_stroke, copy_shape_stroke), + spec!( + "path.style.pasteFill", + "Paste Fill", + [], + r##"{"layer":id?} (pastes copied fill onto active shape layer)"##, + can_paste_shape_fill, + paste_shape_fill + ), + spec!( + "path.style.pasteStroke", + "Paste Complete Stroke", + [], + r##"{"layer":id?} (pastes copied stroke onto active shape layer)"##, + can_paste_shape_stroke, + paste_shape_stroke + ), + spec!("path.rename", "Rename Path", [], r##"{"name":str|"work"="work","to":str} (renaming the work path saves it)"##, has_doc, path_rename), + // ⌘↩ / Ctrl+Enter, as in Photoshop (the UI loads the path selected in the Paths panel, #306). + CommandSpec { + id: "path.toSelection", + label: "Make Selection from Path", + menu: &[], + shortcut: Some("Cmd+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, + enabled: has_doc, + run: path_to_selection, + journal: true, + }, + // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. + CommandSpec { + id: "path.addToSelection", + label: "Add Path to Selection", + menu: &[], + shortcut: Some("Cmd+Shift+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "add"), + journal: true, + }, + // Ctrl+Alt+Enter: subtract the path from the active selection. + CommandSpec { + id: "path.subtractFromSelection", + label: "Subtract Path from Selection", + menu: &[], + shortcut: Some("Cmd+Alt+Enter"), + params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, + enabled: has_doc, + run: |s, p| path_to_selection_with_mode(s, p, "subtract"), + journal: true, + }, + spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), + spec!( + "path.fill", + "Fill Path", + [], + r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer, default active),"color":"#rrggbb"=foreground,"opacity":0..100=100,"mode":"normal|multiply|…"="normal","feather":px=0,"antiAlias":bool=true}"##, + has_layer, + path_fill + ), + spec!( + "path.stroke", + "Stroke Path", + [], + r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer),"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false (taper both ends when the brush follows pen pressure),"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..100?,"color":"#rrggbb"=foreground} (current brush settings otherwise; tools past the Eraser run their paint.* command along the path and take its params)"##, + has_layer, + path_stroke + ), + spec!( + "layer.vectorMask.add", + "Add Vector Mask", + ["Layer", "Vector Mask"], + r##"{"layer":id?,"path":{…}? | "name":str|"work"? (copy a document path),"hide":bool=false (Hide All = inverted)} → vector mask info. Without a path the mask reveals all."##, + has_layer, + |s, p| vector_mask_add(s, p, false) + ), + spec!( + "layer.vectorMask.fromPath", + "Vector Mask from Current Path", + ["Layer", "Vector Mask"], + r##"{"layer":id?,"name":str|"work"="work"}"##, + has_layer, + |s, p| vector_mask_add(s, p, true) + ), + spec!( + "layer.vectorMask.edit", + "Edit Vector Mask", + [], + leak(format!( + r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. On a shape layer (whose path is its vector mask) only density and feather apply: they soften its fill, which shows beyond the path at 100 − density %. {PATH_FORM}"## + )), + has_layer, + vector_mask_edit + ), + spec!("layer.vectorMask.delete", "Delete Vector Mask", ["Layer", "Vector Mask"], r##"{"layer":id?}"##, has_layer, vector_mask_delete), + spec!( + "layer.rasterize.vectorMask", + "Rasterize Vector Mask", + [], + r##"{"layer":id?} (multiplied into the layer mask)"##, + has_layer, + vector_mask_rasterize + ), + // Menu ids of Photoshop's Layer › Vector Mask / Rasterize / Combine Shapes. + spec!("layer.vectorMask.revealAll", "Vector Mask: Reveal All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( + s, + &with(p, "hide", json!(false)), + false + )), + spec!("layer.vectorMask.hideAll", "Vector Mask: Hide All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( + s, + &with(p, "hide", json!(true)), + false + )), + spec!("layer.vectorMask.currentPath", "Vector Mask: Current Path", [], r##"{"layer":id?,"name":str|"work"="work"}"##, has_layer, |s, p| { + vector_mask_add(s, p, true) + }), + spec!("layer.vectorMask.enabled", "Enable Vector Mask", [], r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_layer, |s, p| { + toggle_vector_mask(s, p, "enabled") + }), + spec!("layer.vectorMask.linked", "Link Vector Mask", [], r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_layer, |s, p| toggle_vector_mask( + s, p, "linked" + )), + // Without `layer`, every selected shape layer (the shared Rasterize logic calls back here with one). + spec!("layer.rasterize.shape", "Rasterize Shape", [], r##"{"layer":id?} (no layer: every selected shape layer)"##, has_layer, |s, p| { + if p.get("layer").is_some() { shape_rasterize(s, p) } else { crate::extra_cmds::rasterize(s, p, Some("shape")) } + }), + // With two or more shape layers selected (and no `layer`/`subpath`), these merge them into + // the top-most one, as in Photoshop; with one, they set the operation of its components. + spec!("layer.combineShapes.unite", "Unite Shapes", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(s, p, "combine", "Unite Shapes")), + spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( + s, + p, + "subtract", + "Subtract Front Shape" + )), + spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( + s, + p, + "intersect", + "Intersect Shape Areas" + )), + spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| { + combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes") + }), + spec!( + "layer.combineShapes.mergeShapeComponents", + "Merge Shape Components", + [], + r##"{"layer":id?,"tolerance":px=0.1} (bakes the path operations into plain combined outlines, traced from the exact coverage; no layer and several shape layers selected: merges them into the top-most first)"##, + has_layer, + merge_components + ), + spec!("select.convertToShape", "Convert to Shape", [], r##"{"tolerance":0.5..10 px=2} → shape.info"##, has_selection, selection_to_shape), + CommandSpec { + id: "layer.vectorMask.info", + label: "Vector Mask Info", + menu: &[], + shortcut: None, + params: r##"{"layer":id?} → {layer,path,enabled,linked,density,feather}"##, + enabled: has_layer, + run: |s, p| { + let id = layer_id(s, p)?; + vector_mask_info(s, id) + }, + journal: false, + }, + ] +} + +#[cfg(test)] +mod tests; From 87d4098cbd256e0b6af0bb55dacbe54860edac07 Mon Sep 17 00:00:00 2001 From: mahefanante Date: Tue, 27 Oct 2026 10:41:07 +0000 Subject: [PATCH 8/9] Fix: apple gpu --- crates/engine/src/vector_cmds.rs | 2 -- crates/ui-egui/src/shortcuts.rs | 5 +---- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 9b65102a57..b7a77e6ed8 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1926,8 +1926,6 @@ pub fn specs() -> Vec { run: path_to_selection, journal: true, }, - - // Ctrl+Shift+Enter: add the path to the active selection. // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. CommandSpec { id: "path.addToSelection", diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 221fdeca83..88ad14b68a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -1040,10 +1040,7 @@ mod tests { fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { ctx.begin_pass(egui::RawInput { - events: vec![ - egui::Event::ModifiersChanged(modifiers), - egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, - ], + events: vec![egui::Event::ModifiersChanged(modifiers), egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }], ..Default::default() }); handle(app, ctx); From c53409b451a2115f377efe96a50d5f1fa00406cc Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 29 Oct 2026 15:31:26 +0100 Subject: [PATCH 9/9] Integration: Ctrl+Shift/Alt+Enter take the path being drawn, as Ctrl+Enter (#8015) The new keys went straight to the engine with the Paths-panel path name injected in PhotocraftApp::run, skipping the route Ctrl+Enter takes (vector_ui::path_to_selection). So a Pen path still being drawn was not finished and used: Pen, three clicks, Ctrl+Shift+Enter combined the old work path (or failed with "no work path") and left the Pen path open, and the loaded path stayed selected. Both ids now go through the same route with the mode forced to add/subtract, and the injection is gone. Also removes crates/ui-egui/src/vector_cmds.rs, an uncompiled byte-for- byte copy of the old engine file, and makes the add test check that both areas end up selected. Co-authored-by: mahefanante <42338306+mahefanante@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/app/commands.rs | 13 - crates/ui-egui/src/input/shortcuts/tests.rs | 26 +- crates/ui-egui/src/menus/invoke.rs | 6 + .../tools/vector_ui/path_selection_tests.rs | 35 + crates/ui-egui/src/vector_cmds.rs | 2071 ----------------- 5 files changed, 53 insertions(+), 2098 deletions(-) delete mode 100644 crates/ui-egui/src/vector_cmds.rs diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index dfea9220f5..24004d7749 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -105,19 +105,6 @@ impl PhotocraftApp { // Long commands become background jobs when enabled (`jobs_ui`); the rest run inline. let params = self.with_mask_target(id, params); let params = vector_ui::with_active_path(self, id, params); - // ⌘⇧↩ / ⌘⌥↩ load the path selected in the Paths panel, as ⌘↩ does, rather than always the work path. - let params = if matches!(id, "path.addToSelection" | "path.subtractFromSelection") && params.get("name").is_none() { - match vector_ui::active_path_name(self) { - Some(name) => { - let mut p = params.as_object().cloned().unwrap_or_default(); - p.insert("name".into(), json!(name)); - Value::Object(p) - } - None => params, - } - } else { - params - }; let path_mask = vector_ui::takes_path_mask(id) && params.get("path").is_some_and(|v| !v.is_null()); let on_active_layer = self .session diff --git a/crates/ui-egui/src/input/shortcuts/tests.rs b/crates/ui-egui/src/input/shortcuts/tests.rs index 84d89198bb..cfb3203f10 100644 --- a/crates/ui-egui/src/input/shortcuts/tests.rs +++ b/crates/ui-egui/src/input/shortcuts/tests.rs @@ -163,7 +163,7 @@ fn pen_custom_undo_binding_and_menu_retract_pending_points() { assert!(app.ui.pen.is_none()); } -/// A document with a work path, and a `press` helper that feeds one key press to `handle`. +/// A 200×200 document for the ⌘⇧↩ / ⌘⌥↩ tests. fn selection_app() -> PhotocraftApp { let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); app.run("file.new", serde_json::json!({"width": 200, "height": 200})).unwrap(); @@ -175,8 +175,10 @@ fn set_work_rect(app: &mut PhotocraftApp, x: i32, y: i32, w: i32, h: i32) { app.run("path.set", serde_json::json!({"path": {"subpaths": [{"closed": true, "op": "combine", "knots": knots}]}})).unwrap(); } -fn selected(app: &PhotocraftApp) -> bool { - app.session.active().unwrap().doc.selection.is_some() +/// The selection's bounds (x0, y0, x1, y1), if any. +fn selection_bounds(app: &PhotocraftApp) -> Option<[i32; 4]> { + let r = app.session.active().unwrap().doc.selection.as_ref()?.content_bounds(); + Some([r.x0, r.y0, r.x1, r.y1]) } fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) { @@ -192,18 +194,14 @@ fn press_key(app: &mut PhotocraftApp, ctx: &egui::Context, key: Key, modifiers: fn ctrl_shift_enter_adds_the_path_to_the_selection() { let mut app = selection_app(); let ctx = egui::Context::default(); - set_work_rect(&mut app, 10, 10, 40, 40); - app.run("path.toSelection", serde_json::json!({})).unwrap(); + app.run("select.rect", serde_json::json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap(); let history = app.session.active().unwrap().history.past_len(); set_work_rect(&mut app, 100, 100, 40, 40); press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::SHIFT); - assert_eq!(app.session.active().unwrap().history.past_len(), history + 2, "path.set + selection"); - assert!(selected(&app)); - - // Removing the second rectangle leaves the first one: the key added, it did not replace. - press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); - assert!(selected(&app)); + assert_eq!(app.session.active().unwrap().history.past_len(), history + 2, "path.set + selection: {}", app.ui.status); + // Both rectangles are selected: the key added the path, it did not replace the selection. + assert_eq!(selection_bounds(&app), Some([10, 10, 140, 140])); } #[test] @@ -212,10 +210,10 @@ fn ctrl_alt_enter_subtracts_the_path_from_the_selection() { let ctx = egui::Context::default(); set_work_rect(&mut app, 10, 10, 40, 40); app.run("path.toSelection", serde_json::json!({})).unwrap(); - assert!(selected(&app)); + assert!(selection_bounds(&app).is_some()); press_key(&mut app, &ctx, Key::Enter, Modifiers::COMMAND | Modifiers::ALT); - assert!(!selected(&app), "the only selected area was subtracted"); + assert_eq!(selection_bounds(&app), None, "the only selected area was subtracted: {}", app.ui.status); } #[test] @@ -247,7 +245,7 @@ fn add_and_subtract_path_keys_are_ignored_while_a_text_field_has_focus() { .clear(); } assert_eq!(app.session.active().unwrap().history.past_len(), history); - assert!(selected(&app)); + assert!(selection_bounds(&app).is_some()); } #[test] diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 850ac92fdd..3bb6d616e4 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -187,6 +187,12 @@ fn invoke_file_commands(app: &mut PhotocraftApp, ctx: &egui::Context, id: &str, // ⌘↩ / Ctrl+Enter (#306): load the path selected in the Paths panel (or the one being // drawn) as a selection. "path.toSelection" if params.get("name").is_none() => crate::vector_ui::path_to_selection(app, params.clone()), + // ⌘⇧↩ / ⌘⌥↩ load the same path, adding it to or subtracting it from the selection. + "path.addToSelection" | "path.subtractFromSelection" if params.get("name").is_none() => { + let mut p = params.as_object().cloned().unwrap_or_default(); + p.insert("mode".into(), json!(if id == "path.addToSelection" { "add" } else { "subtract" })); + crate::vector_ui::path_to_selection(app, Value::Object(p)) + } // Layer › Rename Layer from a menu starts in-place renaming in the Layers panel. "layer.renameLayer" if params.get("name").is_none() => { let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs index 8cab12747a..9f520025e2 100644 --- a/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs +++ b/crates/ui-egui/src/tools/vector_ui/path_selection_tests.rs @@ -495,3 +495,38 @@ fn a_hidden_work_path_stays_hidden_when_another_tile_is_clicked() { assert!(right_outlines_on_switch(false) > 0, "a shown Work Path is drawn in that frame"); assert_eq!(right_outlines_on_switch(true), 0, "the hidden one isn't"); } + +/// ⌘⇧↩ / ⌘⌥↩ (#8015) take the same path as ⌘↩: a path being drawn with the Pen is finished first +/// and combined, not the old work path. +#[test] +fn cmd_shift_enter_while_drawing_finishes_the_path_and_adds_it() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 10.0, 10.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 150, "y": 60, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Pen; + let p = |x: f64, y: f64| [[x, y], [x, y], [x, y]]; + h.state_mut().ui.pen = Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], ..Default::default() }); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert!(h.state().ui.pen.is_none(), "the pen path was finished: {}", h.state().ui.status); + // The new triangle (20..120 × 20..80) joins the selected square; the old work path is not used. + assert_eq!(selection(&h), Some([20, 20, 150, 60])); + assert_eq!(h.state().ui.selected_path, None, "loaded path deselected, as with ⌘↩"); +} + +#[test] +fn cmd_shift_and_alt_enter_use_the_path_selected_in_the_paths_panel() { + let mut h = harness(); + h.state_mut().run("path.set", json!({"name": "work", "path": rect_path(0.0, 0.0, 30.0, 30.0)})).unwrap(); + h.state_mut().run("path.set", json!({"name": "Path 1", "path": rect_path(100.0, 40.0, 60.0, 50.0)})).unwrap(); + h.state_mut().run("select.rect", json!({"x": 40, "y": 40, "width": 20, "height": 20})).unwrap(); + h.state_mut().ui.tool = Tool::Brush; + h.state_mut().ui.selected_path = Some("Path 1".into()); + h.run_steps(2); + press(&mut h, Key::Enter, Modifiers { shift: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 120, 50]), "{}", h.state().ui.status); + // ⌘⌥↩ with the saved path selected again takes it back out, leaving the square. + h.state_mut().ui.selected_path = Some("Path 1".into()); + press(&mut h, Key::Enter, Modifiers { alt: true, ..command() }); + assert_eq!(selection(&h), Some([40, 40, 20, 20]), "{}", h.state().ui.status); +} diff --git a/crates/ui-egui/src/vector_cmds.rs b/crates/ui-egui/src/vector_cmds.rs deleted file mode 100644 index b7a77e6ed8..0000000000 --- a/crates/ui-egui/src/vector_cmds.rs +++ /dev/null @@ -1,2071 +0,0 @@ -//! Vector commands: shape layers (`shape.*`), document paths (`path.*`, Paths panel), selection ⇄ -//! path, and layer vector masks (`layer.vectorMask.*`). -//! -//! Coordinates are document pixels. Paths in parameters and results use a compact JSON form: -//! -//! ```json -//! {"subpaths":[{"closed":true,"op":"combine|subtract|intersect|exclude", -//! "knots":[[x,y], {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":true}, …]}], -//! "fillRule":"nonzero|evenodd", "inverted":false} -//! ``` -//! -//! A knot given as `[x, y]` is a corner with retracted handles; `in`/`out` default to the -//! anchor. The serde form of `photocraft_doc::Path` is accepted too. Colours are `"#rrggbb"`, -//! `"#rrggbbaa"` or `[r,g,b,a]` (0..1); opacities are percentages (0..100). -//! -//! Every edit re-renders the shape layer's cache with `photocraft-vector` in one undoable step. -//! PSD blocks stay byte-identical while they still decode to the model (see `photocraft-io`). - -use photocraft_algo::selection::{self as sel, SelectionMode}; -use photocraft_color::{BlendMode, Color}; -use photocraft_doc::{ - ClippingPath, Document, Fill, FillRule, GradientStyle, Knot, Layer, LayerContent, LayerId, LineCap, LineJoin, LiveShape, NamedPath, Path, PathOp, - ShapeLayer, ShapeStroke, StrokeAlign, Subpath, VectorMask, -}; -use photocraft_geom::{Affine, Point, Rect}; -use photocraft_vector as vector; -use serde_json::{Value, json}; - -use crate::commands::{CommandSpec, blend_from_str}; -use crate::{EngineError, Result, Session}; - -/// Keys PSD uses for a shape layer's vector data; dropped when a shape is rasterized. -const SHAPE_BLOCKS: [&[u8; 4]; 8] = [b"vmsk", b"vsms", b"vogk", b"vstk", b"vscg", b"SoCo", b"GdFl", b"PtFl"]; - -pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { - EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } -} - -pub(crate) fn has_doc(s: &Session) -> std::result::Result<(), String> { - s.active().map(|_| ()).ok_or_else(|| "no document open".into()) -} - -fn has_layer(s: &Session) -> std::result::Result<(), String> { - let d = s.active().ok_or("no document open")?; - d.active_layer.filter(|id| d.doc.layer(*id).is_some()).map(|_| ()).ok_or_else(|| "no active layer".into()) -} - -fn has_selection(s: &Session) -> std::result::Result<(), String> { - s.active().filter(|d| d.doc.selection.is_some()).map(|_| ()).ok_or_else(|| "no selection".into()) -} - -fn active_shape(s: &Session) -> std::result::Result<&ShapeLayer, String> { - let d = s.active().ok_or("no document open")?; - let id = d.active_layer.ok_or("no active layer")?; - let layer = d.doc.layer(id).ok_or("no active layer")?; - match &layer.content { - LayerContent::Shape(shape) => Ok(shape), - _ => Err("active layer is not a shape layer".into()), - } -} - -fn has_shape_fill(s: &Session) -> std::result::Result<(), String> { - active_shape(s)?.fill.as_ref().map(|_| ()).ok_or_else(|| "shape has no fill".into()) -} - -fn has_shape_stroke(s: &Session) -> std::result::Result<(), String> { - active_shape(s)?.stroke.as_ref().map(|_| ()).ok_or_else(|| "shape has no stroke".into()) -} - -fn can_paste_shape_fill(s: &Session) -> std::result::Result<(), String> { - active_shape(s)?; - s.path_fill_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape fill copied".into()) -} - -fn can_paste_shape_stroke(s: &Session) -> std::result::Result<(), String> { - active_shape(s)?; - s.path_stroke_clipboard.as_ref().map(|_| ()).ok_or_else(|| "no shape stroke copied".into()) -} - -fn copy_shape_fill(s: &mut Session, _p: &Value) -> Result { - let fill = active_shape(s).map_err(EngineError::Other)?.fill.clone().ok_or_else(|| EngineError::Other("shape has no fill".into()))?; - s.path_fill_clipboard = Some(fill); - Ok(Value::Null) -} - -fn copy_shape_stroke(s: &mut Session, _p: &Value) -> Result { - let stroke = active_shape(s).map_err(EngineError::Other)?.stroke.clone().ok_or_else(|| EngineError::Other("shape has no stroke".into()))?; - s.path_stroke_clipboard = Some(stroke); - Ok(Value::Null) -} - -fn paste_shape_fill(s: &mut Session, p: &Value) -> Result { - let fill = s.path_fill_clipboard.clone().ok_or_else(|| EngineError::Other("no shape fill copied".into()))?; - let id = layer_id(s, p)?; - with_shape(s, id, "Paste Fill", |shape, _| { - shape.fill = Some(fill); - Ok(()) - })?; - shape_info(s, id) -} - -fn paste_shape_stroke(s: &mut Session, p: &Value) -> Result { - let stroke = s.path_stroke_clipboard.clone().ok_or_else(|| EngineError::Other("no shape stroke copied".into()))?; - let id = layer_id(s, p)?; - with_shape(s, id, "Paste Complete Stroke", |shape, _| { - shape.stroke = Some(stroke); - Ok(()) - })?; - shape_info(s, id) -} - -pub(crate) fn layer_id(s: &Session, p: &Value) -> Result { - match p.get("layer").and_then(Value::as_u64) { - Some(id) => Ok(LayerId(id)), - None => s.active().and_then(|d| d.active_layer).ok_or(EngineError::Other("no active layer".into())), - } -} - -fn f64p(p: &Value, k: &str) -> Option { - p.get(k).and_then(Value::as_f64) -} - -pub(crate) fn pt(v: &Value) -> Option { - match v { - Value::Array(a) if a.len() >= 2 => Some(Point::new(a[0].as_f64()?, a[1].as_f64()?)), - Value::Object(_) => Some(Point::new(v.get("x")?.as_f64()?, v.get("y")?.as_f64()?)), - _ => None, - } -} - -pub(crate) fn nums(p: &Value, k: &str) -> Option<[f64; N]> { - let a = p.get(k)?.as_array()?; - if a.len() < N { - return None; - } - let mut out = [0.0; N]; - for (o, v) in out.iter_mut().zip(a) { - *o = v.as_f64()?; - } - Some(out) -} - -/// `[tl, tr, br, bl]` from an array or a single number. -fn radii(p: &Value) -> Option<[f64; 4]> { - match p.get("radii").or_else(|| p.get("radius"))? { - Value::Number(n) => n.as_f64().map(|r| [r; 4]), - _ => nums::<4>(p, "radii"), - } -} - -pub(crate) fn color(v: &Value) -> Option { - match v { - Value::Array(a) if a.len() >= 3 => { - let c: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0) as f32).collect(); - Some(Color::rgba(c[0], c[1], c[2], c.get(3).copied().unwrap_or(1.0))) - } - Value::String(s) => { - let s = s.trim_start_matches('#'); - let h = |i: usize| s.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|v| f32::from(v) / 255.0); - match s.len() { - 6 => Some(Color::rgb(h(0)?, h(2)?, h(4)?)), - 8 => Some(Color::rgba(h(0)?, h(2)?, h(4)?, h(6)?)), - _ => None, - } - } - // A stored `Color` (mode + components), so a CMYK or Lab gradient stop survives a - // read-modify-write through the shape swatches. - Value::Object(_) => serde_json::from_value::(v.clone()) - .ok() - .filter(|c| c.c.iter().all(|x| x.is_finite()) && c.alpha.is_finite() && (0.0..=1.0).contains(&c.alpha)), - _ => None, - } -} - -fn hex(c: &Color) -> String { - let [r, g, b] = c.to_rgb(); - let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; - if c.alpha < 1.0 { format!("#{:02x}{:02x}{:02x}{:02x}", q(r), q(g), q(b), q(c.alpha)) } else { format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b)) } -} - -fn op_from(s: &str) -> Option { - Some(match s.to_ascii_lowercase().as_str() { - "combine" | "add" | "union" => PathOp::Combine, - "subtract" | "minus" => PathOp::Subtract, - "intersect" => PathOp::Intersect, - "exclude" | "xor" => PathOp::Exclude, - "join" => PathOp::Join, - _ => return None, - }) -} - -fn op_name(op: PathOp) -> &'static str { - match op { - PathOp::Combine => "combine", - PathOp::Subtract => "subtract", - PathOp::Intersect => "intersect", - PathOp::Exclude => "exclude", - PathOp::Join => "join", - } -} - -/// Parses a path from the compact JSON form (or the serde form). -pub fn parse_path(v: &Value) -> std::result::Result { - let subs = v.get("subpaths").and_then(Value::as_array).ok_or("path needs `subpaths`")?; - let mut out = Vec::with_capacity(subs.len()); - for (i, s) in subs.iter().enumerate() { - let knots_v = s.get("knots").and_then(Value::as_array).ok_or(format!("subpath {i} needs `knots`"))?; - let mut knots = Vec::with_capacity(knots_v.len()); - for (j, k) in knots_v.iter().enumerate() { - let knot = if k.is_array() { - let a = pt(k).ok_or(format!("knot {i}.{j}: expected [x, y]"))?; - Knot::corner(a.x, a.y) - } else { - let a = k.get("anchor").and_then(pt).ok_or(format!("knot {i}.{j}: missing anchor"))?; - let get = |keys: &[&str]| keys.iter().find_map(|key| k.get(*key).and_then(pt)); - Knot { - anchor: a, - in_ctrl: get(&["in", "inCtrl", "in_ctrl"]).unwrap_or(a), - out_ctrl: get(&["out", "outCtrl", "out_ctrl"]).unwrap_or(a), - smooth: k.get("smooth").and_then(Value::as_bool).unwrap_or(false), - } - }; - knots.push(knot); - } - let op = match s.get("op").and_then(Value::as_str) { - Some(o) => op_from(o).ok_or(format!("subpath {i}: unknown op `{o}`"))?, - None => PathOp::Combine, - }; - out.push(Subpath { closed: s.get("closed").and_then(Value::as_bool).unwrap_or(true), knots, op }); - } - let fill_rule = match v.get("fillRule").or_else(|| v.get("fill_rule")).and_then(Value::as_str).map(str::to_ascii_lowercase).as_deref() { - Some("evenodd" | "even-odd" | "even_odd") => FillRule::EvenOdd, - _ => FillRule::NonZero, - }; - Ok(Path { subpaths: out, fill_rule, inverted: v.get("inverted").and_then(Value::as_bool).unwrap_or(false) }) -} - -/// The compact JSON form of a path. -pub fn path_json(p: &Path) -> Value { - let a = |q: Point| json!([q.x, q.y]); - json!({ - "subpaths": p.subpaths.iter().map(|s| json!({ - "closed": s.closed, - "op": op_name(s.op), - "knots": s.knots.iter().map(|k| json!({ "anchor": a(k.anchor), "in": a(k.in_ctrl), "out": a(k.out_ctrl), "smooth": k.smooth })).collect::>(), - })).collect::>(), - "fillRule": if p.fill_rule == FillRule::EvenOdd { "evenodd" } else { "nonzero" }, - "inverted": p.inverted, - }) -} - -fn fill_json(f: &Fill) -> Value { - match f { - Fill::Solid(c) => json!(hex(c)), - Fill::Gradient { stops, angle, scale, style, reverse, opacity_stops, midpoints, offset, dither, align } => json!({ "gradient": { - "stops": stops.iter().map(|(t, c)| json!([t, hex(c)])).collect::>(), - "angle": angle, "scale": scale * 100.0, "style": format!("{style:?}").to_ascii_lowercase(), "reverse": reverse, - "opacityStops": opacity_stops, "midpoints": midpoints, "offset": [offset.0, offset.1], "dither": dither, "align": align } }), - Fill::Pattern { name, scale, .. } => json!({ "pattern": name, "scale": scale * 100.0 }), - } -} - -/// The most colour stops, opacity stops or midpoints a gradient fill takes, as the gradient stop -/// edits (`gradient.fill.stop`, `gradient.presets.stop`) cap them. -const MAX_GRADIENT_STOPS: usize = 1024; - -/// `"#rrggbb"` / `[r,g,b,a]` / `{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]],…],"angle","scale","style","reverse", -/// "opacityStops":[[t,0..1],…],"midpoints":[0..1,…],"offset":[x,y],"dither","align"}}` / `{"pattern":name,"scale"}`. -/// `null` → `Ok(None)` (no fill). -fn parse_fill(v: &Value) -> std::result::Result, String> { - if v.is_null() { - return Ok(None); - } - if let Some(c) = color(v) { - return Ok(Some(Fill::Solid(c))); - } - if let Some(g) = v.get("gradient") { - // Stop lists are capped as `gradient.fill.stop` caps them: more is an error, not a cut. - let list = |key: &str, what: &str| match g.get(key) { - None | Some(Value::Null) => Ok(None), - Some(Value::Array(a)) if a.len() > MAX_GRADIENT_STOPS => Err(format!("too many {what} (max {MAX_GRADIENT_STOPS})")), - Some(Value::Array(a)) => Ok(Some(a)), - Some(_) => Err(format!("gradient `{key}` must be an array")), - }; - let stops: Vec<(f32, Color)> = list("stops", "colour stops")? - .map(|a| a.iter().filter_map(|s| Some((s.get(0)?.as_f64()? as f32, color(s.get(1)?)?))).collect()) - .unwrap_or_else(|| vec![(0.0, Color::BLACK), (1.0, Color::WHITE)]); - let style = match g.get("style").and_then(Value::as_str).unwrap_or("linear") { - "radial" => GradientStyle::Radial, - "angle" => GradientStyle::Angle, - "reflected" => GradientStyle::Reflected, - "diamond" => GradientStyle::Diamond, - _ => GradientStyle::Linear, - }; - let mut fill = Fill::gradient( - stops, - f64p(g, "angle").unwrap_or(90.0) as f32, - f64p(g, "scale").unwrap_or(100.0) as f32 / 100.0, - style, - g.get("reverse").and_then(Value::as_bool).unwrap_or(false), - ); - // The rest of a gradient, so a read-modify-write (the shape swatches) loses nothing. - if let Fill::Gradient { opacity_stops, midpoints, offset, dither, align, .. } = &mut fill { - let unit = |v: &Value| v.as_f64().map(|n| n.clamp(0.0, 1.0) as f32); - if let Some(a) = list("opacityStops", "opacity stops")? { - *opacity_stops = a.iter().filter_map(|s| Some((unit(s.get(0)?)?, unit(s.get(1)?)?))).collect(); - opacity_stops.sort_by(|a, b| a.0.total_cmp(&b.0)); - } - if let Some(a) = list("midpoints", "midpoints")? { - *midpoints = a.iter().filter_map(unit).collect(); - } - if let Some(o) = g.get("offset") { - let at = |i: usize| o.get(i).and_then(Value::as_f64).map(|n| n.clamp(-1000.0, 1000.0) as f32); - *offset = (at(0).ok_or("gradient offset must be [x, y]")?, at(1).ok_or("gradient offset must be [x, y]")?); - } - if let Some(d) = g.get("dither").and_then(Value::as_bool) { - *dither = d; - } - if let Some(a) = g.get("align").and_then(Value::as_bool) { - *align = a; - } - } - return Ok(Some(fill)); - } - if let Some(name) = v.get("pattern").and_then(Value::as_str) { - return Ok(Some(Fill::Pattern { - name: name.into(), - scale: f64p(v, "scale").unwrap_or(100.0) as f32 / 100.0, - id: String::new(), - angle: 0.0, - link: true, - phase: (0.0, 0.0), - })); - } - Err(format!("unrecognised fill {v}")) -} - -fn stroke_json(s: &ShapeStroke) -> Value { - json!({ - "width": s.width, "fill": fill_json(&s.paint), "opacity": s.opacity * 100.0, - "align": format!("{:?}", s.align).to_ascii_lowercase(), "cap": format!("{:?}", s.cap).to_ascii_lowercase(), - "join": format!("{:?}", s.join).to_ascii_lowercase(), "miterLimit": s.miter_limit, "dashes": s.dashes, "dashOffset": s.dash_offset, - }) -} - -/// Applies stroke keys onto `base` (`null` → no stroke). -pub fn parse_stroke(v: &Value, base: Option, fg: Color) -> std::result::Result, String> { - if v.is_null() || v.as_bool() == Some(false) { - return Ok(None); - } - if !v.is_object() { - return Err("stroke must be an object or null".into()); - } - for (key, lo, hi) in [("width", 0.0, 1_000_000.0), ("opacity", 0.0, 100.0), ("miterLimit", 1.0, 500.0), ("dashOffset", -1_000_000.0, 1_000_000.0)] { - if let Some(value) = v.get(key) - && !value.as_f64().is_some_and(|n| n.is_finite() && (lo..=hi).contains(&n)) - { - return Err(format!("`{key}` must be a finite number between {lo} and {hi}")); - } - } - for key in ["align", "cap", "join"] { - if v.get(key).is_some_and(|value| !value.is_string()) { - return Err(format!("`{key}` must be a string")); - } - } - let mut s = base.unwrap_or(ShapeStroke { paint: Fill::Solid(fg), ..Default::default() }); - if let Some(w) = f64p(v, "width") { - s.width = w as f32; - } - if let Some(c) = v.get("color").or_else(|| v.get("fill")) { - s.paint = parse_fill(c)?.ok_or("stroke colour cannot be null")?; - } - if let Some(o) = f64p(v, "opacity") { - s.opacity = (o / 100.0) as f32; - } - if let Some(a) = v.get("align").and_then(Value::as_str) { - s.align = match a { - "inside" => StrokeAlign::Inside, - "outside" => StrokeAlign::Outside, - "center" | "centre" => StrokeAlign::Center, - o => return Err(format!("unknown align `{o}`")), - }; - } - if let Some(c) = v.get("cap").and_then(Value::as_str) { - s.cap = match c { - "butt" => LineCap::Butt, - "round" => LineCap::Round, - "square" => LineCap::Square, - o => return Err(format!("unknown cap `{o}`")), - }; - } - if let Some(j) = v.get("join").and_then(Value::as_str) { - s.join = match j { - "miter" => LineJoin::Miter, - "round" => LineJoin::Round, - "bevel" => LineJoin::Bevel, - o => return Err(format!("unknown join `{o}`")), - }; - } - if let Some(m) = f64p(v, "miterLimit") { - s.miter_limit = m as f32; - } - if let Some(d) = v.get("dashes") { - s.dashes = match d { - Value::Null => Vec::new(), - Value::Array(a) if a.len() <= 32 => a - .iter() - .map(|x| { - x.as_f64() - .filter(|n| n.is_finite() && (0.0..=10000.0).contains(n)) - .map(|x| x as f32) - .ok_or_else(|| "dash lengths must be finite numbers between 0 and 10000 widths".to_string()) - }) - .collect::, _>>()?, - _ => return Err("`dashes` must be an array (multiples of the width)".into()), - }; - } - if let Some(o) = f64p(v, "dashOffset") { - s.dash_offset = o as f32; - } - s.validate()?; - Ok(Some(s)) -} - -fn live_json(l: &LiveShape) -> Value { - serde_json::to_value(l).unwrap_or(Value::Null) -} - -/// Builds a live shape from `kind` + geometry params. -fn live_from_params(cmd: &str, kind: &str, p: &Value, prev: Option<&LiveShape>) -> Result { - let prev_rect = match prev { - Some(LiveShape::Rect { rect, .. } | LiveShape::Ellipse { rect } | LiveShape::Polygon { rect, .. }) => Some(*rect), - _ => None, - }; - let rect = || nums::<4>(p, "rect").or(prev_rect).ok_or_else(|| bad(cmd, "`rect`: [x, y, w, h] is required")); - Ok(match kind { - "rect" | "rectangle" => { - let r = match prev { - Some(LiveShape::Rect { radii, .. }) => *radii, - _ => [0.0; 4], - }; - LiveShape::Rect { rect: rect()?, radii: radii(p).unwrap_or(r) } - } - "roundedRect" | "roundRect" => { - let r = match prev { - Some(LiveShape::Rect { radii, .. }) if radii.iter().any(|v| *v > 0.0) => *radii, - _ => [10.0; 4], - }; - LiveShape::Rect { rect: rect()?, radii: radii(p).unwrap_or(r) } - } - "ellipse" => LiveShape::Ellipse { rect: rect()? }, - "polygon" | "star" => { - let (ps, pr, pc) = match prev { - Some(LiveShape::Polygon { sides, star_ratio, corner_radius, .. }) => (*sides, *star_ratio, *corner_radius), - _ => (5, if kind == "star" { 0.5 } else { 1.0 }, 0.0), - }; - let corner_radius = f64p(p, "cornerRadius").map_or(pc, |v| if v.is_finite() { v.clamp(0.0, 1.0e6) } else { pc }); - let sides = f64p(p, "sides").map_or(ps, |v| v.round().clamp(3.0, 100.0) as u32); - let ratio = f64p(p, "starRatio").map_or(if kind == "star" && pr >= 1.0 { 0.5 } else { pr }, |v| v.clamp(0.01, 1.0)); - LiveShape::Polygon { - rect: rect()?, - sides, - star_ratio: if kind == "polygon" && f64p(p, "starRatio").is_none() { 1.0 } else { ratio }, - corner_radius, - } - } - "line" => { - let (pf, pto, pw) = match prev { - Some(LiveShape::Line { from, to, weight }) => (Some(*from), Some(*to), *weight), - _ => (None, None, 1.0), - }; - LiveShape::Line { - from: nums::<2>(p, "from").or(pf).ok_or_else(|| bad(cmd, "`from`: [x, y] is required"))?, - to: nums::<2>(p, "to").or(pto).ok_or_else(|| bad(cmd, "`to`: [x, y] is required"))?, - weight: f64p(p, "weight").unwrap_or(pw).max(0.0), - } - } - o => return Err(bad(cmd, format!("unknown kind `{o}`"))), - }) -} - -/// The path `shape.create` draws for live-shape params `p` (the Shape tools preview it while dragging). -pub fn shape_path(p: &Value) -> Result { - let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); - Ok(vector::shapes::live_path(&live_from_params("shape.create", kind, p, None)?)) -} - -fn live_kind(l: &LiveShape) -> &'static str { - match l { - LiveShape::Rect { radii, .. } if radii.iter().any(|r| *r > 0.0) => "roundedRect", - LiveShape::Rect { .. } => "rect", - LiveShape::Ellipse { .. } => "ellipse", - LiveShape::Polygon { star_ratio, .. } if *star_ratio < 1.0 => "star", - LiveShape::Polygon { .. } => "polygon", - LiveShape::Line { .. } => "line", - } -} - -/// Re-renders a shape layer's cache for `doc` (canvas-clipped, document pixel format). -pub fn refresh_shape(doc: &Document, sh: &mut ShapeLayer) { - sh.cache = Some(vector::render_shape(sh, doc.pixel_format(), doc.bounds())); -} - -/// A staged stroke edit rendered with the same validation and cache refresh as `shape.edit`. -/// The caller owns this snapshot; the session and its history remain untouched. -pub fn preview_shape_stroke(doc: &Document, id: LayerId, params: &Value) -> Result { - let mut result = doc.clone(); - let layer = result.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let LayerContent::Shape(shape) = &mut layer.content else { - return Err(EngineError::Other("preview target is not a shape layer".into())); - }; - shape.stroke = parse_stroke(params, shape.stroke.clone(), Color::BLACK).map_err(|e| bad("shape.edit", e))?; - vector::flatten::validate_shape(&shape.path, shape.stroke.as_ref()).map_err(|e| bad("shape.edit", e))?; - refresh_shape(doc, shape); - Ok(result) -} - -/// Moves a layer's vector content by `(dx, dy)`: shape paths (re-rendered) and linked vector -/// masks, recursing into groups. For the Move tool / `layer.move`. -pub fn translate_vectors(doc: &Document, l: &mut Layer, dx: f64, dy: f64) { - let a = Affine::translate(dx, dy); - if let Some(vm) = l.vector_mask.as_mut() - && vm.linked - { - vm.path = vm.path.transform(&a); - } - match &mut l.content { - LayerContent::Shape(sh) => shift_shape(doc, sh, dx, dy), - LayerContent::Group(g) => { - for c in &mut g.children { - translate_vectors(doc, c, dx, dy); - } - } - _ => {} - } -} - -/// Moves a shape by `(dx, dy)`. A whole-pixel move shifts the rendered pixels as they are, so a -/// shape from a PSD keeps Photoshop's anti-aliasing (as moved type does). The render is cut at -/// the canvas, so a shape reaching past it renders again, as does a fractional move. -pub(crate) fn shift_shape(doc: &Document, sh: &mut ShapeLayer, dx: f64, dy: f64) { - transform_shape(sh, &Affine::translate(dx, dy)); - let whole = |v: f64| (v.fract() == 0.0 && v.abs() <= f64::from(i32::MAX)).then_some(v as i32); - let canvas = doc.bounds(); - let inside = sh.cache.as_ref().is_some_and(|c| { - let b = photocraft_compose::bounds::content_bounds(c); - // An empty cache can be an off-canvas shape moving back into view. - !b.is_empty() && b.x0 > canvas.x0 && b.y0 > canvas.y0 && b.x1 < canvas.x1 && b.y1 < canvas.y1 - }); - let stroke_pad = sh.stroke.as_ref().filter(|s| s.opacity > 0.0).map_or(0.0, |s| f64::from(s.width.ceil())); - let stays_inside = sh.path.control_bounds().is_some_and(|(x0, y0, x1, y1)| { - (x0 - stroke_pad) > canvas.x0 as f64 - && (y0 - stroke_pad) > canvas.y0 as f64 - && (x1 + stroke_pad) < canvas.x1 as f64 - && (y1 + stroke_pad) < canvas.y1 as f64 - }); - match (&mut sh.cache, whole(dx), whole(dy)) { - (Some(c), Some(dx), Some(dy)) if inside && stays_inside => *c = crate::layer_multi_cmds::shift_surface(c, dx, dy), - _ => refresh_shape(doc, sh), - } -} - -/// Applies an affine transform to a shape's path, keeping the live shape when the transform -/// is a translation plus axis-aligned scale (otherwise the shape stops being parametric). -pub fn transform_shape(sh: &mut ShapeLayer, a: &Affine) { - sh.path = sh.path.transform(a); - let [m0, m1, m2, m3, tx, ty] = a.m; - let axis_aligned = m1.abs() < 1e-12 && m2.abs() < 1e-12 && m0 > 0.0 && m3 > 0.0; - sh.live = match (sh.live.take(), axis_aligned) { - (Some(l), true) => { - let r = |r: [f64; 4]| [r[0] * m0 + tx, r[1] * m3 + ty, r[2] * m0, r[3] * m3]; - let pt = |p: [f64; 2]| [p[0] * m0 + tx, p[1] * m3 + ty]; - Some(match l { - LiveShape::Rect { rect, radii } => { - let k = m0.min(m3); - LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) } - } - LiveShape::Ellipse { rect } => LiveShape::Ellipse { rect: r(rect) }, - LiveShape::Polygon { rect, sides, star_ratio, corner_radius } => { - LiveShape::Polygon { rect: r(rect), sides, star_ratio, corner_radius: corner_radius * m0.min(m3) } - } - LiveShape::Line { from, to, weight } => { - // Live-shape edits regenerate the line's filled bar from its parameters. - // Keep the weight in sync with a positive scale of the path, or an - // unchanged endpoint edit unexpectedly restores the old thickness (#3063). - // A line's width scales perpendicular to its direction. - let (dx, dy) = (to[0] - from[0], to[1] - from[1]); - let old_length = dx.hypot(dy); - let new_length = (dx * m0).hypot(dy * m3); - let width_scale = if old_length > 0.0 && new_length > 0.0 { m0 * m3 * old_length / new_length } else { m0.min(m3) }; - LiveShape::Line { from: pt(from), to: pt(to), weight: weight * width_scale } - } - }) - } - // Flips and 90° turns keep rectangles, ellipses and lines live (Image Rotation): the new - // live box is the mapped box. Polygons/stars would change orientation, so they don't. - (Some(l), false) if (m1.abs() < 1e-12 && m2.abs() < 1e-12) || (m0.abs() < 1e-12 && m3.abs() < 1e-12) => { - let pt = |p: [f64; 2]| [m0 * p[0] + m2 * p[1] + tx, m1 * p[0] + m3 * p[1] + ty]; - let r = |r: [f64; 4]| { - let (p, q) = (pt([r[0], r[1]]), pt([r[0] + r[2], r[1] + r[3]])); - [p[0].min(q[0]), p[1].min(q[1]), (q[0] - p[0]).abs(), (q[1] - p[1]).abs()] - }; - let k = (m0.abs() + m1.abs()).min(m2.abs() + m3.abs()); - match l { - LiveShape::Rect { rect, radii } if radii.iter().all(|v| (v - radii[0]).abs() < 1e-9) => { - Some(LiveShape::Rect { rect: r(rect), radii: radii.map(|v| v * k) }) - } - LiveShape::Ellipse { rect } => Some(LiveShape::Ellipse { rect: r(rect) }), - LiveShape::Line { from, to, weight } => Some(LiveShape::Line { from: pt(from), to: pt(to), weight: weight * k }), - _ => None, - } - } - _ => None, - }; - sh.psd_raw = None; -} - -/// Is every anchor and control point of `path` a finite point? -fn finite_path(path: &Path) -> bool { - path.subpaths.iter().flat_map(|s| &s.knots).all(|k| [k.anchor, k.in_ctrl, k.out_ctrl].iter().all(|p| p.x.is_finite() && p.y.is_finite())) -} - -/// `path` through Distort / Perspective (`h`, a projective map of its points: exact for straight -/// segments, the end tangents of curves exact) or, with `warp`, Warp (split where the warp bends -/// it). Refused when the map sends a point to infinity (past a perspective's horizon). -pub(crate) fn map_path(path: &Path, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result { - let out = match (h, warp) { - (_, Some(w)) => vector::map::map_path( - path, - |p| { - let (x, y) = w.map(p.x, p.y); - Point::new(x, y) - }, - 0.1, - ), - (Some(h), None) => vector::map::map_points(path, |p| { - let (x, y) = h.apply(p.x, p.y); - Point::new(x, y) - }), - (None, None) => path.clone(), - }; - if !finite_path(&out) { - return Err(EngineError::Other("the distortion sends part of the path out of bounds".into())); - } - Ok(out) -} - -/// Distort, Perspective or Warp of a shape layer (#3009): the shape stays a shape, its path -/// mapped ([`map_path`]), as in Photoshop. A live rectangle, ellipse, polygon or line becomes a -/// plain path, as its parameters can't describe the distorted outline. -pub(crate) fn map_shape(sh: &mut ShapeLayer, h: Option<&photocraft_algo::transform::Homography>, warp: Option<&photocraft_geom::warp::Warp>) -> Result<()> { - sh.path = map_path(&sh.path, h, warp)?; - sh.live = None; - sh.psd_raw = None; - Ok(()) -} - -fn shape_info(s: &Session, id: LayerId) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - let LayerContent::Shape(sh) = &l.content else { - return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name()))); - }; - let bounds = sh.cache.as_ref().map(|c| c.content_bounds()).filter(|r| !r.is_empty()).map(|r| json!([r.x0, r.y0, r.width(), r.height()])); - Ok(json!({ - "layer": id.0, - "name": l.name, - "path": path_json(&sh.path), - "fill": sh.fill.as_ref().map(fill_json), - "stroke": sh.stroke.as_ref().map(stroke_json), - "live": sh.live.as_ref().map(live_json), - "kind": sh.live.as_ref().map_or("path", live_kind), - "bounds": bounds, - })) -} - -pub(crate) fn with_shape(s: &mut Session, id: LayerId, label: &str, f: impl FnOnce(&mut ShapeLayer, &mut Layer) -> Result) -> Result { - if let Some(st) = s.active() { - let locks = st.doc.effective_locks(id); - if locks.all || locks.pixels { - return Err(EngineError::Other("shape layer is locked".into())); - } - } - s.edit(label, |doc, _| { - let snapshot = doc.clone(); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if !matches!(l.content, LayerContent::Shape(_)) { - return Err(EngineError::Other(format!("layer {} is {} {} layer, not a shape layer", id.0, l.content.article(), l.content.kind_name()))); - } - let LayerContent::Shape(mut sh) = std::mem::replace(&mut l.content, LayerContent::Fill(Fill::Solid(Color::BLACK))) else { - return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); - }; - let r = f(&mut sh, l); - if r.is_ok() { - refresh_shape(&snapshot, &mut sh); - } - l.content = LayerContent::Shape(sh); - r - }) -} - -fn default_name(kind: &str) -> &'static str { - match kind { - "rect" | "rectangle" => "Rectangle", - "roundedRect" | "roundRect" => "Rounded Rectangle", - "ellipse" => "Ellipse", - "polygon" => "Polygon", - "star" => "Star", - "line" => "Line", - _ => "Shape", - } -} - -fn shape_create(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "shape.create"; - let kind = p.get("kind").and_then(Value::as_str).unwrap_or("rect"); - let (path, live) = if kind == "path" { - let v = p.get("path").ok_or_else(|| bad(CMD, "kind \"path\" needs `path`"))?; - (parse_path(v).map_err(|e| bad(CMD, e))?, None) - } else { - let l = live_from_params(CMD, kind, p, None)?; - (vector::shapes::live_path(&l), Some(l)) - }; - let fg = s.tools.foreground; - let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - let fill = match p.get("fill") { - Some(v) => parse_fill(v).map_err(|e| bad(CMD, e))?, - None => Some(Fill::Solid(fgc)), - }; - let stroke = match p.get("stroke") { - Some(v) => parse_stroke(v, None, fgc).map_err(|e| bad(CMD, e))?, - None => None, - }; - vector::flatten::validate_shape(&path, stroke.as_ref()).map_err(|e| bad(CMD, e))?; - // Add to an existing shape layer with a path operation (Shape tool in combine/subtract… mode). - if let Some(target) = p.get("addTo").and_then(Value::as_u64) { - let op = p.get("op").and_then(Value::as_str).map_or(Some(PathOp::Combine), op_from).ok_or_else(|| bad(CMD, "unknown `op`"))?; - let id = LayerId(target); - with_shape(s, id, "Add Shape", |sh, _| { - for mut sp in path.subpaths { - sp.op = op; - sh.path.subpaths.push(sp); - } - sh.live = None; - sh.psd_raw = None; - vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?; - Ok(()) - })?; - return shape_info(s, id); - } - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let mut sh = ShapeLayer { path, fill, stroke, live, ..Default::default() }; - let id = s.edit("New Shape Layer", |doc, active| { - refresh_shape(doc, &mut sh); - let name = name.unwrap_or_else(|| doc.next_layer_name(default_name(kind))); - let id = doc.insert_above(*active, Layer::new(name, LayerContent::Shape(sh))); - *active = Some(id); - Ok(id) - })?; - shape_info(s, id) -} - -fn shape_edit(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "shape.edit"; - let id = layer_id(s, p)?; - let fg = s.tools.foreground; - let fgc = Color::rgba(fg[0], fg[1], fg[2], fg[3]); - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - with_shape(s, id, "Edit Shape", |sh, layer| { - if let Some(v) = p.get("path") { - sh.path = parse_path(v).map_err(|e| bad(CMD, e))?; - sh.live = None; - sh.psd_raw = None; - } - let geom_keys = ["kind", "rect", "radii", "radius", "sides", "starRatio", "cornerRadius", "from", "to", "weight"]; - if geom_keys.iter().any(|k| p.get(*k).is_some()) { - let kind = p.get("kind").and_then(Value::as_str).map(str::to_string).or_else(|| sh.live.as_ref().map(|l| live_kind(l).to_string())); - let kind = kind.ok_or_else(|| bad(CMD, "this shape has no live-shape parameters; pass `kind` or `path`"))?; - // Explicit radii on a plain rectangle keep it a (now rounded) rectangle. - let kind = if kind == "rect" && radii(p).is_some_and(|r| r.iter().any(|v| *v > 0.0)) { "roundedRect".to_string() } else { kind }; - let l = live_from_params(CMD, &kind, p, sh.live.as_ref())?; - // A transformed live shape's path may carry nonuniform corner scaling - // that its scalar radii cannot represent. Re-entering unchanged values - // must not regenerate the outline and silently discard that geometry. - if sh.live.as_ref() != Some(&l) { - sh.path = vector::shapes::live_path(&l); - sh.live = Some(l); - sh.psd_raw = None; - } - } - if let Some(m) = nums::<6>(p, "transform") { - transform_shape(sh, &Affine { m }); - } - if let Some([dx, dy]) = nums::<2>(p, "move") { - transform_shape(sh, &Affine::translate(dx, dy)); - } - if let Some(op) = p.get("op").and_then(Value::as_str) { - // Operation of the selected subpath(s) (`subpath`: index, default all but the first). - let op = op_from(op).ok_or_else(|| bad(CMD, format!("unknown op `{op}`")))?; - match p.get("subpath").and_then(Value::as_u64) { - Some(i) => sh.path.subpaths.get_mut(i as usize).ok_or_else(|| bad(CMD, "no such subpath"))?.op = op, - None => sh.path.subpaths.iter_mut().skip(1).for_each(|sp| sp.op = op), - } - sh.live = None; - } - if let Some(r) = p.get("fillRule").and_then(Value::as_str) { - sh.path.fill_rule = if r.eq_ignore_ascii_case("evenodd") { FillRule::EvenOdd } else { FillRule::NonZero }; - } - if let Some(v) = p.get("fill") { - sh.fill = parse_fill(v).map_err(|e| bad(CMD, e))?; - } - if let Some(v) = p.get("stroke") { - sh.stroke = parse_stroke(v, sh.stroke.clone(), fgc).map_err(|e| bad(CMD, e))?; - } - if let Some(n) = name { - layer.name = n; - } - vector::flatten::validate_shape(&sh.path, sh.stroke.as_ref()).map_err(|e| bad(CMD, e))?; - Ok(()) - })?; - shape_info(s, id) -} - -fn shape_rasterize(s: &mut Session, p: &Value) -> Result { - let id = layer_id(s, p)?; - s.edit("Rasterize Shape", |doc, _| { - let snapshot = doc.clone(); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let LayerContent::Shape(sh) = &mut l.content else { - return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); - }; - if sh.cache.is_none() { - refresh_shape(&snapshot, sh); - } - let fmt = snapshot.pixel_format(); - let surface = sh.cache.take().unwrap_or_else(|| photocraft_raster::Surface::new(fmt)); - let surface = if surface.format() == fmt { surface } else { surface.convert(fmt) }; - l.content = LayerContent::Raster(surface); - l.psd_blocks.retain(|(k, _)| !SHAPE_BLOCKS.contains(&k)); - Ok(()) - })?; - Ok(json!({ "layer": id.0 })) -} - -// --------------------------------------------------------------------------- -// Document paths -// --------------------------------------------------------------------------- - -pub(crate) fn is_work(name: Option<&str>) -> bool { - name.is_none_or(|n| n.is_empty() || n.eq_ignore_ascii_case("work") || n == "Work Path") -} - -/// The path a command targets: `"work"`/absent → the work path (falling back to the active -/// layer's shape path or vector mask when there is none), `"layer"` → the active layer's, -/// otherwise a saved path by name. -fn resolve_path(s: &Session, name: Option<&str>) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let layer_path = || -> Option { - let l = d.doc.layer(d.active_layer?)?; - match &l.content { - LayerContent::Shape(sh) => Some(sh.path.clone()), - _ => l.vector_mask.as_ref().map(|v| v.path.clone()), - } - }; - if name == Some("layer") { - return layer_path().ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into())); - } - if is_work(name) { - return d.doc.work_path.clone().or_else(layer_path).ok_or_else(|| EngineError::Other("no work path".into())); - } - let n = name.unwrap_or_default(); - d.doc.paths.iter().find(|p| p.name == n).map(|p| p.path.clone()).ok_or_else(|| EngineError::Other(format!("no path named \"{n}\""))) -} - -/// The path an edit targets, except a shape layer's (edit those with [`with_shape`], which -/// re-renders them): `layer`'s vector mask when given, else the work path or a saved path. -pub(crate) fn path_mut<'a>(doc: &'a mut Document, name: &str, layer: Option) -> Result<&'a mut Path> { - if let Some(id) = layer { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - return l.vector_mask.as_mut().map(|vm| &mut vm.path).ok_or_else(|| EngineError::Other("active layer has no shape path or vector mask".into())); - } - if is_work(Some(name)) { - return doc.work_path.as_mut().ok_or_else(|| EngineError::Other("no work path".into())); - } - doc.paths.iter_mut().find(|q| q.name == name).map(|q| &mut q.path).ok_or_else(|| EngineError::Other(format!("no path named \"{name}\""))) -} - -fn paths_list(s: &Session) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let doc = &d.doc; - let knots = |p: &Path| p.subpaths.iter().map(|s| s.knots.len()).sum::(); - let layer = d.active_layer.and_then(|id| doc.layer(id)).and_then(|l| match &l.content { - LayerContent::Shape(sh) => Some(json!({ "kind": "shape", "layer": l.id.0, "name": format!("{} Shape Path", l.name), "knots": knots(&sh.path) })), - _ => { - l.vector_mask.as_ref().map(|v| json!({ "kind": "vectorMask", "layer": l.id.0, "name": format!("{} Vector Mask", l.name), "knots": knots(&v.path) })) - } - }); - Ok(json!({ - "paths": doc.paths.iter().map(|p| json!({ "name": p.name, "knots": knots(&p.path), "subpaths": p.path.subpaths.len() })).collect::>(), - "workPath": doc.work_path.as_ref().map(|p| json!({ "knots": knots(p), "subpaths": p.subpaths.len() })), - "clippingPath": doc.clipping_path.as_ref().map(|c| json!({ "name": c.name, "flatness": c.flatness })), - "layerPath": layer, - })) -} - -fn path_set(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "path.set"; - let path = parse_path(p.get("path").ok_or_else(|| bad(CMD, "missing `path`"))?).map_err(|e| bad(CMD, e))?; - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let append = p.get("op").and_then(Value::as_str).map(|o| op_from(o).ok_or_else(|| bad(CMD, format!("unknown op `{o}`")))).transpose()?; - s.edit(if is_work(name.as_deref()) { "Work Path" } else { "Save Path" }, |doc, _| { - let merge = |old: Option<&Path>, new: Path| match (old, append) { - (Some(o), Some(op)) => { - let mut o = o.clone(); - o.subpaths.extend(new.subpaths.into_iter().map(|mut sp| { - sp.op = op; - sp - })); - o - } - _ => new, - }; - if is_work(name.as_deref()) { - doc.work_path = Some(merge(doc.work_path.as_ref(), path)); - } else { - let n = name.clone().unwrap_or_default(); - match doc.paths.iter_mut().find(|q| q.name == n) { - Some(q) => { - q.path = merge(Some(&q.path), path); - } - None => doc.paths.push(NamedPath { name: n, path, psd_raw: None }), - } - } - Ok(()) - })?; - let path = resolve_path(s, name.as_deref())?; - Ok(json!({ "name": name.unwrap_or_else(|| "work".into()), "path": path_json(&path) })) -} - -fn path_delete(s: &mut Session, p: &Value) -> Result { - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - s.edit("Delete Path", |doc, _| { - if is_work(name.as_deref()) { - return doc.work_path.take().map(|_| ()).ok_or_else(|| EngineError::Other("no work path".into())); - } - let n = name.clone().unwrap_or_default(); - let before = doc.paths.len(); - doc.paths.retain(|q| q.name != n); - if doc.paths.len() == before { - return Err(EngineError::Other(format!("no path named \"{n}\""))); - } - if doc.clipping_path.as_ref().is_some_and(|c| c.name == n) { - doc.clipping_path = None; - } - Ok(()) - })?; - Ok(Value::Null) -} - -fn clipping_path_set(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "path.clippingPath.set"; - let name = p.get("name").and_then(Value::as_str).filter(|name| !name.is_empty()).ok_or_else(|| bad(CMD, "missing saved path `name`"))?.to_owned(); - let flatness = match p.get("flatness") { - Some(value) => { - value.as_f64().filter(|value| value.is_finite() && (0.0..=100.0).contains(value)).ok_or_else(|| bad(CMD, "`flatness` must be between 0 and 100"))? - as f32 - } - None => 0.0, - }; - s.edit("Clipping Path", |doc, _| { - if !doc.paths.iter().any(|path| path.name == name) { - return Err(EngineError::Other(format!("no saved path named \"{name}\""))); - } - doc.clipping_path = Some(ClippingPath { name: name.clone(), flatness }); - Ok(()) - })?; - Ok(json!({ "name": name, "flatness": flatness })) -} - -fn clipping_path_clear(s: &mut Session, _p: &Value) -> Result { - s.edit("Clear Clipping Path", |doc, _| { - doc.clipping_path.take().ok_or_else(|| EngineError::Other("no clipping path".into()))?; - Ok(()) - })?; - Ok(Value::Null) -} - -/// The path `path.transform` edits: `layer`'s shape path or vector mask, else the work path or a -/// saved path. -fn transform_target(s: &Session, name: &str, layer: Option) -> Result { - let Some(id) = layer else { return resolve_path(s, Some(name)) }; - let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - match &l.content { - LayerContent::Shape(sh) => Ok(sh.path.clone()), - _ => l.vector_mask.as_ref().map(|v| v.path.clone()).ok_or_else(|| EngineError::Other("target layer has no shape path or vector mask".into())), - } -} - -/// `n` numbers at `key` (`[x0, y0, x1, y1]` or `[[x, y] × 4]`, flattened): finite, magnitude at -/// most 1000000. `None` when `key` is absent. -fn coords(p: &Value, key: &str, n: usize) -> Result>> { - const CMD: &str = "path.transform"; - let Some(v) = p.get(key) else { return Ok(None) }; - let flat: Vec<&Value> = match v.as_array() { - Some(a) if a.iter().all(Value::is_array) => a.iter().filter_map(Value::as_array).flatten().collect(), - Some(a) => a.iter().collect(), - None => Vec::new(), - }; - let nums: Vec = flat.iter().filter_map(|v| v.as_f64()).filter(|x| x.is_finite() && x.abs() <= 1_000_000.0).collect(); - if flat.len() != n || nums.len() != n { - return Err(bad(CMD, format!("`{key}` must hold {n} finite numbers with magnitude at most 1000000"))); - } - Ok(Some(nums)) -} - -/// How `path.transform` moves the path. -enum PathMap { - Affine(Affine), - /// Distort / Perspective: the box's corners go to a quad. - Projective(photocraft_algo::transform::Homography), - Warp(Box), -} - -fn path_transform(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "path.transform"; - let name = p.get("name").and_then(Value::as_str).unwrap_or("work").to_owned(); - let layer_id = if name == "layer" { Some(layer_id(s, p)?) } else { None }; - // The box Distort, Perspective and Warp frame: `rect`, else the path's control bounds. - let frame = |s: &Session| -> Result<[f64; 4]> { - let r = match coords(p, "rect", 4)? { - Some(r) => [r[0], r[1], r[2], r[3]], - None => { - let (x0, y0, x1, y1) = transform_target(s, &name, layer_id)?.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?; - [x0, y0, x1, y1] - } - }; - if r[2] - r[0] <= 0.0 || r[3] - r[1] <= 0.0 { - return Err(bad(CMD, "`rect` must have a positive width and height (a straight path needs a `rect`)")); - } - Ok(r) - }; - let map = if let Some(values) = p.get("matrix") { - let values = values.as_array().filter(|values| values.len() == 6).ok_or_else(|| bad(CMD, "`matrix` must contain six numbers"))?; - let mut m = [0.0; 6]; - for (slot, value) in m.iter_mut().zip(values) { - *slot = value - .as_f64() - .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) - .ok_or_else(|| bad(CMD, "matrix entries must be finite numbers with magnitude at most 1000000"))?; - } - PathMap::Affine(Affine { m }) - } else if let Some(q) = coords(p, "quad", 8)? { - // Distort and Perspective (#3027): where the frame's corners go, clockwise from top-left. - let quad = [[q[0], q[1]], [q[2], q[3]], [q[4], q[5]], [q[6], q[7]]]; - let h = photocraft_algo::transform::Homography::rect_to_quad(frame(s)?, quad) - .filter(|h| h.0.iter().all(|v| v.is_finite()) && h.inverse().is_some()) - .ok_or_else(|| bad(CMD, "degenerate quad"))?; - let [a, b, c, d, e, f, g, hh, i] = h.0; - if g.abs() < 1e-12 && hh.abs() < 1e-12 && i.abs() > 1e-12 { - // A parallelogram is affine: a live shape stays live. - PathMap::Affine(Affine { m: [a / i, d / i, b / i, e / i, c / i, f / i] }) - } else { - PathMap::Projective(h) - } - } else if ["warp", "style", "mesh"].iter().any(|key| p.get(*key).is_some()) { - let w = crate::warp_cmds::warp_from_params(CMD, p, frame(s)?)?; - if [w.bend, w.h_distort, w.v_distort].iter().chain(w.bounds.iter()).any(|v| !v.is_finite() || v.abs() > 1_000_000.0) { - return Err(bad(CMD, "warp values must be finite numbers with magnitude at most 1000000")); - } - PathMap::Warp(Box::new(w)) - } else { - let number = |key: &str, default: f64| -> Result { - match p.get(key) { - Some(value) => value - .as_f64() - .filter(|n| n.is_finite() && n.abs() <= 1_000_000.0) - .ok_or_else(|| bad(CMD, format!("`{key}` must be a finite number with magnitude at most 1000000"))), - None => Ok(default), - } - }; - if !["translateX", "translateY", "scaleX", "scaleY", "angle"].iter().any(|key| p.get(*key).is_some()) { - return Err(bad(CMD, "pass `matrix`, `quad`, a warp (`style`, `mesh` or `warp`) or translate/scale/angle parameters")); - } - let (tx, ty) = (number("translateX", 0.0)?, number("translateY", 0.0)?); - let (sx, sy) = (number("scaleX", 1.0)?, number("scaleY", 1.0)?); - let angle = number("angle", 0.0)?; - // An explicit `layer` parameter determines both the transformed path and its pivot (#3062). - let path = transform_target(s, &name, layer_id)?; - let (x0, y0, x1, y1) = path.control_bounds().ok_or_else(|| bad(CMD, "path has no points to transform"))?; - let (cx, cy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5); - PathMap::Affine( - Affine::translate(-cx, -cy) - .then(&Affine { m: [sx, 0.0, 0.0, sy, 0.0, 0.0] }) - .then(&Affine::rotate(angle.to_radians())) - .then(&Affine::translate(cx + tx, cy + ty)), - ) - }; - let out = match &map { - PathMap::Affine(a) => json!({ "name": name, "matrix": a.m }), - PathMap::Projective(h) => json!({ "name": name, "homography": h.0 }), - PathMap::Warp(_) => json!({ "name": name, "warp": true }), - }; - if let Some(id) = layer_id - && s.active().and_then(|d| d.doc.layer(id)).is_some_and(|layer| matches!(layer.content, LayerContent::Shape(_))) - { - with_shape(s, id, "Transform Path", |shape, _| { - match &map { - PathMap::Affine(a) => transform_shape(shape, a), - PathMap::Projective(h) => map_shape(shape, Some(h), None)?, - PathMap::Warp(w) => map_shape(shape, None, Some(w))?, - } - shape.psd_raw = None; - Ok(()) - })?; - return Ok(out); - } - s.edit("Transform Path", |doc, _| { - let path = path_mut(doc, &name, layer_id)?; - *path = match &map { - PathMap::Affine(a) => path.transform(a), - PathMap::Projective(h) => map_path(path, Some(h), None)?, - PathMap::Warp(w) => map_path(path, None, Some(w))?, - }; - Ok(()) - })?; - Ok(out) -} - -fn path_rename(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "path.rename"; - let name = p.get("name").and_then(Value::as_str).map(str::to_string); - let to = p.get("to").and_then(Value::as_str).filter(|t| !t.is_empty()).ok_or_else(|| bad(CMD, "missing `to`"))?.to_string(); - if is_work(Some(&to)) { - return Err(bad(CMD, "`to` cannot name the work path")); - } - s.edit(if is_work(name.as_deref()) { "Save Path" } else { "Rename Path" }, |doc, _| { - if doc.paths.iter().any(|q| q.name == to) { - return Err(EngineError::Other(format!("a path named \"{to}\" already exists"))); - } - if is_work(name.as_deref()) { - // Photoshop: saving the work path turns it into a named path. - let path = doc.work_path.take().ok_or_else(|| EngineError::Other("no work path".into()))?; - doc.paths.push(NamedPath { name: to.clone(), path, psd_raw: None }); - return Ok(()); - } - let n = name.clone().unwrap_or_default(); - let q = doc.paths.iter_mut().find(|q| q.name == n).ok_or_else(|| EngineError::Other(format!("no path named \"{n}\"")))?; - q.name = to.clone(); - if let Some(c) = doc.clipping_path.as_mut() - && c.name == n - { - c.name = to.clone(); - } - Ok(()) - })?; - Ok(json!({ "name": to })) -} - -fn path_to_selection(s: &mut Session, p: &Value) -> Result { - let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - // Validate before rasterizing or touching selection history. An unchecked feather value - // could silently become zero (negative/string), or expand into an excessive blur. - let feather = match p.get("feather") { - None => 0.0, - Some(v) => v - .as_f64() - .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) - .ok_or_else(|| bad("path.toSelection", "`feather` must be a finite number within 0..1000"))? as f32, - }; - let aa = match p.get("antiAlias") { - None => true, - Some(Value::Bool(v)) => *v, - _ => return Err(bad("path.toSelection", "`antiAlias` must be a boolean")), - }; - let mode = match p.get("mode") { - None => SelectionMode::Replace, - Some(Value::String(v)) if matches!(v.as_str(), "replace" | "add" | "subtract" | "intersect") => SelectionMode::parse(v), - _ => return Err(bad("path.toSelection", "`mode` must be replace, add, subtract or intersect")), - }; - let area = s.active().ok_or(EngineError::NoDocument)?.doc.bounds(); - let mut mask = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE).render(area); - if !aa { - for v in &mut mask { - *v = if *v >= 0.5 { 1.0 } else { 0.0 }; - } - } - if feather > 0.0 { - mask = sel::feather(&mask, area.width() as usize, area.height() as usize, feather); - } - let selected = s.edit("Make Selection", |doc, _| { - doc.selection = sel::combine(doc.selection.as_ref(), &mask, area, mode); - Ok(doc.selection.is_some()) - })?; - Ok(json!({ "selected": selected })) -} - -/// Runs `path.toSelection` with a forced combine mode (Ctrl+Shift+Enter / Ctrl+Alt+Enter). -/// Any `mode` passed by the caller is overridden; the other params (`name`, `feather`, -/// `antiAlias`) are forwarded unchanged and validated by `path_to_selection`. -/// `path.toSelection` with the combine mode fixed by the command (⌘⇧↩ adds, ⌘⌥↩ subtracts); -/// a `mode` in the params is overridden, the rest is forwarded and validated as usual. -fn path_to_selection_with_mode(s: &mut Session, p: &Value, mode: &str) -> Result { - let mut q = match p { - Value::Object(_) => p.clone(), - _ => json!({}), - }; - q["mode"] = json!(mode); - path_to_selection(s, &q) -} - -fn select_to_work_path(s: &mut Session, p: &Value) -> Result { - let tol = f64p(p, "tolerance").unwrap_or(2.0).clamp(0.5, 10.0); - let d = s.active().ok_or(EngineError::NoDocument)?; - let area = d.doc.bounds(); - let sel_surface = d.doc.selection.as_ref().ok_or_else(|| EngineError::Other("no selection".into()))?; - let mask = sel::mask_from_surface(Some(sel_surface), area); - let path = vector::trace::trace_mask(&mask, area, 0.0, tol); - let knots: usize = path.subpaths.iter().map(|s| s.knots.len()).sum(); - let subpaths = path.subpaths.len(); - s.edit("Make Work Path", |doc, _| { - doc.work_path = Some(path); - Ok(()) - })?; - Ok(json!({ "subpaths": subpaths, "knots": knots })) -} - -fn path_fill(s: &mut Session, p: &Value) -> Result { - let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let fg = s.tools.foreground; - let c = p.get("color").and_then(color).unwrap_or(Color::rgba(fg[0], fg[1], fg[2], fg[3])); - let rgb = c.to_rgb(); - let src = [rgb[0], rgb[1], rgb[2], c.alpha]; - let opacity = (f64p(p, "opacity").unwrap_or(100.0) / 100.0).clamp(0.0, 1.0) as f32; - let mode = p.get("mode").and_then(Value::as_str).and_then(blend_from_str).unwrap_or(BlendMode::Normal); - let feather = f64p(p, "feather").unwrap_or(0.0) as f32; - let aa = p.get("antiAlias").and_then(Value::as_bool).unwrap_or(true); - let id = layer_id(s, p)?; - s.edit("Fill Path", |doc, _| { - let area = doc.bounds(); - let r = vector::fill_rasterizer(&path, vector::DEFAULT_TOLERANCE); - // The feather reaches 3σ = 3 × radius (σ = radius, as in Photoshop), plus a pixel. - let feather_pad = (feather.ceil() as i32).saturating_mul(3).saturating_add(1); - let area = r.pixel_bounds().map_or(area, |b| b.inflate(feather_pad).intersect(&area)); - if area.is_empty() { - return Ok(()); - } - let mut cov = r.render(area); - if !aa { - cov.iter_mut().for_each(|v| *v = if *v >= 0.5 { 1.0 } else { 0.0 }); - } - if feather > 0.0 { - cov = sel::feather(&cov, area.width() as usize, area.height() as usize, feather); - } - // An active selection limits the fill, soft edges included, as every other fill does (#2387). - if let Some(selection) = doc.selection.as_ref() { - let mask = sel::mask_from_surface(Some(selection), area); - cov.iter_mut().zip(&mask).for_each(|(c, m)| *c *= m); - } - let locks = doc.effective_locks(id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if locks.pixels || locks.all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); - } - let lock = locks.transparency; - let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Fill Path needs a pixel layer".into()))?; - paint_coverage(surf, area, &cov, src, opacity, mode, lock); - Ok(()) - })?; - Ok(Value::Null) -} - -/// Composites `src` × coverage onto a surface over `area` (any pixel format). -fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32], src: [f32; 4], opacity: f32, mode: BlendMode, lock_alpha: bool) { - let fmt = surf.format(); - let ch = fmt.channels(); - let mut vals = surf.read_region(area); - for (px, c) in vals.chunks_exact_mut(ch).zip(cov) { - if *c <= 0.0 { - continue; - } - let dst = photocraft_raster::to_rgba(&fmt, px); - let mut out = photocraft_compose::psblend::composite(mode, dst, [src[0], src[1], src[2], src[3] * c], opacity); - if lock_alpha { - out[3] = dst[3]; - } - photocraft_raster::from_rgba_into(&fmt, out, px); - } - surf.write_region(area, &vals); - surf.prune(); -} - -/// Stroke Path's brush-based tools besides the Brush, Pencil and Eraser it renders itself: each -/// paints the path with its own `paint.*` command (Photoshop's Stroke Path dialog Tool list). -pub const STROKE_PATH_TOOLS: &[(&str, &str)] = &[ - ("backgroundEraser", "paint.backgroundEraser"), - ("cloneStamp", "paint.cloneStamp"), - ("patternStamp", "paint.patternStamp"), - ("healingBrush", "paint.healingBrush"), - ("spotHealingBrush", "paint.spotHealing"), - ("historyBrush", "paint.historyBrush"), - ("blur", "paint.blur"), - ("sharpen", "paint.sharpen"), - ("smudge", "paint.smudge"), - ("dodge", "paint.dodge"), - ("burn", "paint.burn"), - ("sponge", "paint.sponge"), - ("colorReplacement", "paint.colorReplacement"), - ("mixerBrush", "paint.mixerBrush"), -]; - -/// At most this many points per subpath when Simulate Pressure resamples it. -const MAX_PRESSURE_POINTS: f64 = 200_000.0; - -/// The stroke points along one flattened subpath. With `simulate` (Stroke Path's Simulate -/// Pressure) the subpath is resampled about every pixel and the pressure rises from 0 at its start -/// to full at the middle and falls back to 0 at its end, so a brush whose size or opacity follows -/// pen pressure tapers at both ends, as in Photoshop. Otherwise every point has full pressure. -fn stroke_points(pts: &[(f64, f64)], closed: bool, simulate: bool) -> Vec { - use photocraft_paint::StrokePoint; - let mut line = pts.to_vec(); - if closed && let Some(&first) = pts.first() { - line.push(first); - } - let full = |line: Vec<(f64, f64)>| line.into_iter().map(|(x, y)| StrokePoint::new(x, y, 1.0)).collect(); - if !simulate { - return full(line); - } - let seg = |a: &(f64, f64), b: &(f64, f64)| (b.0 - a.0).hypot(b.1 - a.1); - let total: f64 = line.iter().zip(line.iter().skip(1)).map(|(a, b)| seg(a, b)).sum(); - if !(total.is_finite() && total > 0.0) { - return full(line); - } - let step = (total / MAX_PRESSURE_POINTS).max(1.0); - let pressure = |d: f64| (d / total * std::f64::consts::PI).sin().clamp(0.0, 1.0) as f32; - let mut out = Vec::new(); - let mut walked = 0.0; - for (a, b) in line.iter().zip(line.iter().skip(1)) { - let len = seg(a, b); - let n = (len / step).ceil().clamp(1.0, MAX_PRESSURE_POINTS) as usize; - for i in 0..n { - let t = i as f64 / n as f64; - out.push(StrokePoint::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, pressure(walked + len * t))); - } - walked += len; - } - if let Some(&(x, y)) = line.last() { - out.push(StrokePoint::new(x, y, 0.0)); - } - out -} - -/// Stroke Path with a tool that has its own `paint.*` command: the command runs once per subpath, -/// with the caller's params (size, opacity, the tool's options) and the path's points, all in one -/// history step. Only `path.stroke` itself is journalled, so a recorded action replays it once. -fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::Polyline], simulate: bool) -> Result { - let mut params = p.as_object().cloned().unwrap_or_default(); - for k in ["name", "tool", "simulatePressure", "coalesce"] { - params.remove(k); - } - let journal = s.journal.len(); - let r = crate::analysis_cmds::compound(s, "Stroke Path", |s| { - // The union of what each subpath's command changed; None when one of them didn't say. - let mut dmg = Some(Rect::EMPTY); - for pl in lines { - let pts = stroke_points(&pl.pts, pl.closed, simulate); - if pts.is_empty() { - continue; - } - let mut call = params.clone(); - call.insert("points".into(), Value::Array(pts.iter().map(|q| json!([q.x, q.y, q.pressure])).collect())); - s.execute(cmd, Value::Object(call))?; - let r = s.active().and_then(|d| d.last_damage); - dmg = dmg.zip(r).map(|(a, b)| { - if a.is_empty() { - b - } else if b.is_empty() { - a - } else { - a.union(&b) - } - }); - } - Ok(dmg) - }); - s.journal.truncate(journal); - let dmg = r?; - if let Some(st) = s.active_mut() { - st.last_damage = dmg; - } - let dmg = dmg.unwrap_or_else(|| s.active().map_or(Rect::EMPTY, |d| d.doc.bounds())); - Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) -} - -fn path_stroke(s: &mut Session, p: &Value) -> Result { - let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; - let tool = p.get("tool").and_then(Value::as_str).unwrap_or("brush"); - let simulate = match p.get("simulatePressure") { - None | Some(Value::Null) => false, - Some(v) => v.as_bool().ok_or_else(|| bad("path.stroke", "`simulatePressure` must be true or false"))?, - }; - if let Some(&(_, cmd)) = STROKE_PATH_TOOLS.iter().find(|(t, _)| *t == tool) { - let lines = vector::flatten_path(&path, 0.1); - return stroke_with_command(s, p, cmd, &lines, simulate); - } - let base = s.tools.brush.clone(); - let fg = s.tools.foreground; - let mut brush = photocraft_paint::BrushSettings { - size: f64p(p, "size").map_or(base.size, |v| v as f32), - hardness: f64p(p, "hardness").map_or(base.hardness, |v| v as f32), - opacity: f64p(p, "opacity").map_or(base.opacity, |v| (v / 100.0) as f32), - color: p.get("color").and_then(color).map_or(fg, |c| { - let v = c.to_rgb(); - [v[0], v[1], v[2], c.alpha] - }), - erase: tool == "eraser", - ..base - }; - // The path is the exact geometry: stroke smoothing (a hand-drawing aid) would cut its corners. - brush.smoothing.amount = 0.0; - match tool { - "pencil" => brush.hardness = 1.0, - "brush" | "eraser" => {} - o => { - let tools: Vec<&str> = ["brush", "pencil", "eraser"].into_iter().chain(STROKE_PATH_TOOLS.iter().map(|(t, _)| *t)).collect(); - return Err(bad("path.stroke", format!("unknown tool `{o}` ({})", tools.join("|")))); - } - } - crate::brush_cmds::validate_brush(&brush, "path.stroke")?; - let lines = vector::flatten_path(&path, 0.1); - let id = layer_id(s, p)?; - let bg = s.tools.background; - let dmg = s.edit("Stroke Path", |doc, _| { - let sel = doc.selection.clone(); - let locks = doc.effective_locks(id); - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - // Pixel and full locks (the layer's own or a parent group's) refuse the stroke, as Fill Path does (#2388). - if locks.pixels || locks.all { - return Err(EngineError::Other(format!("Could not complete your request because the layer \"{}\" is locked", l.name))); - } - let lock = locks.transparency; - let surf = l.surface_mut().ok_or_else(|| EngineError::Other("Stroke Path needs a pixel layer".into()))?; - let mut brush = brush.clone(); - if brush.erase && lock { - // Locked transparency (e.g. the Background): the Eraser paints the background colour (#76). - brush.erase = false; - brush.color = bg; - } - let mut dmg = Rect::EMPTY; - for pl in &lines { - let pts = stroke_points(&pl.pts, pl.closed, simulate); - if pts.is_empty() { - continue; - } - let r = photocraft_paint::apply_stroke(surf, &photocraft_paint::Stroke { brush: brush.clone(), points: pts }, sel.as_ref(), lock); - dmg = if dmg.is_empty() { r } else { dmg.union(&r) }; - } - Ok(dmg) - })?; - Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) -} - -// --------------------------------------------------------------------------- -// Vector masks -// --------------------------------------------------------------------------- - -fn vector_mask_info(s: &Session, id: LayerId) -> Result { - let d = s.active().ok_or(EngineError::NoDocument)?; - let l = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?; - Ok(match (&l.vector_mask, &l.content) { - (Some(v), _) => { - json!({ "layer": id.0, "path": path_json(&v.path), "enabled": v.enabled, "linked": v.linked, "density": v.density * 100.0, "feather": v.feather }) - } - // A shape layer's path is its vector mask. - (None, LayerContent::Shape(sh)) => { - json!({ "layer": id.0, "path": path_json(&sh.path), "enabled": true, "linked": true, "shape": true, "density": sh.density * 100.0, "feather": sh.feather }) - } - (None, _) => json!({ "layer": id.0, "vectorMask": null }), - }) -} - -fn vector_mask_add(s: &mut Session, p: &Value, from_path: bool) -> Result { - const CMD: &str = "layer.vectorMask.add"; - let id = layer_id(s, p)?; - let mut path = match p.get("path") { - Some(v) => parse_path(v).map_err(|e| bad(CMD, e))?, - None if from_path || p.get("name").is_some() => { - let name = p.get("name").and_then(Value::as_str); - // Never fall back to the layer's own path here. - if is_work(name) { - s.active().and_then(|d| d.doc.work_path.clone()).ok_or_else(|| EngineError::Other("no work path".into()))? - } else { - resolve_path(s, name)? - } - } - None => Path::default(), - }; - if p.get("hide").and_then(Value::as_bool).unwrap_or(false) { - path.inverted = !path.inverted; - } - s.edit("Add Vector Mask", |doc, _| { - let locked = doc.effective_locks(id).all; - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - if matches!(l.content, LayerContent::Shape(_)) { - return Err(EngineError::Other("a shape layer's path is already its vector mask".into())); - } - if locked { - return Err(EngineError::Other(format!("layer \"{}\" is locked", l.name))); - } - crate::extra_cmds::background_to_layer(doc, id); - doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?.vector_mask = Some(VectorMask::new(path)); - Ok(()) - })?; - vector_mask_info(s, id) -} - -fn vector_mask_edit(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "layer.vectorMask.edit"; - let id = layer_id(s, p)?; - let path = p.get("path").map(parse_path).transpose().map_err(|e| bad(CMD, e))?; - // Same range as a pixel mask's feather (`layer.layerMask.edit`), checked before any edit (#4201). - let feather = p - .get("feather") - .map(|v| { - v.as_f64() - .filter(|n| n.is_finite() && (0.0..=1000.0).contains(n)) - .map(|n| n as f32) - .ok_or_else(|| bad(CMD, "`feather` must be a finite number within 0..1000")) - }) - .transpose()?; - let density = p - .get("density") - .map(|v| { - v.as_f64() - .filter(|n| n.is_finite() && (0.0..=100.0).contains(n)) - .map(|n| (n / 100.0) as f32) - .ok_or_else(|| bad(CMD, "`density` must be a finite number within 0..100 (%)")) - }) - .transpose()?; - // A shape layer's path is its vector mask (Photoshop's Properties › Masks): Density and - // Feather soften its fill; the path itself is edited with the shape and path commands. - if s.active().and_then(|d| d.doc.layer(id)).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)) && l.vector_mask.is_none()) { - if let Some(k) = ["path", "enabled", "linked", "invert"].into_iter().find(|k| p.get(*k).is_some()) { - return Err(bad(CMD, format!("`{k}`: a shape layer's vector mask is its path; only `density` and `feather` apply"))); - } - if density.is_none() && feather.is_none() { - return vector_mask_info(s, id); - } - with_shape(s, id, "Edit Vector Mask", |sh, _| { - if let Some(d) = density { - sh.density = d; - } - if let Some(f) = feather { - sh.feather = f; - } - Ok(()) - })?; - return vector_mask_info(s, id); - } - s.edit("Edit Vector Mask", |doc, _| { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let vm = l.vector_mask.as_mut().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; - if let Some(pth) = path { - vm.path = pth; - } - if let Some(b) = p.get("enabled").and_then(Value::as_bool) { - vm.enabled = b; - } - if let Some(b) = p.get("linked").and_then(Value::as_bool) { - vm.linked = b; - } - if let Some(d) = density { - vm.density = d; - } - if let Some(f) = feather { - vm.feather = f; - } - if let Some(b) = p.get("invert").and_then(Value::as_bool) - && b - { - vm.path.inverted = !vm.path.inverted; - } - Ok(()) - })?; - vector_mask_info(s, id) -} - -fn vector_mask_delete(s: &mut Session, p: &Value) -> Result { - let id = layer_id(s, p)?; - s.edit("Delete Vector Mask", |doc, _| { - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - l.vector_mask.take().map(|_| ()).ok_or_else(|| EngineError::Other("layer has no vector mask".into())) - })?; - Ok(Value::Null) -} - -/// Layer › Rasterize › Vector Mask: multiplies the vector mask into the pixel mask. When a feather -/// (either mask's) or the pixel mask's density shapes the rendered edge, the pixel mask instead -/// becomes exactly what the compositor showed, with those settings baked in (#992). -fn vector_mask_rasterize(s: &mut Session, p: &Value) -> Result { - use photocraft_compose::masks::{combined_mask, feather_sigma}; - let id = layer_id(s, p)?; - s.edit("Rasterize Vector Mask", |doc, _| { - let area = doc.bounds(); - let depth = doc.depth; - let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; - let soft = l.vector_mask.as_ref().is_some_and(|v| v.enabled) - && (l.vector_mask.as_ref().is_some_and(|v| feather_sigma(v.feather) > 0.0) - || l.mask.as_ref().is_some_and(|m| m.enabled && (feather_sigma(m.feather) > 0.0 || m.density < 1.0))); - let shown = if soft { combined_mask(l, area) } else { None }; - let vm = l.vector_mask.take().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; - let mut mask = l.mask.take().unwrap_or_else(|| { - let mut m = photocraft_doc::LayerMask::reveal_all(); - m.surface = - photocraft_raster::Surface::with_default(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Grayscale, depth, false), &[1.0]); - m - }); - if let Some(shown) = shown { - // Past the canvas the pixel mask keeps its own (density-applied) default, as below. - let d = mask.surface.default_pixel().first().copied().unwrap_or(1.0); - let mut surface = photocraft_raster::Surface::with_default(mask.surface.format(), &[1.0 - mask.density * (1.0 - d)]); - surface.write_region(area, &shown.read_region(area)); - surface.prune(); - (mask.surface, mask.feather, mask.density) = (surface, 0.0, 1.0); - l.mask = Some(mask); - return Ok(()); - } - let vals = vector::vector_mask_values(&vm, area); - // Outside the canvas the vector mask is 0 unless it is empty/inverted; keep the old default there. - let old = mask.surface.read_region(area); - let merged: Vec = old.iter().zip(&vals).map(|(a, b)| a * b).collect(); - mask.surface.write_region(area, &merged); - mask.surface.prune(); - l.mask = Some(mask); - Ok(()) - })?; - Ok(json!({ "layer": id.0 })) -} - -/// `p` with `key` set (for alias commands). -fn with(p: &Value, key: &str, v: Value) -> Value { - let mut o = if p.is_object() { p.clone() } else { json!({}) }; - o[key] = v; - o -} - -fn toggle_vector_mask(s: &mut Session, p: &Value, key: &str) -> Result { - let id = layer_id(s, p)?; - let cur = { - let d = s.active().ok_or(EngineError::NoDocument)?; - let vm = d.doc.layer(id).ok_or(EngineError::NoLayer(id))?.vector_mask.as_ref().ok_or_else(|| EngineError::Other("layer has no vector mask".into()))?; - if key == "enabled" { vm.enabled } else { vm.linked } - }; - let v = p.get(key).and_then(Value::as_bool).unwrap_or(!cur); - vector_mask_edit(s, &json!({ "layer": id.0, key: v })) -} - -/// Traces the exact coverage of the shape's path back into plain outlines. -fn merge_components(s: &mut Session, p: &Value) -> Result { - let tol = f64p(p, "tolerance").unwrap_or(0.1).clamp(0.05, 10.0); - if let Some(ids) = combine_targets(s, p, &["layer"])? { - return merge_shape_layers(s, &ids, None, "Merge Shape Components", Some(tol)); - } - let id = layer_id(s, p)?; - with_shape(s, id, "Merge Shape Components", |sh, _| bake_components(sh, tol))?; - shape_info(s, id) -} - -/// Bakes `sh`'s path operations into plain combined outlines, traced from the exact coverage. -fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { - // Trace at 4× resolution so the fitted outline follows the anti-aliased edge closely. - const K: f64 = 4.0; - let up = sh.path.transform(&Affine::scale(K)); - let r = vector::fill_rasterizer(&up, vector::DEFAULT_TOLERANCE); - let Some(b) = r.pixel_bounds() else { - sh.path.subpaths.clear(); - return Ok(()); - }; - let b = b.inflate(2); - if u64::from(b.width()) * u64::from(b.height()) > 400_000_000 { - return Err(EngineError::Other("shape too large to merge".into())); - } - let cov = r.render(b); - let traced = vector::trace::trace_mask(&cov, b, 0.0, tol * K); - sh.path = traced.transform(&Affine::scale(1.0 / K)); - sh.live = None; - sh.psd_raw = None; - Ok(()) -} - -/// The shape layers Layer › Combine Shapes merges into one: with none of the `narrow` params -/// given and two or more shape layers selected, those layers bottom-to-top (other selected layers -/// are left alone). `None` keeps the single-layer behaviour (acting on one layer's components). -fn combine_targets(s: &Session, p: &Value, narrow: &[&str]) -> Result>> { - if narrow.iter().any(|k| p.get(*k).is_some()) { - return Ok(None); - } - let d = s.active().ok_or(EngineError::NoDocument)?; - let sel = d.selected_layers(); - let ids = crate::layer_multi_cmds::top_level(&d.doc, &sel); - let ids: Vec = ids.into_iter().filter(|id| d.doc.layer(*id).is_some_and(|l| matches!(l.content, LayerContent::Shape(_)))).collect(); - Ok((ids.len() > 1).then_some(ids)) -} - -/// Layer › Combine Shapes with several shape layers selected: Photoshop merges them into the -/// top-most one. The bottom layer's components keep their operations; every component of the -/// layers above takes `op` (front shapes subtract, intersect… in stacking order), or, with no -/// `op` (Merge Shape Components), keeps its own with each layer's first component combined, and -/// the result is then baked with tolerance `bake`. The other layers are removed; one history step. -fn merge_shape_layers(s: &mut Session, ids: &[LayerId], op: Option, label: &str, bake: Option) -> Result { - let (&keep, _) = ids.split_last().ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?; - if let Some(st) = s.active() { - for id in ids { - let locks = st.doc.effective_locks(*id); - if locks.all || locks.pixels { - return Err(EngineError::Other("a selected shape layer is locked".into())); - } - } - } - s.edit(label, |doc, active| { - let mut path: Option = None; - for id in ids { - let l = doc.layer(*id).ok_or(EngineError::NoLayer(*id))?; - let LayerContent::Shape(sh) = &l.content else { - return Err(EngineError::Other(format!("layer {} is not a shape layer", id.0))); - }; - let Some(acc) = path.as_mut() else { - path = Some(sh.path.clone()); - continue; - }; - for (j, sp) in sh.path.subpaths.iter().enumerate() { - let mut sp = sp.clone(); - sp.op = match op { - Some(op) => op, - None if j == 0 => PathOp::Combine, - None => sp.op, - }; - acc.subpaths.push(sp); - } - } - let path = path.ok_or_else(|| EngineError::Other("Combine Shapes needs two or more shape layers".into()))?; - let snapshot = doc.clone(); - let l = doc.layer_mut(keep).ok_or(EngineError::NoLayer(keep))?; - let LayerContent::Shape(sh) = &mut l.content else { - return Err(EngineError::Other(format!("layer {} is not a shape layer", keep.0))); - }; - let mut merged = ShapeLayer { path, live: None, psd_raw: None, cache: None, ..sh.clone() }; - if let Some(tol) = bake { - bake_components(&mut merged, tol)?; - } - vector::flatten::validate_shape(&merged.path, merged.stroke.as_ref()).map_err(|e| bad(label, e))?; - refresh_shape(&snapshot, &mut merged); - *sh = merged; - for id in ids.iter().filter(|id| **id != keep) { - doc.remove(*id); - } - *active = Some(keep); - Ok(()) - })?; - crate::layer_multi_cmds::reselect(s, vec![keep], Some(keep)); - shape_info(s, keep) -} - -/// Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect / Exclude: merges the -/// selected shape layers with `op` ([`merge_shape_layers`]), or with one layer (or an explicit -/// `layer`/`subpath`) sets the operation of that layer's components (`shape.edit`'s `op`). -fn combine_shapes(s: &mut Session, p: &Value, op: &str, label: &str) -> Result { - if let Some(ids) = combine_targets(s, p, &["layer", "subpath"])? { - let op = op_from(op).ok_or_else(|| EngineError::Other(format!("unknown op `{op}`")))?; - return merge_shape_layers(s, &ids, Some(op), label, None); - } - shape_edit(s, &with(p, "op", json!(op))) -} - -fn selection_to_shape(s: &mut Session, p: &Value) -> Result { - let tol = f64p(p, "tolerance").or_else(|| f64p(p, "simplifyTolerance")).unwrap_or(2.0).clamp(0.5, 10.0); - let d = s.active().ok_or(EngineError::NoDocument)?; - let area = d.doc.bounds(); - let mask = sel::mask_from_surface(d.doc.selection.as_ref(), area); - let path = vector::trace::trace_mask(&mask, area, 0.0, tol); - let mut q = json!({ "kind": "path", "path": path_json(&path) }); - for k in ["fill", "name", "stroke"] { - if let Some(v) = p.get(k) { - q[k] = v.clone(); - } - } - shape_create(s, &q) -} - -// --------------------------------------------------------------------------- -// Specs -// --------------------------------------------------------------------------- - -/// Params of the Layer › Combine Shapes operations. -const COMBINE_PARAMS: &str = r##"{"layer":id?,"subpath":index? (default all but the first)} (no layer/subpath and two or more shape layers selected: merges them into the top-most, whose components above the bottom layer's take the operation)"##; - -pub(crate) const PATH_FORM: &str = r##"path: {"subpaths":[{"closed":bool=true,"op":"combine|subtract|intersect|exclude","knots":[[x,y] | {"anchor":[x,y],"in":[x,y],"out":[x,y],"smooth":bool}]}],"fillRule":"nonzero|evenodd","inverted":bool}"##; - -macro_rules! spec { - ($id:literal, $label:literal, [$($m:literal),*], $params:expr, $en:expr, $run:expr) => { - CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: $en, run: $run, journal: true } - }; -} - -pub(crate) fn leak(s: String) -> &'static str { - Box::leak(s.into_boxed_str()) -} - -pub fn specs() -> Vec { - let stroke = r##""stroke":{"width":px,"color":"#rrggbb"|fill,"opacity":0..100,"align":"inside|center|outside","cap":"butt|round|square","join":"miter|round|bevel","miterLimit":n,"dashes":[multiples of width],"dashOffset":n}|null"##; - let fill = r##""fill":"#rrggbb"|[r,g,b,a]|{"gradient":{"stops":[[t,"#hex"|[r,g,b,a]|stored Color]],"angle":deg,"scale":%,"style":"linear|radial|angle|reflected|diamond","reverse":bool,"opacityStops":[[t,0..1]]?,"midpoints":[0..1]?,"offset":[x,y]?,"dither":bool?,"align":bool?}}|{"pattern":name}|null (no fill)"##; - vec![ - spec!( - "shape.create", - "New Shape Layer", - ["Layer", "New"], - leak(format!( - r##"{{"kind":"rect|roundedRect|ellipse|polygon|star|line|path"="rect","rect":[x,y,w,h] (rect/roundedRect/ellipse/polygon/star),"radii":[tl,tr,br,bl]|r (roundedRect=10),"sides":3..100=5,"starRatio":0..1 (star=0.5),"cornerRadius":px=0 (polygon/star: rounds every vertex; the Triangle tool is a 3-sided polygon),"from":[x,y],"to":[x,y],"weight":px=1 (line),"path":{{…}} (kind path),{fill}=foreground,{stroke}=none,"name":str?,"addTo":layerId? + "op":"combine|subtract|intersect|exclude" (append to an existing shape layer)}} → shape.info. {PATH_FORM}"## - )), - has_doc, - shape_create - ), - spec!( - "shape.edit", - "Edit Shape", - [], - leak(format!( - r##"{{"layer":id?,"path":{{…}}? (replaces; drops live shape),"kind":str?,"rect":[x,y,w,h]?,"radii":[tl,tr,br,bl]|r?,"sides":n?,"starRatio":r?,"cornerRadius":px?,"from":[x,y]?,"to":[x,y]?,"weight":px? (live-shape params regenerate the path),"move":[dx,dy]?,"transform":[a,b,c,d,e,f]?,"op":"combine|…"? + "subpath":index? (default all but the first),"fillRule":"nonzero|evenodd"?,{fill}?,{stroke}? (merged into the current stroke),"name":str?}} → shape.info"## - )), - has_layer, - shape_edit - ), - CommandSpec { - id: "shape.info", - label: "Shape Layer Info", - menu: &[], - shortcut: None, - params: r##"{"layer":id?} → {layer,name,kind,path,fill,stroke,live,bounds:[x,y,w,h]}"##, - enabled: has_layer, - run: |s, p| { - let id = layer_id(s, p)?; - shape_info(s, id) - }, - journal: false, - }, - spec!("shape.rasterize", "Rasterize Shape", ["Layer", "Rasterize"], r##"{"layer":id?}"##, has_layer, shape_rasterize), - CommandSpec { - id: "path.list", - label: "List Paths", - menu: &[], - shortcut: None, - params: r##"{} → {paths:[{name,knots,subpaths}],workPath,clippingPath,layerPath (active layer's shape path / vector mask)}"##, - enabled: has_doc, - run: |s, _| paths_list(s), - journal: false, - }, - CommandSpec { - id: "path.info", - label: "Path Info", - menu: &[], - shortcut: None, - params: r##"{"name":str|"work"|"layer"="work"} → {name,path}"##, - enabled: has_doc, - run: |s, p| { - let name = p.get("name").and_then(Value::as_str); - let path = resolve_path(s, name)?; - Ok(json!({ "name": name.unwrap_or("work"), "path": path_json(&path) })) - }, - journal: false, - }, - spec!( - "path.set", - "Set Path", - [], - leak(format!( - r##"{{"name":str|"work"="work","path":{{…}},"op":"combine|subtract|intersect|exclude"? (append to the existing path with this op instead of replacing)}}. {PATH_FORM}"## - )), - has_doc, - path_set - ), - spec!("path.delete", "Delete Path", [], r##"{"name":str|"work"="work"}"##, has_doc, path_delete), - spec!( - "path.transform", - "Free Transform Path", - [], - r##"{"name":str|"work"|"layer"="work","layer":id? (with layer target),"matrix":[a,b,c,d,e,f] | "quad":[[x,y]×4] (Distort/Perspective: where the frame's corners go, clockwise from top-left) | "style"|"mesh"|"warp":… (Warp, as edit.transform.warp) | "translateX":px?,"translateY":px?,"scaleX":factor?,"scaleY":factor?,"angle":degrees?,"rect":[x0,y0,x1,y1]? (the quad's or warp's frame; default = path control bounds)} (numeric transforms pivot on path bounds center)"##, - has_doc, - path_transform - ), - spec!( - "path.clippingPath.set", - "Clipping Path", - [], - r##"{"name":savedPathName,"flatness":0..100=0} (PSD export clipping path)"##, - has_doc, - clipping_path_set - ), - spec!("path.clippingPath.clear", "Clear Clipping Path", [], r##"{}"##, has_doc, clipping_path_clear), - spec!("path.style.copyFill", "Copy Fill", [], r##"{} (copies active shape layer fill)"##, has_shape_fill, copy_shape_fill), - spec!("path.style.copyStroke", "Copy Complete Stroke", [], r##"{} (copies active shape layer stroke)"##, has_shape_stroke, copy_shape_stroke), - spec!( - "path.style.pasteFill", - "Paste Fill", - [], - r##"{"layer":id?} (pastes copied fill onto active shape layer)"##, - can_paste_shape_fill, - paste_shape_fill - ), - spec!( - "path.style.pasteStroke", - "Paste Complete Stroke", - [], - r##"{"layer":id?} (pastes copied stroke onto active shape layer)"##, - can_paste_shape_stroke, - paste_shape_stroke - ), - spec!("path.rename", "Rename Path", [], r##"{"name":str|"work"="work","to":str} (renaming the work path saves it)"##, has_doc, path_rename), - // ⌘↩ / Ctrl+Enter, as in Photoshop (the UI loads the path selected in the Paths panel, #306). - CommandSpec { - id: "path.toSelection", - label: "Make Selection from Path", - menu: &[], - shortcut: Some("Cmd+Enter"), - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, - enabled: has_doc, - run: path_to_selection, - journal: true, - }, - // ⌘⇧↩ / Ctrl+Shift+Enter adds the path to the selection, ⌘⌥↩ / Ctrl+Alt+Enter subtracts it. - CommandSpec { - id: "path.addToSelection", - label: "Add Path to Selection", - menu: &[], - shortcut: Some("Cmd+Shift+Enter"), - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "add")"##, - enabled: has_doc, - run: |s, p| path_to_selection_with_mode(s, p, "add"), - journal: true, - }, - // Ctrl+Alt+Enter: subtract the path from the active selection. - CommandSpec { - id: "path.subtractFromSelection", - label: "Subtract Path from Selection", - menu: &[], - shortcut: Some("Cmd+Alt+Enter"), - params: r##"{"name":str|"work"|"layer"="work","feather":px=0,"antiAlias":bool=true} (path.toSelection with mode "subtract")"##, - enabled: has_doc, - run: |s, p| path_to_selection_with_mode(s, p, "subtract"), - journal: true, - }, - spec!("select.toWorkPath", "Make Work Path", [], r##"{"tolerance":0.5..10 px=2} → {subpaths,knots}"##, has_selection, select_to_work_path), - spec!( - "path.fill", - "Fill Path", - [], - r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer, default active),"color":"#rrggbb"=foreground,"opacity":0..100=100,"mode":"normal|multiply|…"="normal","feather":px=0,"antiAlias":bool=true}"##, - has_layer, - path_fill - ), - spec!( - "path.stroke", - "Stroke Path", - [], - r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer),"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false (taper both ends when the brush follows pen pressure),"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..100?,"color":"#rrggbb"=foreground} (current brush settings otherwise; tools past the Eraser run their paint.* command along the path and take its params)"##, - has_layer, - path_stroke - ), - spec!( - "layer.vectorMask.add", - "Add Vector Mask", - ["Layer", "Vector Mask"], - r##"{"layer":id?,"path":{…}? | "name":str|"work"? (copy a document path),"hide":bool=false (Hide All = inverted)} → vector mask info. Without a path the mask reveals all."##, - has_layer, - |s, p| vector_mask_add(s, p, false) - ), - spec!( - "layer.vectorMask.fromPath", - "Vector Mask from Current Path", - ["Layer", "Vector Mask"], - r##"{"layer":id?,"name":str|"work"="work"}"##, - has_layer, - |s, p| vector_mask_add(s, p, true) - ), - spec!( - "layer.vectorMask.edit", - "Edit Vector Mask", - [], - leak(format!( - r##"{{"layer":id?,"path":{{…}}?,"enabled":bool?,"linked":bool?,"density":0..100?,"feather":0..1000? (pixels),"invert":true?}}. On a shape layer (whose path is its vector mask) only density and feather apply: they soften its fill, which shows beyond the path at 100 − density %. {PATH_FORM}"## - )), - has_layer, - vector_mask_edit - ), - spec!("layer.vectorMask.delete", "Delete Vector Mask", ["Layer", "Vector Mask"], r##"{"layer":id?}"##, has_layer, vector_mask_delete), - spec!( - "layer.rasterize.vectorMask", - "Rasterize Vector Mask", - [], - r##"{"layer":id?} (multiplied into the layer mask)"##, - has_layer, - vector_mask_rasterize - ), - // Menu ids of Photoshop's Layer › Vector Mask / Rasterize / Combine Shapes. - spec!("layer.vectorMask.revealAll", "Vector Mask: Reveal All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( - s, - &with(p, "hide", json!(false)), - false - )), - spec!("layer.vectorMask.hideAll", "Vector Mask: Hide All", [], r##"{"layer":id?}"##, has_layer, |s, p| vector_mask_add( - s, - &with(p, "hide", json!(true)), - false - )), - spec!("layer.vectorMask.currentPath", "Vector Mask: Current Path", [], r##"{"layer":id?,"name":str|"work"="work"}"##, has_layer, |s, p| { - vector_mask_add(s, p, true) - }), - spec!("layer.vectorMask.enabled", "Enable Vector Mask", [], r##"{"layer":id?,"enabled":bool? (default: toggle)}"##, has_layer, |s, p| { - toggle_vector_mask(s, p, "enabled") - }), - spec!("layer.vectorMask.linked", "Link Vector Mask", [], r##"{"layer":id?,"linked":bool? (default: toggle)}"##, has_layer, |s, p| toggle_vector_mask( - s, p, "linked" - )), - // Without `layer`, every selected shape layer (the shared Rasterize logic calls back here with one). - spec!("layer.rasterize.shape", "Rasterize Shape", [], r##"{"layer":id?} (no layer: every selected shape layer)"##, has_layer, |s, p| { - if p.get("layer").is_some() { shape_rasterize(s, p) } else { crate::extra_cmds::rasterize(s, p, Some("shape")) } - }), - // With two or more shape layers selected (and no `layer`/`subpath`), these merge them into - // the top-most one, as in Photoshop; with one, they set the operation of its components. - spec!("layer.combineShapes.unite", "Unite Shapes", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes(s, p, "combine", "Unite Shapes")), - spec!("layer.combineShapes.subtractFrontShape", "Subtract Front Shape", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( - s, - p, - "subtract", - "Subtract Front Shape" - )), - spec!("layer.combineShapes.intersectShapeAreas", "Intersect Shape Areas", [], COMBINE_PARAMS, has_layer, |s, p| combine_shapes( - s, - p, - "intersect", - "Intersect Shape Areas" - )), - spec!("layer.combineShapes.excludeOverlappingShapes", "Exclude Overlapping Shapes", [], COMBINE_PARAMS, has_layer, |s, p| { - combine_shapes(s, p, "exclude", "Exclude Overlapping Shapes") - }), - spec!( - "layer.combineShapes.mergeShapeComponents", - "Merge Shape Components", - [], - r##"{"layer":id?,"tolerance":px=0.1} (bakes the path operations into plain combined outlines, traced from the exact coverage; no layer and several shape layers selected: merges them into the top-most first)"##, - has_layer, - merge_components - ), - spec!("select.convertToShape", "Convert to Shape", [], r##"{"tolerance":0.5..10 px=2} → shape.info"##, has_selection, selection_to_shape), - CommandSpec { - id: "layer.vectorMask.info", - label: "Vector Mask Info", - menu: &[], - shortcut: None, - params: r##"{"layer":id?} → {layer,path,enabled,linked,density,feather}"##, - enabled: has_layer, - run: |s, p| { - let id = layer_id(s, p)?; - vector_mask_info(s, id) - }, - journal: false, - }, - ] -} - -#[cfg(test)] -mod tests;