diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index cbbbe069a5..75d52b5786 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -27,7 +27,7 @@ next to them. | `crates/ui-egui/src/i18n/sr.tsv` | Serbian (Latin, Ekavian) UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `sampler.svg`, `artboard.svg`, `perspective-crop.svg`, `frame-placeholder.svg`, `ellipse-dashed.svg`, `gradient.svg`, `type-mask.svg`, `type-mask-vertical.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `quick-selection.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `convert-point.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `pen-freeform.svg`, `pen-curvature.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg`, `path-op-*.svg` and `marquee-{row,column}.svg` (113 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-left,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/sampler.svg` | Color Sampler tool glyph: a target reticle marking a sampled pixel | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | @@ -35,6 +35,7 @@ next to them. | `assets/icons/artboard.svg` | Artboard tool glyph: a board with a tick at each end of its edges | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/perspective-crop.svg` | Perspective Crop tool glyph: a crop frame in perspective with its grid and crop-mark ticks | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/type-mask.svg`, `assets/icons/type-mask-vertical.svg` | Horizontal and Vertical Type Mask tool glyphs: a T drawn as marching ants (the vertical one beside a downward arrow), on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | +| `assets/icons/path-op-{new-layer,combine,subtract,intersect,exclude,merge}.svg` | Path Operations glyphs (Pen options bar): a single square for New Layer, and two overlapping squares showing the area each operation keeps (union, back minus front, overlap, everything but the overlap, the merged outline), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | diff --git a/assets/icons/path-op-combine.svg b/assets/icons/path-op-combine.svg new file mode 100644 index 0000000000..4c60a71e25 --- /dev/null +++ b/assets/icons/path-op-combine.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-exclude.svg b/assets/icons/path-op-exclude.svg new file mode 100644 index 0000000000..e2bea5b413 --- /dev/null +++ b/assets/icons/path-op-exclude.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-intersect.svg b/assets/icons/path-op-intersect.svg new file mode 100644 index 0000000000..a6bca9787e --- /dev/null +++ b/assets/icons/path-op-intersect.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/assets/icons/path-op-merge.svg b/assets/icons/path-op-merge.svg new file mode 100644 index 0000000000..32dbe69ffe --- /dev/null +++ b/assets/icons/path-op-merge.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-new-layer.svg b/assets/icons/path-op-new-layer.svg new file mode 100644 index 0000000000..21b26eac87 --- /dev/null +++ b/assets/icons/path-op-new-layer.svg @@ -0,0 +1,13 @@ + + + diff --git a/assets/icons/path-op-subtract.svg b/assets/icons/path-op-subtract.svg new file mode 100644 index 0000000000..c461ac44ad --- /dev/null +++ b/assets/icons/path-op-subtract.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/automation/src/server.rs b/crates/automation/src/server.rs index f52376a9c5..5e94c12cb8 100644 --- a/crates/automation/src/server.rs +++ b/crates/automation/src/server.rs @@ -321,7 +321,9 @@ pub struct UiSetParams { /// cropOverlayOrientation (0..3), cropShield (an object patching show_cropped_area, enabled, /// color ("matchCanvas" or "custom"), custom_color ([r, g, b]), opacity (0..100), /// auto_adjust, classic_mode and auto_center_preview), cropFill (background or contentAware, - /// the Crop tool's Fill), shapeStroke (an object patching width, align, cap, join, miterLimit, + /// the Crop tool's Fill), penMode (path or shape, the Pen tools' mode), penPathOp (newLayer, + /// combine, subtract, intersect or exclude: how the next Pen path joins the selected shape + /// layer or path), shapeStroke (an object patching width, align, cap, join, miterLimit, /// dashes, dashOffset and opacity; null disables the stroke). Shape stroke fields use /// `shape.edit` names and units; colour comes from `tools.setColors`. camTransformOnDrop (bool, /// the Content-Aware Move tool's Transform On Drop). tileGrid (bool, a developer overlay of the diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index 77f728ce04..9d85ac58b6 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -910,6 +910,7 @@ mod tests { | "path.convertPoint" | "path.addAnchor" | "path.deleteAnchor" + | "path.mergeShapeComponents" | "path.clippingPath.set" | "path.rename" | "path.stroke" diff --git a/crates/engine/src/path_edit_cmds.rs b/crates/engine/src/path_edit_cmds.rs index 503ac8efae..090f4e74c6 100644 --- a/crates/engine/src/path_edit_cmds.rs +++ b/crates/engine/src/path_edit_cmds.rs @@ -148,6 +148,20 @@ fn delete_anchor(s: &mut Session, p: &Value) -> Result { Ok(r) } +/// Merge Shape Components on a path (the Pen's and Path Selection's Path Operations menu): the +/// components' operations are baked into plain combined outlines, as for a shape layer. +fn merge_components(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "path.mergeShapeComponents"; + let tol = match p.get("tolerance") { + Some(v) => v.as_f64().filter(|t| t.is_finite()).ok_or_else(|| bad(CMD, "`tolerance` must be a number"))?.clamp(0.05, 10.0), + None => 0.1, + }; + edit_path(s, CMD, p, "Merge Shape Components", |path| { + *path = crate::vector_cmds::bake_path(path, tol).map_err(|e| e.to_string())?; + Ok(()) + }) +} + pub fn specs() -> Vec { const TARGET: &str = r#""name":str|"work"|"layer"="work","layer":id? (with "layer": a shape layer's path or a vector mask)"#; let spec = |id: &'static str, label: &'static str, params: String, run: fn(&mut Session, &Value) -> Result| CommandSpec { @@ -192,6 +206,12 @@ pub fn specs() -> Vec { r#""subpath":i,"knot":j (its neighbours are joined, keeping their handles; a subpath's last anchor removes the subpath; the path's only anchor is refused) (the result also has subpathRemoved:bool)"#.into(), delete_anchor, ), + spec( + "path.mergeShapeComponents", + "Merge Shape Components", + r#""tolerance":px=0.1 (bakes the components' path operations into plain combined outlines, traced from the exact coverage)"#.into(), + merge_components, + ), ] } @@ -395,6 +415,54 @@ mod tests { assert_eq!(s.active().unwrap().history.entries().len(), steps); } + /// Coverage of `path` at the pixel `(x, y)`. + fn covered(path: &photocraft_doc::Path, x: i32, y: i32) -> f32 { + photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] + } + + /// A 10..60 square with a front 25..45 square cut out of it (Subtract Front Shape), then + /// Merge Shape Components: plain combined outlines covering the same area, in one step. + #[test] + fn merge_shape_components_bakes_a_paths_operations() { + let mut s = session(); + s.execute("path.set", json!({"path": square()})).unwrap(); + let hole = json!({"subpaths": [{"closed": true, "knots": [[25, 25], [45, 25], [45, 45], [25, 45]]}]}); + s.execute("path.set", json!({"path": hole, "op": "subtract"})).unwrap(); + let before = work(&s); + let r = s.execute("path.mergeShapeComponents", json!({})).unwrap(); + assert_eq!(r["name"], json!("work")); + assert_eq!(s.active().unwrap().history.undo_label(), Some("Merge Shape Components")); + let merged = work(&s); + assert_eq!(merged.subpaths.len(), 2, "the outline and its hole"); + for (x, y) in [(15, 15), (35, 35), (55, 50), (5, 5)] { + assert!((covered(&merged, x, y) - covered(&before, x, y)).abs() < 0.01, "({x}, {y})"); + } + assert_eq!((covered(&merged, 35, 35), covered(&merged, 15, 15)), (0.0, 1.0), "the hole stays a hole"); + assert!(s.undo()); + assert_eq!(work(&s), before); + // A saved path and a vector mask merge the same way; an inverted mask stays inverted. + s.execute("path.set", json!({"name": "Mug", "path": square()})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "Mug"})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap().0; + s.execute("layer.vectorMask.add", json!({"layer": id, "path": square()})).unwrap(); + s.execute("layer.vectorMask.edit", json!({"layer": id, "path": {"subpaths": square()["subpaths"], "inverted": true}})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap(); + let vm = s.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + assert!(vm.path.inverted && !vm.path.subpaths.is_empty()); + } + + #[test] + fn merge_shape_components_on_a_shape_layer_rerenders_it() { + let mut s = session(); + let id = s.execute("shape.create", json!({"kind": "rect", "rect": [10, 10, 50, 50], "fill": "#ff0000"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("shape.create", json!({"kind": "rect", "rect": [25, 25, 20, 20], "addTo": id, "op": "subtract"})).unwrap(); + s.execute("path.mergeShapeComponents", json!({"name": "layer", "layer": id})).unwrap(); + let st = s.active().unwrap(); + let photocraft_doc::LayerContent::Shape(ShapeLayer { path, cache, .. }) = &st.doc.layer(LayerId(id)).unwrap().content else { panic!("shape") }; + assert_eq!(covered(path, 35, 35), 0.0); + assert!(cache.is_some()); + } + #[test] fn bad_params_fail_without_an_edit() { let mut s = session(); @@ -428,6 +496,9 @@ mod tests { ("path.deleteAnchor", json!({"subpath": 0, "knot": -1})), ("path.deleteAnchor", json!({"subpath": 0})), ("path.deleteAnchor", json!({"name": "layer", "subpath": 0, "knot": 0})), + ("path.mergeShapeComponents", json!({"tolerance": "fine"})), + ("path.mergeShapeComponents", json!({"name": "nope"})), + ("path.mergeShapeComponents", json!({"name": "layer"})), ] { assert!(s.execute(id, p.clone()).is_err(), "{id} {p}"); } diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 15c14e5a1c..8d9ca458e8 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1552,13 +1552,22 @@ fn merge_components(s: &mut Session, p: &Value) -> Result { /// Bakes `sh`'s path operations into plain combined outlines, traced from the exact coverage. fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { + sh.path = bake_path(&sh.path, tol)?; + sh.live = None; + sh.psd_raw = None; + Ok(()) +} + +/// `path` with its path operations baked into plain combined outlines, traced from the exact +/// coverage within `tol` px (Merge Shape Components). An empty area gives an empty path; an +/// inverted path (a vector mask's) stays inverted around the merged area. +pub(crate) fn bake_path(path: &Path, 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 up = Path { inverted: false, ..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(()); + return Ok(Path { subpaths: Vec::new(), ..path.clone() }); }; let b = b.inflate(2); if u64::from(b.width()) * u64::from(b.height()) > 400_000_000 { @@ -1566,10 +1575,7 @@ fn bake_components(sh: &mut ShapeLayer, tol: f64) -> Result<()> { } 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(()) + Ok(Path { inverted: path.inverted, ..traced.transform(&Affine::scale(1.0 / K)) }) } /// The shape layers Layer › Combine Shapes merges into one: with none of the `narrow` params diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 581925f83c..d5154c057f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -131,7 +131,7 @@ pub enum Outcome { /// field's value is validated before the first one is applied, so a typo, an unknown field, a /// bad value or a bad nested key can't reply with success while nothing — or only half of it — /// changed (#412). -pub const UI_SET_FIELDS: [&str; 38] = [ +pub const UI_SET_FIELDS: [&str; 40] = [ "tool", "strokeTrace", "panels", @@ -166,6 +166,8 @@ pub const UI_SET_FIELDS: [&str; 38] = [ "cropOverlayOrientation", "cropShield", "cropFill", + "penMode", + "penPathOp", "shapeStroke", "camTransformOnDrop", "tileGrid", @@ -558,6 +560,20 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) }), None => None, }; + // The Pen tools' options bar (#1397): Path or Shape mode, and the Path Operation. + let pen_mode = match p.get("penMode") { + Some(v) => Some(v.as_str().filter(|m| matches!(*m, "path" | "shape")).ok_or("penMode must be \"path\" or \"shape\"")?.to_string()), + None => None, + }; + let pen_path_op = match p.get("penPathOp") { + Some(v) => Some( + v.as_str() + .filter(|o| crate::vector_ui::PATH_OPS.iter().any(|(id, ..)| id == o)) + .ok_or("penPathOp must be one of newLayer, combine, subtract, intersect, exclude")? + .to_string(), + ), + None => None, + }; // The Crop tool's gear menu: Show Cropped Area and the shield (#1919). let crop_shield = merged_object(&app.ui.tool_options.crop_shield, p.get("cropShield"), "cropShield")?; if crop_shield.as_ref().is_some_and(|s| !(0.0..=100.0).contains(&s.opacity)) { @@ -710,6 +726,12 @@ fn dispatch(app: &mut PhotocraftApp, ctx: &egui::Context, req: &ControlRequest) if let Some(on) = crop_content_aware { app.ui.tool_options.crop_content_aware = on; } + if let Some(m) = pen_mode { + app.ui.tool_options.vector_mode = m; + } + if let Some(o) = pen_path_op { + app.ui.tool_options.pen_path_op = o; + } if let Some(v) = crop_overlay_show { app.ui.tool_options.crop_overlay_show = v; } @@ -2104,6 +2126,20 @@ mod tests { assert!(!app.ui.tool_options.crop_content_aware); } + /// #1397: the Pen's mode and Path Operation over the control channel, reported by `ui.inspect`. + #[test] + fn ui_set_drives_the_pen_path_operation() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + assert_eq!(call(&mut app, &ctx, "ui.set", json!({"tool": "pen", "penMode": "shape", "penPathOp": "subtract"}))["ok"], true); + let o = &call(&mut app, &ctx, "ui.inspect", json!({}))["result"]["toolOptions"]; + assert_eq!((o["vector_mode"].clone(), o["pen_path_op"].clone()), (json!("shape"), json!("subtract"))); + for bad in [json!({"penPathOp": "melt"}), json!({"penPathOp": 1}), json!({"penMode": "pixels"}), json!({"penMode": "path", "penPathOp": "xor"})] { + assert_eq!(call(&mut app, &ctx, "ui.set", bad.clone())["ok"], false, "{bad}"); + assert_eq!((app.ui.tool_options.vector_mode.as_str(), app.ui.tool_options.pen_path_op.as_str()), ("shape", "subtract"), "{bad}: unchanged"); + } + } + /// #1919: the Crop tool's overlay menu over the control channel, reported by `ui.inspect`. #[test] fn ui_set_drives_the_crop_overlay_options() { diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 6175183cb5..0a4a6574f3 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -453,6 +453,7 @@ Intersect Shape Areas Průnik oblastí tvarů Exclude Overlapping Shapes Vyloučit překrývající se tvary Merge Shape Components Sloučit součásti tvaru + Path operations Operace s cestami Align Zarovnat Top Edges Horní okraje Vertical Centers Svislé středy diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 5fbbceb440..bb9ef44566 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -452,6 +452,7 @@ Intersect Shape Areas Schnittmenge der Formen Exclude Overlapping Shapes Überlappende Formen ausschließen Merge Shape Components Formkomponenten zusammenfügen + Path operations Pfadvorgänge Align Ausrichten Top Edges Obere Kanten Vertical Centers Vertikale Mitten diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index d21c3ba6e4..5494f054ca 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1904,6 +1904,7 @@ Intersect Shape Areas Τομή περιοχών σχημάτων Exclude Overlapping Shapes Εξαίρεση επικαλυπτόμενων σχημάτων Merge Shape Components Συγχώνευση συστατικών σχήματος + Path operations Λειτουργίες διαδρομών Top Edges Επάνω ακμές Vertical Centers Κατακόρυφα κέντρα Bottom Edges Κάτω ακμές diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 4956fcd97b..001d62d237 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -449,6 +449,7 @@ Intersect Shape Areas Intersecar áreas de formas Exclude Overlapping Shapes Excluir formas superpuestas Merge Shape Components Combinar componentes de formas + Path operations Operaciones de trazado Align Alinear Top Edges Bordes superiores Vertical Centers Centros verticales diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 62b2e8ea26..f80c59419c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -437,6 +437,7 @@ Intersect Shape Areas Intersection des zones de formes Exclude Overlapping Shapes Exclure les formes superposées Merge Shape Components Fusionner les composants de forme + Path operations Opérations sur les tracés Align Aligner Top Edges Bords supérieurs Vertical Centers Centres verticaux diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index a52cb5fdba..525c4347f8 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -355,6 +355,7 @@ Median Medián Merge Layers Rétegek egyesítése Merge Shape Components Alakzatösszetevők egyesítése + Path operations Útvonalműveletek Merge Visible Láthatók egyesítése Modify Módosítás New Adjustment Layer Új korrekciós réteg diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 14e65b2b72..658eb5db64 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -430,6 +430,7 @@ Intersect Shape Areas Irisan Area Bentuk Exclude Overlapping Shapes Kecualikan Bentuk yang Tumpang Tindih Merge Shape Components Gabungkan Komponen Bentuk + Path operations Operasi path Align Rata Top Edges Tepi Atas Vertical Centers Tengah Vertikal diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 857cc66d96..98e5e15607 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -445,6 +445,7 @@ Intersect Shape Areas Interseca aree forme Exclude Overlapping Shapes Escludi forme sovrapposte Merge Shape Components Unisci componenti forma + Path operations Operazioni sui tracciati Align Allinea Top Edges Bordi superiori Vertical Centers Centri verticali diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index b14f5c1192..186e22465a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -447,6 +447,7 @@ Intersect Shape Areas シェイプの重なりを抽出 Exclude Overlapping Shapes シェイプの重なりを除外 Merge Shape Components シェイプの構成要素を結合 + Path operations パスの操作 Align 整列 Top Edges 上端 Vertical Centers 垂直方向の中央 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index d5d58b2ff2..8a9bce0110 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -434,6 +434,7 @@ Intersect Shape Areas 모양 영역 교차 Exclude Overlapping Shapes 겹치는 모양 제외 Merge Shape Components 모양 구성 요소 병합 + Path operations 패스 작업 Align 정렬 Top Edges 위쪽 가장자리 Vertical Centers 세로 중앙 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 394aaecbbb..4e15d5405b 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -438,6 +438,7 @@ Intersect Shape Areas Overlappende vormgebieden Exclude Overlapping Shapes Overlappende vormen uitsluiten Merge Shape Components Vormcomponenten samenvoegen + Path operations Padbewerkingen Align Uitlijnen Top Edges Bovenranden Vertical Centers Verticale middens diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 660e93bbb7..d788613c7e 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -445,6 +445,7 @@ Intersect Shape Areas Część wspólna obszarów kształtów Exclude Overlapping Shapes Wyklucz nakładające się kształty Merge Shape Components Scal składniki kształtu + Path operations Operacje na ścieżkach Align Wyrównaj Top Edges Górne krawędzie Vertical Centers Środki w pionie diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 338c4fadb7..617852d524 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -432,6 +432,7 @@ Intersect Shape Areas Fazer interseção das áreas de formas Exclude Overlapping Shapes Excluir formas sobrepostas Merge Shape Components Mesclar componentes de formas + Path operations Operações de demarcador Align Alinhar Top Edges Bordas superiores Vertical Centers Centros verticais diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 11fd43811a..8a812ba7a8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1954,6 +1954,7 @@ Merge Group Объединить группу Merge Layers Объединить слои Merge Shape Components Объединить компоненты фигуры + Path operations Операции с контурами Merge Visible Свести видимые Stamp Visible Штамп видимых Select Layer Above Выбрать слой выше diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 25175aeff2..14d9080474 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -427,6 +427,7 @@ Intersect Shape Areas Presek oblasti oblika Exclude Overlapping Shapes Isključi preklapajuće oblike Merge Shape Components Spoji komponente oblika + Path operations Operacije s putanjama Align Poravnaj Top Edges Gornje ivice Vertical Centers Vertikalni centri diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 6af5510125..04886acc8c 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -428,6 +428,7 @@ Intersect Shape Areas Şekil Alanlarını Kesiş Exclude Overlapping Shapes Çakışan Şekilleri Hariç Tut Merge Shape Components Şekil Bileşenlerini Birleştir + Path operations Yol işlemleri Align Hizala Top Edges Üst Kenarlar Vertical Centers Dikey Merkezler diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 37851e90f3..07287f1520 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1930,6 +1930,7 @@ Merge Group Об'єднати групу Merge Layers Об'єднати шари Merge Shape Components Об'єднати компоненти фігури + Path operations Операції з контурами Merge Visible Об'єднати видимі Stamp Visible Створити об'єднану копію видимих Select Layer Above Вибрати шар вище diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index fd7950b930..d152589184 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -330,6 +330,7 @@ Median Trung vị Merge Layers Gộp lớp Merge Shape Components Gộp thành phần hình + Path operations Thao tác đường dẫn Merge Visible Gộp các lớp hiển thị Minimum Cực tiểu Mode Chế độ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 0f242f8f63..64f8035e3c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -452,6 +452,7 @@ Intersect Shape Areas 形状区域相交 Exclude Overlapping Shapes 排除重叠形状 Merge Shape Components 合并形状组件 + Path operations 路径操作 Align 对齐 Top Edges 顶边 Vertical Centers 垂直居中 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 621bee5518..91d9152cd4 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -457,6 +457,7 @@ Intersect Shape Areas 交集形狀區域 Exclude Overlapping Shapes 排除重疊形狀 Merge Shape Components 合併形狀元件 + Path operations 路徑操作 Align 對齊 Top Edges 頂端邊緣 Vertical Centers 垂直置中 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 7fdaf4f7d0..5cccf74ac2 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -90,6 +90,12 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("palette", include_bytes!("../../../assets/icons/palette.svg")), ("panel-right", include_bytes!("../../../assets/icons/panel-right.svg")), ("panels-top-left", include_bytes!("../../../assets/icons/panels-top-left.svg")), + ("path-op-combine", include_bytes!("../../../assets/icons/path-op-combine.svg")), + ("path-op-exclude", include_bytes!("../../../assets/icons/path-op-exclude.svg")), + ("path-op-intersect", include_bytes!("../../../assets/icons/path-op-intersect.svg")), + ("path-op-merge", include_bytes!("../../../assets/icons/path-op-merge.svg")), + ("path-op-new-layer", include_bytes!("../../../assets/icons/path-op-new-layer.svg")), + ("path-op-subtract", include_bytes!("../../../assets/icons/path-op-subtract.svg")), ("pen-curvature", include_bytes!("../../../assets/icons/pen-curvature.svg")), ("pen-freeform", include_bytes!("../../../assets/icons/pen-freeform.svg")), ("pen-add-anchor", include_bytes!("../../../assets/icons/pen-add-anchor.svg")), diff --git a/crates/ui-egui/src/pen_path_ops_tests.rs b/crates/ui-egui/src/pen_path_ops_tests.rs new file mode 100644 index 0000000000..8ba3002d58 --- /dev/null +++ b/crates/ui-egui/src/pen_path_ops_tests.rs @@ -0,0 +1,210 @@ +//! The Pen's Path Operations (#1397, #7533): New Layer, Combine Shapes, Subtract Front Shape, +//! Intersect Shape Areas and Exclude Overlapping Shapes decide how a new path joins the shape or +//! path it is drawn into, and Merge Shape Components bakes them, as in Photoshop's options bar. + +use egui::vec2; +use egui_kittest::kittest::{NodeT, Queryable}; +use photocraft_doc::vector::PathOp; +use photocraft_doc::{LayerContent, LayerId, Path}; +use serde_json::json; + +use super::{pen_down, pen_path_op, pen_up}; +use crate::PhotocraftApp; +use crate::state::Tool; + +fn app() -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 200, "height": 200})).unwrap(); + app.ui.tool = Tool::Pen; + app +} + +/// Clicks a closed square with the Pen, from `(x, y)` with side `side`. +fn square(app: &mut PhotocraftApp, x: f64, y: f64, side: f64) { + for (dx, dy) in [(0.0, 0.0), (side, 0.0), (side, side), (0.0, side)] { + pen_down(app, x + dx, y + dy, false); + pen_up(app); + } + pen_down(app, x + 0.5, y + 0.5, false); // close + pen_up(app); +} + +fn shape_path(app: &PhotocraftApp) -> (LayerId, Path) { + let st = app.session.active().unwrap(); + let id = st.active_layer.unwrap(); + match &st.doc.layer(id).unwrap().content { + LayerContent::Shape(sh) => (id, sh.path.clone()), + _ => panic!("not a shape layer"), + } +} + +fn work(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().expect("a work path") +} + +fn layers(app: &PhotocraftApp) -> usize { + app.session.active().unwrap().doc.layers.len() +} + +fn covered(path: &Path, x: i32, y: i32) -> f32 { + photocraft_vector::fill_rasterizer(path, photocraft_vector::DEFAULT_TOLERANCE).render(photocraft_geom::Rect::from_xywh(x, y, 1, 1))[0] +} + +/// The issue's mug: the body is drawn, then the hole in its handle is cut out of the same shape +/// layer with Subtract Front Shape, one History step each. +#[test] +fn subtract_front_shape_cuts_a_hole_in_the_selected_shape_layer() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + assert_eq!(app.ui.tool_options.pen_path_op, "newLayer", "Photoshop's default in Shape mode"); + square(&mut app, 20.0, 20.0, 100.0); + let (id, _) = shape_path(&app); + let count = layers(&app); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 50.0, 50.0, 40.0); + let (same, path) = shape_path(&app); + assert_eq!((same, layers(&app)), (id, count), "no new layer"); + assert_eq!(path.subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Subtract]); + assert_eq!((covered(&path, 70, 70), covered(&path, 30, 30)), (0.0, 1.0), "a hole in the body"); + app.session.undo(); + assert_eq!(shape_path(&app).1.subpaths.len(), 1, "undo takes back only the hole"); + app.session.redo(); + // Intersect and Exclude are stored the same way. + for (op, want) in [("intersect", PathOp::Intersect), ("exclude", PathOp::Exclude), ("combine", PathOp::Combine)] { + app.ui.tool_options.pen_path_op = op.into(); + square(&mut app, 10.0, 10.0, 30.0); + assert_eq!(shape_path(&app).1.subpaths.last().map(|s| s.op), Some(want), "{op}"); + } + assert_eq!(layers(&app), count); + // New Layer makes another shape layer again. + app.ui.tool_options.pen_path_op = "newLayer".into(); + square(&mut app, 10.0, 10.0, 30.0); + assert_eq!(layers(&app), count + 1); + assert_ne!(shape_path(&app).0, id); +} + +/// With no shape layer selected, an operation other than New Layer still starts a shape layer +/// (Photoshop: there is nothing to combine with), and the next path joins it. +#[test] +fn an_operation_without_a_selected_shape_starts_a_new_shape_layer() { + let mut app = app(); + app.ui.tool_options.vector_mode = "shape".into(); + app.ui.tool_options.pen_path_op = "subtract".into(); + let count = layers(&app); + square(&mut app, 20.0, 20.0, 100.0); + let (id, path) = shape_path(&app); + assert_eq!((layers(&app), path.subpaths.len()), (count + 1, 1)); + assert_eq!(covered(&path, 60, 60), 1.0, "a lone first component draws"); + square(&mut app, 50.0, 50.0, 40.0); + assert_eq!((shape_path(&app).0, layers(&app)), (id, count + 1)); +} + +/// Path mode has no New Layer (it combines); the other operations are stored on each subpath +/// added to the Work Path, a selected saved path or a targeted vector mask. +#[test] +fn path_mode_adds_subpaths_with_the_operation() { + let mut app = app(); + assert_eq!(pen_path_op(&app.ui.tool_options), Some("combine")); + square(&mut app, 20.0, 20.0, 100.0); + square(&mut app, 30.0, 30.0, 10.0); + app.ui.tool_options.pen_path_op = "intersect".into(); + square(&mut app, 50.0, 50.0, 100.0); + assert_eq!(work(&app).subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Combine, PathOp::Intersect]); + // A selected saved path. + app.run("path.set", json!({"name": "Saved", "path": {"subpaths": [{"closed": true, "knots": [[2, 2], [60, 2], [60, 60]]}]}})).unwrap(); + app.ui.selected_path = Some("Saved".into()); + app.ui.tool_options.pen_path_op = "exclude".into(); + square(&mut app, 10.0, 10.0, 20.0); + let saved = app.session.active().unwrap().doc.paths.iter().find(|p| p.name == "Saved").unwrap().path.clone(); + assert_eq!(saved.subpaths.last().map(|s| s.op), Some(PathOp::Exclude)); + // A targeted vector mask. + let id = app.session.active().unwrap().active_layer.unwrap().0; + app.run("layer.vectorMask.add", json!({"layer": id, "path": {"subpaths": [{"closed": true, "knots": [[0, 0], [100, 0], [100, 100], [0, 100]]}]}})).unwrap(); + app.ui.vector_mask_target = true; + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 20.0, 20.0, 30.0); + let vm = app.session.active().unwrap().doc.layer(LayerId(id)).unwrap().vector_mask.clone().unwrap(); + assert_eq!(vm.path.subpaths.iter().map(|s| s.op).collect::>(), [PathOp::Combine, PathOp::Subtract]); +} + +/// The options bar's Path Operations button sets the operation, offers New Layer only in Shape +/// mode, and its Merge Shape Components bakes the path the Pen draws into. +#[test] +fn the_options_bar_menu_sets_the_operation_and_merges() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 100.0); + app.ui.tool_options.pen_path_op = "subtract".into(); + square(&mut app, 50.0, 50.0, 40.0); + app.ui.tool_options.pen_path_op = "combine".into(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + let open = |h: &mut egui_kittest::Harness<'_, PhotocraftApp>| { + h.get_by_label("Path operations").click(); + h.run_steps(3); + }; + open(&mut h); + assert!(h.get_by_label("New Layer").accesskit_node().is_disabled(), "Path mode has no New Layer"); + h.get_by_label("Exclude Overlapping Shapes").click(); + h.run_steps(3); + assert_eq!(h.state().ui.tool_options.pen_path_op, "exclude"); + assert!(h.query_by_label("Exclude Overlapping Shapes").is_none(), "choosing closes the menu"); + // Merge Shape Components: the Work Path's hole stays a hole, now as plain outlines. + let before = work(h.state()); + open(&mut h); + h.get_by_label("Merge Shape Components").click(); + h.run_steps(3); + let merged = work(h.state()); + assert_ne!(merged, before); + assert_eq!((covered(&merged, 70, 70), covered(&merged, 30, 30)), (0.0, 1.0)); + // Shape mode offers New Layer. + h.state_mut().ui.tool_options.vector_mode = "shape".into(); + h.run_steps(2); + open(&mut h); + assert!(!h.get_by_label("New Layer").accesskit_node().is_disabled()); + h.get_by_label("New Layer").click(); + h.run_steps(3); + assert_eq!(pen_path_op(&h.state().ui.tool_options), None); +} + +/// Merge Shape Components is disabled where there is nothing to merge: one component, or in Shape +/// mode no shape layer selected. +#[test] +fn merge_is_disabled_with_fewer_than_two_components() { + let mut app = app(); + square(&mut app, 20.0, 20.0, 100.0); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1100.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + ui.horizontal(|ui| super::options_bar(app, ui, Tool::Pen)); + } + }, + app, + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + h.get_by_label("Path operations").click(); + h.run_steps(3); + assert!(h.get_by_label("Merge Shape Components").accesskit_node().is_disabled()); +} + +/// An unknown stored operation (an edited settings file) falls back to New Layer, never panics. +#[test] +fn an_unknown_operation_falls_back_to_new_layer() { + let mut app = app(); + app.ui.tool_options.pen_path_op = "melt".into(); + assert_eq!(pen_path_op(&app.ui.tool_options), Some("combine")); + app.ui.tool_options.vector_mode = "shape".into(); + assert_eq!(pen_path_op(&app.ui.tool_options), None); + let count = layers(&app); + square(&mut app, 20.0, 20.0, 50.0); + square(&mut app, 30.0, 30.0, 50.0); + assert_eq!(layers(&app), count + 2); +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d464cb3cc8..85f4f67786 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -580,6 +580,11 @@ pub struct ToolOptions { pub enhance_edge: bool, /// Pen: "path" (work path) or "shape" (shape layer). pub vector_mode: String, + /// Pen, Freeform Pen and Curvature Pen › Path Operations (#1397): how a new path combines with + /// the path or shape it is drawn into: "newLayer" (Shape mode: a new shape layer; Path mode + /// draws it as "combine"), "combine", "subtract", "intersect" or "exclude". + #[serde(default = "default_pen_path_op")] + pub pen_path_op: String, /// Shape tools: fill with the foreground colour, stroke width (0 = none), rectangle corner /// radius, polygon sides, line weight. pub shape_fill: bool, @@ -724,6 +729,10 @@ fn default_move_target() -> String { "group".into() } +fn default_pen_path_op() -> String { + "newLayer".into() +} + fn default_curve_fit() -> f32 { 2.0 } @@ -809,6 +818,7 @@ impl Default for ToolOptions { finger_painting: false, enhance_edge: false, vector_mode: "path".into(), + pen_path_op: default_pen_path_op(), shape_fill: true, stroke_width: 0.0, shape_stroke: Default::default(), diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index 9fa9169f87..0326035eea 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -189,9 +189,9 @@ pub fn option_keys(tool: Tool) -> &'static [&'static str] { Tool::Smudge => reset!(strength, sample_all_layers, finger_painting), Tool::Dodge | Tool::Burn => reset!(tone_range, exposure, protect_tones), Tool::Sponge => reset!(sponge_mode, vibrance), - Tool::Pen => reset!(vector_mode, pen_auto_add_delete), - Tool::CurvaturePen => reset!(vector_mode), - Tool::FreeformPen => reset!(vector_mode, curve_fit), + Tool::Pen => reset!(vector_mode, pen_path_op, pen_auto_add_delete), + Tool::CurvaturePen => reset!(vector_mode, pen_path_op), + Tool::FreeformPen => reset!(vector_mode, pen_path_op, curve_fit), Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => { reset!(vector_mode, shape_fill, stroke_width, shape_stroke, corner_radius, polygon_sides, line_weight) } diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index 8b5e914af2..062a41e6dd 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -436,14 +436,21 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { commit_path(app, &pen, closed); } -/// Commit a finished Pen, Freeform Pen or Curvature Pen path, as one History step: a new shape -/// layer (Shape mode), else a subpath of the targeted vector mask, else the work path. +/// Commit a finished Pen, Freeform Pen or Curvature Pen path, as one History step: in Shape mode a +/// new shape layer, or with a Path Operation other than New Layer a subpath of the selected shape +/// layer; in Path mode a subpath of the targeted vector mask, else of the selected path or a new +/// work path. An added subpath takes the options bar's Path Operation (#1397). fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { if pen.knots.len() < 2 { return; } let path = pen_to_json(pen, closed); - let r = if app.ui.tool_options.vector_mode == "shape" { + let op = pen_path_op(&app.ui.tool_options); + let into_shape = op.and_then(|_| selected_shape(&app.session)).map(|s| s.layer.0); + let r = if let (Some(op), Some(layer), "shape") = (op, into_shape, app.ui.tool_options.vector_mode.as_str()) { + // The selected shape layer keeps its fill and stroke; the new component cuts, intersects… + app.run("shape.create", json!({"kind": "path", "path": path, "addTo": layer, "op": op})) + } else if app.ui.tool_options.vector_mode == "shape" { let fill = if closed { fill_param(app) } else { Value::Null }; let stroke = if closed { stroke_param(app) @@ -458,7 +465,10 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { // A targeted vector mask takes the new subpath (#196), as in Photoshop. let mut p = photocraft_engine::vector_cmds::path_json(&existing); if let (Some(subs), Some(new)) = (p.get_mut("subpaths").and_then(Value::as_array_mut), path.get("subpaths").and_then(Value::as_array)) { - subs.extend(new.iter().cloned()); + subs.extend(new.iter().cloned().map(|mut sp| { + sp["op"] = json!(op.unwrap_or("combine")); + sp + })); } app.run("layer.vectorMask.edit", json!({"layer": id, "path": p})) } else { @@ -469,7 +479,7 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { let name = target.clone().unwrap_or_else(|| "work".into()); let mut params = json!({"name": name, "path": path}); if target.is_some() { - params["op"] = json!("combine"); + params["op"] = json!(op.unwrap_or("combine")); } app.run("path.set", params).inspect(|_| app.ui.selected_path = Some(name)) }; @@ -484,6 +494,90 @@ fn path_exists(doc: &Document, name: &str) -> bool { if name == "work" { doc.work_path.is_some() } else { doc.paths.iter().any(|p| p.name == name) } } +/// The Pen's Path Operations (#1397), in Photoshop's menu order: the `pen_path_op` value, its +/// label and its icon. +pub(crate) const PATH_OPS: [(&str, &str, &str); 5] = [ + ("newLayer", "New Layer", "path-op-new-layer"), + ("combine", "Combine Shapes", "path-op-combine"), + ("subtract", "Subtract Front Shape", "path-op-subtract"), + ("intersect", "Intersect Shape Areas", "path-op-intersect"), + ("exclude", "Exclude Overlapping Shapes", "path-op-exclude"), +]; + +/// The operation (`path.set` / `shape.create` `op`) a new Pen path is added with; None for a new +/// shape layer (New Layer, in Shape mode). Path mode has no New Layer: there it combines. +pub(crate) fn pen_path_op(o: &ToolOptions) -> Option<&'static str> { + let op = PATH_OPS.iter().map(|(v, ..)| *v).find(|v| *v == o.pen_path_op).unwrap_or("newLayer"); + match op { + "newLayer" if o.vector_mode == "shape" => None, + "newLayer" => Some("combine"), + op => Some(op), + } +} + +/// What the Path Operations menu's Merge Shape Components merges, as a command, its params and +/// the number of shape components there: in Shape mode the selected shape layer; in Path mode +/// the path the Pen draws into (a targeted vector mask, the selected path, else the Work Path). +fn merge_target(app: &PhotocraftApp) -> Option<(&'static str, Value, usize)> { + let st = app.session.active()?; + if app.ui.tool_options.vector_mode == "shape" { + let id = st.active_layer?; + let LayerContent::Shape(sh) = &st.doc.layer(id)?.content else { return None }; + return Some(("layer.combineShapes.mergeShapeComponents", json!({"layer": id.0}), sh.path.components().len())); + } + if let Some((id, p)) = targeted_vector_mask(app) { + return Some(("path.mergeShapeComponents", json!({"name": "layer", "layer": id}), p.components().len())); + } + let name = app.ui.selected_path.clone().filter(|n| path_exists(&st.doc, n)).unwrap_or_else(|| "work".into()); + let path = if name == "work" { st.doc.work_path.as_ref() } else { st.doc.paths.iter().find(|p| p.name == name).map(|p| &p.path) }?; + Some(("path.mergeShapeComponents", json!({"name": name}), path.components().len())) +} + +/// The Path Operations button of the Pen, Freeform Pen and Curvature Pen (#1397), Photoshop's: +/// how the next path combines with the shape or path it is drawn into, and Merge Shape Components. +/// Its icon shows the current operation. +fn path_operations_menu(app: &mut PhotocraftApp, ui: &mut egui::Ui) { + let shape = app.ui.tool_options.vector_mode == "shape"; + let current = pen_path_op(&app.ui.tool_options).unwrap_or("newLayer"); + let icon = PATH_OPS.iter().find(|(v, ..)| *v == current).map_or("path-op-combine", |(.., i)| *i); + let resp = crate::icons::button_with_icon_size(ui, icon, 24.0, 16.0, false, tl!("Path operations")); + let resp = crate::brush_picker::named(resp, "Path operations"); + let merge = merge_target(app).filter(|(.., n)| *n > 1); + let mut run = None; + egui::Popup::menu(&resp).show(|ui| { + ui.set_min_width(240.0); + for (value, label, glyph) in PATH_OPS { + // New Layer makes a shape layer: Path mode has none. + if ui.add_enabled_ui(shape || value != "newLayer", |ui| path_op_button(ui, glyph, tl!(label), value == current)).inner.clicked() { + app.ui.tool_options.pen_path_op = value.into(); + ui.close(); + } + } + ui.separator(); + if ui.add_enabled_ui(merge.is_some(), |ui| path_op_button(ui, "path-op-merge", tl!("Merge Shape Components"), false)).inner.clicked() { + run = merge.clone(); + ui.close(); + } + }); + if let Some((id, params, _)) = run + && let Err(e) = app.run(id, params) + { + app.ui.status = e; + app.ui.status_error = true; + } +} + +/// A Path Operations menu row: its icon and label, highlighted when it is the current operation. +fn path_op_button(ui: &mut egui::Ui, glyph: &str, label: &str, checked: bool) -> egui::Response { + let icon = ui.id().with((glyph, "icon")); + let b = egui::Button::new((egui::Atom::custom(icon, vec2(16.0, 16.0)), label, egui::Atom::grow())).selected(checked).atom_ui(ui); + if let Some(r) = b.rect(icon) { + crate::icons::paint(ui, r, glyph, 16.0, Tokens::get(ui.ctx()).icon); + } + b.response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), checked, label)); + b.response +} + // --------------------------------------------------------------------------------------------- // Freeform Pen and Curvature Pen @@ -1143,6 +1237,9 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.stroke_width = enabled_stroke_width(o.stroke_width); } } + crate::widgets::vline(ui, 22.0); + path_operations_menu(app, ui); + let o = &mut app.ui.tool_options; if tool == Tool::Pen { crate::widgets::vline(ui, 22.0); crate::widgets::checkbox(ui, &mut o.pen_auto_add_delete, tl!("Auto Add/Delete")); @@ -2725,6 +2822,10 @@ mod path_selection_tests; #[path = "pen_draw_tests.rs"] mod pen_draw_tests; +#[cfg(test)] +#[path = "pen_path_ops_tests.rs"] +mod pen_path_ops_tests; + #[cfg(test)] #[path = "vector_paint_tests.rs"] mod paint_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index e6d5c9591d..cc8c882e86 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -58,6 +58,7 @@ The transport is `apps/photocraft/src/control_server.rs`, and the handlers are i - Each painting tool keeps its own brush, as in Photoshop: `tools.setBrush` and `ui.set {brushSize}` change the active tool's brush, and switching tools swaps in that tool's own (size, hardness, mode, opacity, flow, dynamics, smoothing). A tool used for the first time starts from the brush in hand. The colours, the erase flag and the stroke seed stay shared. So `ui.set {tool: "eraser", brushSize: 12}` sets the Eraser's size, and `ui.set {tool: "brush"}` afterwards brings the Brush's own size back. - `ui.set` also accepts the Eyedropper options bar (#1649): `eyedropperSampleSize` (`"point"` or 1, 3, 5, 11, 31, 51, 101: the side of the averaged square, shared by the Eyedropper, a painting tool's Alt-click eyedropper, the Curves/Color Picker eyedroppers and the Info panel), `eyedropperSample` (`current`, `currentAndBelow`, `all`, `allNoAdjustments`, `currentAndBelowNoAdjustments`) and `eyedropperRing` (boolean, Show Sampling Ring). `ui.inspect` reports them under `toolOptions` (`eyedropper_size`, `eyedropper_sample`, `eyedropper_ring`). The engine command `document.sampleColor {x, y, size, sampleLayer}` returns the colour such a sample picks. - `ui.set` also accepts the Crop tool's overlay menu (#1919): `cropOverlay` (`thirds`, `grid`, `diagonal`, `triangle`, `goldenRatio`, `goldenSpiral`), `cropOverlayShow` (`auto`: only while the crop box is dragged, Photoshop's default; `always`; `never`) and `cropOverlayOrientation` (0..3; Triangle has two orientations and Golden Spiral four, the others ignore it). `ui.inspect` reports them under `toolOptions` (`crop_overlay`, `crop_overlay_show`, `crop_overlay_orientation`). In the app, O cycles the overlay and ⇧O its orientation while the Crop tool shows a box. +- `ui.set` also accepts the Pen tools' options bar (#1397): `penMode` (`path` or `shape`) and `penPathOp`, the Path Operations menu (`newLayer`, `combine`, `subtract`, `intersect`, `exclude`): how the next Pen, Freeform Pen or Curvature Pen path joins the selected shape layer (Shape mode; `newLayer` makes a new one) or the targeted vector mask, selected path or Work Path (Path mode, where `newLayer` combines). `ui.inspect` reports them under `toolOptions` (`vector_mode`, `pen_path_op`). Merge Shape Components is the engine command `path.mergeShapeComponents` (work, saved or `layer` path) or `layer.combineShapes.mergeShapeComponents` (shape layers). - `ui.set` also accepts `cropFill` (`background`, Photoshop's Background (Default), or `contentAware`), the Crop tool's Fill dropdown (#3823): with `contentAware`, committing a turned or extending frame passes `contentAware: true` to `image.crop`, which fills the area the image doesn't cover from its surroundings. `ui.inspect` reports it as `toolOptions.crop_content_aware`. Both gradient modes use the current preset, including its opacity stops. Select `Foreground to Transparent` through `gradient.presets.select`, or pass custom `stops` and `transparency: [[0,100],[1,0]]` (location 0..1, opacity 0..100). Use `applyToLayer: false` to edit only the tool preset. Classic gradient drags dispatch `paint.gradient` without overriding them. - `ui.set` also accepts the Crop tool's gear menu (#1919) as `cropShield`, an object patching any of `show_cropped_area` (Show Cropped Area; off hides everything outside the box behind the canvas colour, H in the app), `enabled` (Enable Crop Shield), `color` (`matchCanvas`, the pasteboard colour, or `custom`), `custom_color` (`[r, g, b]`, 0..255), `opacity` (0..100, default 75), `auto_adjust` (Auto Adjust Opacity: half the opacity while the box is dragged), `classic_mode` (Use Classic Mode, default off: the box turns and moves over the image; off is Photoshop's default mode, where the box stays upright and the view turns and pans the image behind it while the Crop tool shows a frame, P in the app; in both modes picking the tool turns a rotated view upright about its centre, Classic Mode keeping the camera upright with the frame drawn turned, and the view stays upright after a commit or cancel; Classic Mode greys out Show Cropped Area, which then acts as on, and Auto Center Preview) and `auto_center_preview` (default on: in default mode a box being resized keeps its centre where it is on screen, also after a pan, a drawn one moves to the middle of the canvas, and X re-centres it; a move never re-centres the box). `ui.inspect` reports it as `toolOptions.crop_shield`. The pending frame (`crop_rect`, `crop_angle`: its turn on the image) and the `image.crop` a commit runs are the same in both modes. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 793f09840c..f84bab6603 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity: tools, handles, modifiers and feel -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-19 · **Change:** minor (Pen, Freeform Pen and Curvature Pen Path Operations menu, #1397; Pen Auto Add/Delete on the path being drawn, #6140; Show Transform Controls frames every selected layer; guide keys and clicks; menu bar from the keyboard, #4988; Crop tool Content-Aware Fill; on-canvas corner radius widgets, TOOL-214-12; Curvature Pen edits the active path; Clip Thumbnails to Layer Bounds; Pen ⇧ 45°; last selected tool restored at launch; Layers panel filter-type dropdown with Kind, Name and Mode, #5048; Free Transform box and marching ants follow the selection, #5297; History: the trash deletes the selected snapshot, rows drag onto the trash, #5324; smart filter reordering by drag; smart object perspective row corrected to done, #426; Free Transform H/V skew fields; toolbar tool shortcuts can be reassigned; ⇧-dragged guides land on ruler ticks; layer and selection keys; tool keys; spring-loaded tool letters; type formatting keys; Pen Auto Add/Delete, #4144; smart filter rows; stale missing-tool rows corrected: Color Replacement, Red Eye and the Add/Delete Anchor Point pair are live toolbar tools, Frame landed; no percentage changed) · **Target:** Adobe Photoshop 2026 How PhotoCraft's direct manipulation compares with Photoshop 2026 (27.11.0): the toolbox, the handles and modifier keys of each gesture, nudging, snapping, numeric precision, navigation, @@ -123,7 +123,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Handle on the last point, close-drag handle | partial | #1482 | | Pen Auto Add/Delete: click a segment of the active path, or of the path being drawn, to add, an anchor to delete; ⇧ suspends it | done | #4144, #6140 | | Curvature Pen edits the active path with no modifier: drag an anchor (the curve reflows), click a segment to add an anchor, double-click smooth ↔ corner; cursor badge + on segments, four arrows on anchors | done: `direct_select.rs` (`curvature_press`, `curvature_hover`), `path.moveAnchors`/`path.convertPoint` with `curvature`, `direct_select_tests.rs` | | -| Path operations in the options bar (combine, subtract, intersect, exclude) | partial | `layer.combineShapes.*`; Pen options #1397 | +| Path operations in the options bar (combine, subtract, intersect, exclude) | partial: the Pen, Freeform Pen and Curvature Pen have Photoshop's Path Operations menu (New Layer in Shape mode, Combine, Subtract Front Shape, Intersect, Exclude, Merge Shape Components) for the selected shape layer, a targeted vector mask, the selected path or the Work Path (`vector_ui.rs` `path_operations_menu`, `pen_path_ops_tests.rs`, `path.mergeShapeComponents`); the Shape tools and Path Selection don't have the menu yet | `layer.combineShapes.*`; #1397 | | On-canvas corner-radius handles on live shapes | done: every selected live rectangle shows a widget at each arc centre with Path Selection, the Rectangle tool and Move with Show Transform Controls; a drag rounds every corner of every selected rectangle to the same radius, ⌥ that corner on each, with the corner radius cursor and an `R:` readout, previewed and committed as one history step (`corner_radius.rs`, `corner_radius_tests.rs`) | TOOL-214-12 | | Shape stroke controls (dash, caps, joins, align) | done | TOOL-1053-1, [`shape-strokes.md`](shape-strokes.md) | | Rotated shape selection hints correct | partial | #2512 | @@ -227,6 +227,7 @@ Paragraph; the rest open as in-app floating windows. | Date | Change | Summary | |---|---|---| +| 2026-10-19 | minor | Pen, Freeform Pen and Curvature Pen Path Operations menu (#1397): the operation a new path is added with, and Merge Shape Components for paths (`path.mergeShapeComponents`); row stays partial until the Shape tools and Path Selection have it; no percentage changed | | 2026-10-13 | minor | Menu bar from the keyboard: Alt / F10 focus, Alt+letter, underlined mnemonics (#4988) | | 2026-10-13 | minor | Layers panel filter row: Kind is a real dropdown with Kind, Name and Mode filters, drivable by `ui.set {layerFilter}` (#5048); row added as partial | | 2026-10-13 | minor | Stale "missing tool" rows corrected against main: Color Replacement (`paint.colorReplacement`), Red Eye (`paint.redEye`) and Add/Delete Anchor Point (the P flyout cycle) are live toolbar tools, so only Art History Brush is missing; no percentage or estimate changed |