From c436aa7294671c608f274895f6c8ec334dce86bd Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 12:56:44 +0200 Subject: [PATCH] Tools: Freeform Pen and Curvature Pen (#1054) (#2856) The Pen flyout (P / Shift+P) now holds Pen, Freeform Pen and Curvature Pen. Freeform Pen: a drag draws freehand; on release the trace is fitted with cubic Beziers within the options bar's Curve Fit (default 2 px) by photocraft_vector::trace::fit_trace (Schneider, "An Algorithm for Automatically Fitting Digitized Curves", Graphics Gems 1990, the fitting Make Work Path already uses), keeping turns sharper than 60 degrees as corners. Releasing on the start point closes the path. Curvature Pen: each click adds a smooth point and the path runs through every point (Catmull-Rom style handles from photocraft_vector::edit::curvature_knots); dragging a point moves it, double-clicking toggles corner/smooth, clicking the first point closes, Enter/Esc finish and Backspace removes the last point, as with the Pen. Both commit through the Pen's path: a shape layer in Shape mode, else the targeted vector mask, else the work path, as one History step. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ATTRIBUTION.md | 3 +- README.md | 6 +- assets/icons/pen-curvature.svg | 21 +++ assets/icons/pen-freeform.svg | 19 ++ crates/ui-egui/src/anchor_tools_tests.rs | 6 +- crates/ui-egui/src/canvas.rs | 16 ++ crates/ui-egui/src/hold_keys.rs | 2 + crates/ui-egui/src/i18n/cs.tsv | 5 + crates/ui-egui/src/i18n/de.tsv | 5 + crates/ui-egui/src/i18n/el.tsv | 5 + crates/ui-egui/src/i18n/es.tsv | 5 + crates/ui-egui/src/i18n/fr.tsv | 5 + crates/ui-egui/src/i18n/id.tsv | 5 + crates/ui-egui/src/i18n/it.tsv | 5 + crates/ui-egui/src/i18n/ja.tsv | 5 + crates/ui-egui/src/i18n/ko.tsv | 5 + crates/ui-egui/src/i18n/nl.tsv | 5 + crates/ui-egui/src/i18n/pl.tsv | 5 + crates/ui-egui/src/i18n/pt-br.tsv | 5 + crates/ui-egui/src/i18n/ru.tsv | 5 + crates/ui-egui/src/i18n/uk.tsv | 5 + crates/ui-egui/src/i18n/zh-hans.tsv | 5 + crates/ui-egui/src/i18n/zh-hant.tsv | 5 + crates/ui-egui/src/icon_data.rs | 2 + crates/ui-egui/src/icons.rs | 2 + crates/ui-egui/src/lib.rs | 5 +- crates/ui-egui/src/mask_thumbs_ui/tests.rs | 7 +- crates/ui-egui/src/panels.rs | 2 +- crates/ui-egui/src/path_selection_tests.rs | 6 +- crates/ui-egui/src/pen_variants_tests.rs | 106 +++++++++++ crates/ui-egui/src/snap_ui.rs | 3 +- crates/ui-egui/src/state.rs | 20 ++- crates/ui-egui/src/tool_reset.rs | 3 +- crates/ui-egui/src/vector_ui.rs | 165 +++++++++++++++-- crates/vector/src/edit.rs | 47 +++++ crates/vector/src/trace.rs | 198 +++++++++++++++++++-- docs/gaps.md | 8 +- docs/target-app-parity.md | 6 +- docs/ui-parity.md | 2 +- 39 files changed, 681 insertions(+), 54 deletions(-) create mode 100644 assets/icons/pen-curvature.svg create mode 100644 assets/icons/pen-freeform.svg create mode 100644 crates/ui-egui/src/pen_variants_tests.rs diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index da46de9f83..8b657b8247 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,7 +20,7 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian 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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `pen-add-anchor.svg`, `pen-delete-anchor.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 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`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.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` (112 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-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/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 | @@ -30,6 +30,7 @@ next to them. | `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/pen-freeform.svg`, `assets/icons/pen-curvature.svg` | Freeform Pen and Curvature Pen tool glyphs: Lucide `pen-tool` scaled down, plus a freehand squiggle or a curve through two points | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/pen-add-anchor.svg`, `assets/icons/pen-delete-anchor.svg` | Add Anchor Point and Delete Anchor Point tool glyphs: Lucide `pen-tool` plus a + or − mark | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/pen-tool.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/symmetry-{vertical,horizontal,dual,diagonal}.svg` | Painting symmetry axis glyphs | @shorbank | Original work; supplied SVG line geometry centred on a common square canvas, stroke adjusted to match the painting-symmetry glyph | MIT OR Apache-2.0, [`assets/icons/LICENSE-symmetry-axes.txt`](assets/icons/LICENSE-symmetry-axes.txt) | diff --git a/README.md b/README.md index be6e1654c4..6ca860804f 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Every screenshot here is the real app at work on public-domain art, rendered off
A badge made of shape layers: a gradient-filled lotus, a star and a dotted ring.
Water Lilies, Claude Monet, 1906

Pixel-perfect vectors

- Rectangle, Ellipse, Triangle, Polygon, Line and the Pen tool, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes. + Rectangle, Ellipse, Triangle, Polygon, Line and the Pen, Freeform Pen and Curvature Pen tools, with resolution-independent shape layers, gradient fills, and dashed, aligned strokes.

Combine shapes (unite, subtract, intersect, exclude), keep paths in the Paths panel, use them as vector masks, or stroke and fill them. 116 of 116 shape layers in our PSD corpus match Photoshop's pixels. @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 58 tools

- Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom +

🧰 60 tools

+ Move · Rectangular and Elliptical Marquee · Single Row Marquee · Single Column Marquee · Lasso · Polygonal Lasso · Magnetic Lasso · Object Selection · Quick Selection · Magic Wand · Crop · Slice · Slice Select · Eyedropper · Ruler · Note · Count · Remove · Spot Healing Brush · Healing Brush · Patch · Content-Aware Move · Red Eye · Brush · Pencil · Mixer Brush · Color Replacement · Clone Stamp · Pattern Stamp · History Brush · Eraser · Background Eraser · Magic Eraser · Gradient · Paint Bucket · Blur · Sharpen · Smudge · Dodge · Burn · Sponge · Pen · Freeform Pen · Curvature Pen · Add Anchor Point · Delete Anchor Point · Horizontal and Vertical Type · Path Selection · Direct Selection · Rectangle · Ellipse · Triangle · Polygon · Line · Custom Shape · Hand · Rotate View · Zoom

🖌️ A real brush engine

diff --git a/assets/icons/pen-curvature.svg b/assets/icons/pen-curvature.svg new file mode 100644 index 0000000000..51ff1ff578 --- /dev/null +++ b/assets/icons/pen-curvature.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/assets/icons/pen-freeform.svg b/assets/icons/pen-freeform.svg new file mode 100644 index 0000000000..38ddb6bd2b --- /dev/null +++ b/assets/icons/pen-freeform.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/crates/ui-egui/src/anchor_tools_tests.rs b/crates/ui-egui/src/anchor_tools_tests.rs index abd4b56971..550d82adb3 100644 --- a/crates/ui-egui/src/anchor_tools_tests.rs +++ b/crates/ui-egui/src/anchor_tools_tests.rs @@ -46,7 +46,11 @@ fn click(app: &mut PhotocraftApp, at: [f64; 2], mods: Modifiers) { #[test] fn both_tools_join_the_pen_group() { let group: Vec = Tool::ALL.iter().copied().filter(|t| t.key() == 'P').collect(); - assert_eq!(group, vec![Tool::Pen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], "P cycles Pen → Add → Delete"); + assert_eq!( + group, + vec![Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], + "P cycles Pen → Freeform → Curvature → Add → Delete" + ); let mut app = app(Tool::Brush); app.ui.tool = Tool::from_name("addAnchorPoint").unwrap(); assert_eq!(app.ui.tool, Tool::AddAnchorPoint); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 963b94a7f4..2567b7b18e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -714,6 +714,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool { | Tool::Patch | Tool::ContentAwareMove | Tool::QuickSelection + | Tool::FreeformPen ) } @@ -3060,6 +3061,8 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect: Tool::PolygonLasso if response.double_clicked() || response.triple_clicked() => commit_polygon(app), // The same for the Magnetic Lasso: along the edges, or straight with ⌥. Tool::MagneticLasso if response.double_clicked() || response.triple_clicked() => crate::magnetic_lasso_ui::close(app, mods.alt), + // The first click placed (or grabbed) the point; the second toggles it. + Tool::CurvaturePen if response.double_clicked() => crate::vector_ui::curvature_toggle(app, d[0], d[1]), _ => { if tool == Tool::Move && app.ui.transform.is_none() { begin_transform_controls_at(app, &ctx, &xf, p); @@ -3971,6 +3974,10 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View marching_ants_segments(painter, xf, &moved, painter.ctx().input(|i| i.time)); } } + Tool::FreeformPen => { + let pts: Vec = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.5, crate::theme::Tokens::get(painter.ctx()).accent))); + } Tool::Lasso | Tool::Patch | Tool::ContentAwareMove => { let mut pts: Vec = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect(); if let Some(lasso) = &d.lasso { @@ -4080,6 +4087,8 @@ fn tool_move(app: &mut PhotocraftApp, x: f64, y: f64, pressure: f32, mods: egui: } if tool == Tool::Pen { crate::vector_ui::pen_move(app, x, y); + } else if tool == Tool::CurvaturePen { + crate::vector_ui::curvature_move(app, x, y); } let zoom = app.point_zoom(); if let Some(d) = app.drag.as_mut().filter(|d| d.reposition) { @@ -4343,6 +4352,10 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) crate::vector_ui::pen_down(app, x, y); return; } + Tool::CurvaturePen => { + crate::vector_ui::curvature_down(app, x, y); + return; + } Tool::CloneStamp | Tool::Healing if mods.alt => { crate::retouch_ui::set_source(app, x, y); return; @@ -4425,6 +4438,8 @@ pub fn tool_event(app: &mut PhotocraftApp, ev: ToolEvent, mods: egui::Modifiers) } if tool == Tool::Pen { crate::vector_ui::pen_up(app); + } else if tool == Tool::CurvaturePen { + crate::vector_ui::curvature_up(app); } let zoom = app.point_zoom(); let Some(mut d) = app.drag.take() else { return }; @@ -4694,6 +4709,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { } match d.tool { Tool::ObjectSelection => crate::retouch_ui::finish_object_selection(app, d.start, [end[0], end[1]], d.modifiers), + Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points), // Photoshop: a click without dragging opens Create Rectangle / Ellipse / … instead. t if crate::vector_ui::is_shape_tool(t) && crate::shape_dialog::is_click(d.start, &d.points, app.current_zoom()) diff --git a/crates/ui-egui/src/hold_keys.rs b/crates/ui-egui/src/hold_keys.rs index 5691576a56..8b301e7dc7 100644 --- a/crates/ui-egui/src/hold_keys.rs +++ b/crates/ui-egui/src/hold_keys.rs @@ -82,6 +82,8 @@ pub fn cmd_moves(t: Tool) -> bool { | Tool::PathSelection | Tool::DirectSelection | Tool::Pen + | Tool::FreeformPen + | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::RectMarquee diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index bdb46574fa..4ec735d78a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2488,6 +2488,11 @@ cameraData Lens Objektiv Previous Document Předchozí dokument W x H x Resolution Š x V x Rozlišení Front Image Přední obraz + Freeform Pen Tool Nástroj pero od ruky + Curvature Pen Tool Nástroj pero křivosti + Curve Fit: Přizpůsobení křivky: + Drag: draw freehand · Release on the start point: close Tažení: kreslit od ruky · uvolnění na počátečním bodě: uzavřít + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Kliknutí: hladký bod · dvojklik: roh · tažení bodu: přesunout · kliknutí na první bod: uzavřít · {key} dokončit New Crop Preset… Nová předvolba oříznutí… Delete Crop Preset… Odstranit předvolbu oříznutí… Select the crop presets to delete: Vyberte předvolby oříznutí k odstranění: diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 2f38e62dcb..9263cbc050 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2521,6 +2521,11 @@ cameraData Lens Objektiv Previous Document Vorheriges Dokument W x H x Resolution B x H x Auflösung Front Image Vorderes Bild + Freeform Pen Tool Freiform-Stift-Werkzeug + Curvature Pen Tool Rundungsstift-Werkzeug + Curve Fit: Kurvenanpassung: + Drag: draw freehand · Release on the start point: close Ziehen: frei zeichnen · Am Startpunkt loslassen: schließen + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klick: weicher Punkt · Doppelklick: Ecke · Punkt ziehen: verschieben · Ersten Punkt klicken: schließen · {key} fertig New Crop Preset… Neue Freistellungsvorgabe… Delete Crop Preset… Freistellungsvorgabe löschen… Select the crop presets to delete: Wählen Sie die zu löschenden Freistellungsvorgaben: diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4ed84eb658..58b2ff5a8d 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3585,6 +3585,11 @@ cameraData Lens Φακός Previous Document Προηγούμενο έγγραφο W x H x Resolution Π x Υ x Ανάλυση Front Image Μπροστινή εικόνα + Freeform Pen Tool Πένα ελεύθερης σχεδίασης + Curvature Pen Tool Πένα καμπυλότητας + Curve Fit: Προσαρμογή καμπύλης: + Drag: draw freehand · Release on the start point: close Σύρσιμο: ελεύθερη σχεδίαση · Άφημα στο αρχικό σημείο: κλείσιμο + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Κλικ: ομαλό σημείο · Διπλό κλικ: γωνία · Σύρσιμο σημείου: μετακίνηση · Κλικ στο πρώτο σημείο: κλείσιμο · {key}: τέλος New Crop Preset… Νέα προεπιλογή περικοπής… Delete Crop Preset… Διαγραφή προεπιλογής περικοπής… Select the crop presets to delete: Επιλέξτε τις προεπιλογές περικοπής για διαγραφή: diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6898881e85..17fe479cfa 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2562,6 +2562,11 @@ cameraData Lens Objetivo Previous Document Documento anterior W x H x Resolution An. x Al. x Resolución Front Image Imagen frontal + Freeform Pen Tool Herramienta Pluma de forma libre + Curvature Pen Tool Herramienta Pluma de curvatura + Curve Fit: Ajuste de curva: + Drag: draw freehand · Release on the start point: close Arrastrar: dibujar a mano alzada · Soltar en el punto inicial: cerrar + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Clic: punto suave · Doble clic: esquina · Arrastrar un punto: mover · Clic en el primer punto: cerrar · {key}: terminar New Crop Preset… Nuevo ajuste preestablecido de recorte… Delete Crop Preset… Eliminar ajuste preestablecido de recorte… Select the crop presets to delete: Seleccione los ajustes preestablecidos de recorte que desea eliminar: diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 81ab41c79b..91d46cca2c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2899,6 +2899,11 @@ cameraData Lens Objectif Previous Document Document précédent W x H x Resolution L x H x Résolution Front Image Image de premier plan + Freeform Pen Tool Outil Plume libre + Curvature Pen Tool Outil Plume de courbure + Curve Fit: Ajustement de courbe : + Drag: draw freehand · Release on the start point: close Glisser : dessin à main levée · Relâcher sur le point de départ : fermer + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Clic : point lisse · Double-clic : angle · Glisser un point : déplacer · Clic sur le premier point : fermer · {key} : terminer New Crop Preset… Nouveau paramètre prédéfini de recadrage… Delete Crop Preset… Supprimer le paramètre prédéfini de recadrage… Select the crop presets to delete: Sélectionnez les paramètres prédéfinis de recadrage à supprimer : diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index be11cbff9d..81ad6b02ca 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2886,6 +2886,11 @@ cameraData Lens Lensa Previous Document Dokumen Sebelumnya W x H x Resolution L x T x Resolusi Front Image Gambar Depan + Freeform Pen Tool Alat Pen Bebas + Curvature Pen Tool Alat Pen Kelengkungan + Curve Fit: Penyesuaian Kurva: + Drag: draw freehand · Release on the start point: close Seret: gambar bebas · Lepas di titik awal: tutup + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: titik halus · Klik ganda: sudut · Seret titik: pindahkan · Klik titik pertama: tutup · {key} selesai New Crop Preset… Preset Pangkas Baru… Delete Crop Preset… Hapus Preset Pangkas… Select the crop presets to delete: Pilih preset pangkas yang akan dihapus: diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a564eb43ff..43eef02440 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2552,6 +2552,11 @@ cameraData Lens Obiettivo Previous Document Documento precedente W x H x Resolution L x A x Risoluzione Front Image Immagine in primo piano + Freeform Pen Tool Strumento Penna a mano libera + Curvature Pen Tool Strumento Penna curvatura + Curve Fit: Adattamento curva: + Drag: draw freehand · Release on the start point: close Trascina: disegna a mano libera · Rilascia sul punto iniziale: chiudi + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Click: punto morbido · Doppio click: angolo · Trascina un punto: sposta · Click primo punto: chiudi · {key} termina New Crop Preset… Nuovo predefinito di ritaglio… Delete Crop Preset… Elimina predefinito di ritaglio… Select the crop presets to delete: Seleziona i predefiniti di ritaglio da eliminare: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 9c1fd51cd4..38cb414938 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3500,6 +3500,11 @@ cameraData Lens レンズ Previous Document 前のドキュメント W x H x Resolution 幅 x 高さ x 解像度 Front Image 前面の画像 + Freeform Pen Tool フリーフォームペンツール + Curvature Pen Tool 曲線ペンツール + Curve Fit: カーブフィット: + Drag: draw freehand · Release on the start point: close ドラッグ: フリーハンドで描画 · 始点で離す: 閉じる + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish クリック: スムーズポイント · ダブルクリック: 角 · 点をドラッグ: 移動 · 最初の点をクリック: 閉じる · {key} 完了 New Crop Preset… 新規切り抜きプリセット… Delete Crop Preset… 切り抜きプリセットを削除… Select the crop presets to delete: 削除する切り抜きプリセットを選択してください: diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index bc350c88aa..bd20d61b2c 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3066,6 +3066,11 @@ cameraData Lens 렌즈 Previous Document 이전 문서 W x H x Resolution 폭 x 높이 x 해상도 Front Image 전면 이미지 + Freeform Pen Tool 자유 형태 펜 도구 + Curvature Pen Tool 곡률 펜 도구 + Curve Fit: 곡선 맞춤: + Drag: draw freehand · Release on the start point: close 드래그: 자유롭게 그리기 · 시작점에서 놓기: 닫기 + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 클릭: 매끄러운 점 · 더블 클릭: 모서리 · 점 드래그: 이동 · 첫 점 클릭: 닫기 · {key}: 완료 New Crop Preset… 새 자르기 사전 설정… Delete Crop Preset… 자르기 사전 설정 삭제… Select the crop presets to delete: 삭제할 자르기 사전 설정을 선택하십시오: diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f427ee9f79..bbea0bd05e 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3653,6 +3653,11 @@ cameraData Lens Lens Previous Document Vorig document W x H x Resolution B x H x Resolutie Front Image Voorste afbeelding + Freeform Pen Tool Pengereedschap Vrije vorm + Curvature Pen Tool Pengereedschap Kromming + Curve Fit: Curve aanpassen: + Drag: draw freehand · Release on the start point: close Slepen: uit de vrije hand tekenen · Loslaten op beginpunt: sluiten + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: vloeiend punt · Dubbelklik: hoek · Punt slepen: verplaatsen · Klik op eerste punt: sluiten · {key} voltooien New Crop Preset… Nieuwe uitsnijvoorinstelling… Delete Crop Preset… Uitsnijvoorinstelling verwijderen… Select the crop presets to delete: Selecteer de uitsnijvoorinstellingen die u wilt verwijderen: diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 6841bf6859..db57cdb233 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3501,6 +3501,11 @@ cameraData Lens Obiektyw Previous Document Poprzedni dokument W x H x Resolution Szer. x Wys. x Rozdzielczość Front Image Obraz na wierzchu + Freeform Pen Tool Narzędzie Pióro dowolne + Curvature Pen Tool Narzędzie Pióro krzywizny + Curve Fit: Dopasowanie krzywej: + Drag: draw freehand · Release on the start point: close Przeciągnięcie: rysowanie odręczne · Puszczenie w punkcie początkowym: zamknij + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Klik: punkt gładki · Dwuklik: narożnik · Przeciągnięcie punktu: przesuń · Klik w pierwszy punkt: zamknij · {key} zakończ New Crop Preset… Nowe ustawienie kadrowania… Delete Crop Preset… Usuń ustawienie kadrowania… Select the crop presets to delete: Wybierz ustawienia kadrowania do usunięcia: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 815f08505a..376e9959af 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2907,6 +2907,11 @@ cameraData Lens Lente Previous Document Documento anterior W x H x Resolution L x A x Resolução Front Image Imagem frontal + Freeform Pen Tool Ferramenta Caneta de forma livre + Curvature Pen Tool Ferramenta Caneta de curvatura + Curve Fit: Ajuste de curva: + Drag: draw freehand · Release on the start point: close Arrastar: desenhar à mão livre · Soltar no ponto inicial: fechar + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Clique: ponto suave · Clique duplo: canto · Arrastar um ponto: mover · Clique no primeiro ponto: fechar · {key}: concluir New Crop Preset… Nova predefinição de corte… Delete Crop Preset… Excluir predefinição de corte… Select the crop presets to delete: Selecione as predefinições de corte a excluir: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7044d7a891..a8e339e1d2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2828,6 +2828,11 @@ cameraData Lens Объектив Previous Document Предыдущий документ W x H x Resolution Ш x В x Разрешение Front Image Изображение переднего плана + Freeform Pen Tool Свободное перо + Curvature Pen Tool Перо кривизны + Curve Fit: Точность кривой: + Drag: draw freehand · Release on the start point: close Перетаскивание: рисовать от руки · отпустить на начальной точке: замкнуть + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Клик: гладкая точка · двойной клик: угол · перетаскивание точки: переместить · клик по первой точке: замкнуть · {key} завершить Rasterize Layer Style Растрировать стиль слоя Search settings… Поиск настроек… No matching settings Нет подходящих настроек diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 2b5eeef070..4154ab2063 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3586,6 +3586,11 @@ cameraData Lens Об'єктив Previous Document Попередній документ W x H x Resolution Ш x В x Роздільна здатність Front Image Зображення переднього плану + Freeform Pen Tool Інструмент «Довільне перо» + Curvature Pen Tool Інструмент «Перо кривизни» + Curve Fit: Точність кривої: + Drag: draw freehand · Release on the start point: close Перетягування: малювати від руки · Відпускання на початковій точці: замкнути + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Клацання: плавна точка · Подвійне клацання: кут · Перетягування точки: перемістити · Клацання першої точки: замкнути · {key}: завершити Search settings… Пошук налаштувань… No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 2aaff8cfe2..b87a32ea2a 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2495,6 +2495,11 @@ cameraData Lens 镜头 Previous Document 上一个文档 W x H x Resolution 宽 x 高 x 分辨率 Front Image 前面的图像 + Freeform Pen Tool 自由钢笔工具 + Curvature Pen Tool 弯度钢笔工具 + Curve Fit: 曲线拟合: + Drag: draw freehand · Release on the start point: close 拖动:自由绘制 · 在起点松开:闭合 + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 单击:平滑点 · 双击:角点 · 拖动点:移动 · 单击起点:闭合 · {key} 完成 Search settings… 搜索设置… No matching settings 没有匹配的设置 New Crop Preset… 新建裁剪预设… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index b6dbb048a8..de5eb63621 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2465,6 +2465,11 @@ cameraData Lens 鏡頭 Previous Document 上一個文件 W x H x Resolution 寬 x 高 x 解析度 Front Image 前面的影像 + Freeform Pen Tool 自由筆型工具 + Curvature Pen Tool 曲線筆工具 + Curve Fit: 曲線擬合: + Drag: draw freehand · Release on the start point: close 拖曳:手繪 · 在起點放開:閉合 + Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 按一下:平滑點 · 按兩下:轉角 · 拖曳點:移動 · 按一下第一點:閉合 · {key} 完成 Search settings… 搜尋設定… No matching settings 沒有符合的設定 New Crop Preset… 新增裁切預設集… diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index f128ef9f4e..0dfa6c1a25 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -81,6 +81,8 @@ 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")), + ("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")), ("pen-delete-anchor", include_bytes!("../../../assets/icons/pen-delete-anchor.svg")), ("pen-tool", include_bytes!("../../../assets/icons/pen-tool.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f6e760d8b9..5791de4cb3 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -130,6 +130,8 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::QuickSelection => "circle-dashed", Tool::ObjectSelection => "square-dashed-mouse-pointer", Tool::Pen => "pen-tool", + Tool::FreeformPen => "pen-freeform", + Tool::CurvaturePen => "pen-curvature", Tool::AddAnchorPoint => "pen-add-anchor", Tool::DeleteAnchorPoint => "pen-delete-anchor", Tool::PathSelection => "mouse-pointer-2", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59707c71e8..c09cf5994a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1457,7 +1457,7 @@ impl eframe::App for PhotocraftApp { if self.ui.text_edit.is_some() && !self.ui.tool.is_type() { type_tool::commit(self); } - if self.ui.pen.is_some() && self.ui.tool != state::Tool::Pen { + if self.ui.pen.as_ref().is_some_and(|p| !p.drawn_with(self.ui.tool)) { vector_ui::pen_commit(self, false); } transform_tool::end_if_left(self); @@ -2104,6 +2104,9 @@ mod color_swatch_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod pen_variants_tests; + #[cfg(test)] mod color_replacement_tests; #[cfg(test)] diff --git a/crates/ui-egui/src/mask_thumbs_ui/tests.rs b/crates/ui-egui/src/mask_thumbs_ui/tests.rs index 3fd0904eb9..b7ef3845ca 100644 --- a/crates/ui-egui/src/mask_thumbs_ui/tests.rs +++ b/crates/ui-egui/src/mask_thumbs_ui/tests.rs @@ -440,12 +440,7 @@ fn clicking_the_vector_mask_targets_it_for_the_path_tools() { let k = layer(&h, masked).vector_mask.unwrap().path.subpaths[0].knots[0].anchor; assert_eq!((k.x, k.y), (25.0, 12.0)); // The Pen adds a subpath to it. - h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { - knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], - unlinked: Vec::new(), - dragging: false, - adjusting_last: false, - }); + h.state_mut().ui.pen = Some(crate::vector_ui::PenPath { knots: vec![[[150.0, 10.0]; 3], [[190.0, 10.0]; 3], [[190.0, 40.0]; 3]], ..Default::default() }); crate::vector_ui::pen_commit(h.state_mut(), true); assert_eq!(layer(&h, masked).vector_mask.unwrap().path.subpaths.len(), 2); assert!(h.state().session.active().unwrap().doc.work_path.is_none(), "not a work path"); diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index e5bef76434..d0aa813f39 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -34,7 +34,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ &[Tool::Dodge, Tool::Burn, Tool::Sponge], ], &[ - &[Tool::Pen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], + &[Tool::Pen, Tool::FreeformPen, Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint], &[Tool::Type, Tool::VerticalType], &[Tool::PathSelection, Tool::DirectSelection], &[Tool::Rectangle, Tool::EllipseShape, Tool::Triangle, Tool::Polygon, Tool::Line, Tool::CustomShape], diff --git a/crates/ui-egui/src/path_selection_tests.rs b/crates/ui-egui/src/path_selection_tests.rs index bac3bda70f..55042703ec 100644 --- a/crates/ui-egui/src/path_selection_tests.rs +++ b/crates/ui-egui/src/path_selection_tests.rs @@ -66,8 +66,7 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { let mut h = harness(); 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)], unlinked: Vec::new(), dragging: false, adjusting_last: false }); + 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, command()); let app = h.state(); @@ -77,8 +76,7 @@ fn cmd_enter_while_drawing_finishes_the_path_and_loads_it() { // Plain ↩ still only finishes a path. let mut h = harness(); h.state_mut().ui.tool = Tool::Pen; - h.state_mut().ui.pen = - Some(PenPath { knots: vec![p(20.0, 20.0), p(120.0, 20.0), p(120.0, 80.0)], unlinked: Vec::new(), dragging: false, adjusting_last: false }); + 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::NONE); assert!(h.state().session.active().unwrap().doc.work_path.is_some()); diff --git a/crates/ui-egui/src/pen_variants_tests.rs b/crates/ui-egui/src/pen_variants_tests.rs new file mode 100644 index 0000000000..5b18dee110 --- /dev/null +++ b/crates/ui-egui/src/pen_variants_tests.rs @@ -0,0 +1,106 @@ +//! The Freeform Pen and Curvature Pen (#1054): gestures through the canvas commit the same paths +//! the Pen does. + +use egui::Modifiers; +use photocraft_doc::{LayerContent, Path}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +fn app(tool: Tool) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 300, "height": 200})).unwrap(); + app.ui.tool = tool; + app +} + +fn work_path(app: &PhotocraftApp) -> Path { + app.session.active().unwrap().doc.work_path.clone().expect("a work path") +} + +fn click(app: &mut PhotocraftApp, x: f64, y: f64) { + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, Modifiers::NONE); + tool_event(app, ToolEvent::Up { x, y }, Modifiers::NONE); +} + +#[test] +fn freeform_pen_drag_is_fitted_into_one_work_path_or_shape() { + assert_eq!(Tool::from_name("freeformPen"), Some(Tool::FreeformPen)); + assert_eq!(Tool::FreeformPen.key(), 'P'); + let mut app = app(Tool::FreeformPen); + let before = app.session.active().unwrap().history.entries().len(); + // A zig-zag with two right-angle corners, sampled every 2 px: 90 pointer samples. + let corners = [[20.0, 20.0], [80.0, 20.0], [80.0, 80.0], [140.0, 80.0]]; + let trace: Vec<[f64; 2]> = corners + .windows(2) + .flat_map(|w| (0..30).map(move |k| [w[0][0] + (w[1][0] - w[0][0]) * k as f64 / 30.0, w[0][1] + (w[1][1] - w[0][1]) * k as f64 / 30.0])) + .chain([corners[3]]) + .collect(); + tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 20.0, pressure: 1.0 }, Modifiers::NONE); + for p in &trace[1..] { + tool_event(&mut app, ToolEvent::Move { x: p[0], y: p[1], pressure: 1.0 }, Modifiers::NONE); + } + tool_event(&mut app, ToolEvent::Up { x: 140.0, y: 80.0 }, Modifiers::NONE); + let path = work_path(&app); + assert_eq!(path.subpaths.len(), 1); + let sp = &path.subpaths[0]; + assert!(!sp.closed); + let anchors: Vec<[f64; 2]> = sp.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, corners, "the fit keeps the corners, not the 90 samples"); + assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one History step"); + + // Shape mode: a closed loop (released on its start) becomes a filled shape layer. + app.ui.tool_options.vector_mode = "shape".into(); + tool_event(&mut app, ToolEvent::Down { x: 200.0, y: 100.0, pressure: 1.0 }, Modifiers::NONE); + for i in 1..=90 { + let a = (i as f64 * 4.0).to_radians(); + tool_event(&mut app, ToolEvent::Move { x: 160.0 + 40.0 * a.cos(), y: 100.0 + 40.0 * a.sin(), pressure: 1.0 }, Modifiers::NONE); + } + tool_event(&mut app, ToolEvent::Up { x: 200.0, y: 100.0 }, Modifiers::NONE); + let st = app.session.active().unwrap(); + let LayerContent::Shape(sh) = &st.doc.layer(st.active_layer.unwrap()).unwrap().content else { panic!("no shape layer") }; + assert!(sh.path.subpaths[0].closed && sh.fill.is_some()); + assert!(sh.path.subpaths[0].knots.len() <= 8, "{} anchors for a circle", sh.path.subpaths[0].knots.len()); +} + +#[test] +fn curvature_pen_clicks_make_a_smooth_path_through_every_point() { + assert_eq!(Tool::from_name("Curvature Pen Tool"), Some(Tool::CurvaturePen)); + let mut app = app(Tool::CurvaturePen); + let pts = [[20.0, 150.0], [100.0, 40.0], [180.0, 150.0], [260.0, 60.0]]; + for p in pts { + click(&mut app, p[0], p[1]); + } + assert!(app.session.active().unwrap().doc.work_path.is_none(), "clicks are not History steps"); + // Dragging an existing point moves it. + tool_event(&mut app, ToolEvent::Down { x: 260.0, y: 60.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Move { x: 250.0, y: 70.0, pressure: 1.0 }, Modifiers::NONE); + tool_event(&mut app, ToolEvent::Up { x: 250.0, y: 70.0 }, Modifiers::NONE); + crate::vector_ui::pen_commit(&mut app, false); // ↩ + let path = work_path(&app); + let knots = &path.subpaths[0].knots; + let anchors: Vec<[f64; 2]> = knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, [pts[0], pts[1], pts[2], [250.0, 70.0]], "the path passes through every point"); + for k in &knots[1..3] { + // Handles are a third of a chord of at least 100 px, never zero length; collinear to + // 1e-9 (f64 rounding of the same direction scaled twice). + let (i, o) = ([k.anchor.x - k.in_ctrl.x, k.anchor.y - k.in_ctrl.y], [k.out_ctrl.x - k.anchor.x, k.out_ctrl.y - k.anchor.y]); + let (li, lo) = (i[0].hypot(i[1]), o[0].hypot(o[1])); + assert!(li > 10.0 && lo > 10.0); + assert!(k.smooth && ((i[0] * o[1] - i[1] * o[0]) / (li * lo)).abs() < 1e-9 && i[0] * o[0] + i[1] * o[1] > 0.0); + } + + // Double-clicking a point makes it a corner; clicking the first point closes the path. + app.run("path.delete", json!({"name": "work"})).unwrap(); + for p in &pts[..3] { + click(&mut app, p[0], p[1]); + } + crate::vector_ui::curvature_toggle(&mut app, 100.0, 40.0); + click(&mut app, 20.0, 150.0); + let sp = &work_path(&app).subpaths[0]; + assert!(sp.closed && sp.knots.len() == 3); + assert!(!sp.knots[1].smooth && sp.knots[1].in_ctrl == sp.knots[1].anchor, "a corner point"); + assert!(sp.knots[0].smooth && sp.knots[2].smooth); +} diff --git a/crates/ui-egui/src/snap_ui.rs b/crates/ui-egui/src/snap_ui.rs index a1cdf8e366..18c5bdd1dd 100644 --- a/crates/ui-egui/src/snap_ui.rs +++ b/crates/ui-egui/src/snap_ui.rs @@ -107,6 +107,7 @@ fn is_point_tool(t: Tool) -> bool { | Tool::Crop | Tool::ObjectSelection | Tool::Pen + | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle @@ -241,7 +242,7 @@ pub fn begin_guide(app: &mut PhotocraftApp, p: [f64; 2]) { /// the pixel grid. fn pixel_round(app: &PhotocraftApp, p: [f64; 2]) -> [f64; 2] { if app.session.prefs().tools.snap_vector_tools_and_transforms_to_pixel_grid - && matches!(app.ui.tool, Tool::Pen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) + && matches!(app.ui.tool, Tool::Pen | Tool::CurvaturePen | Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line) { [p[0].round(), p[1].round()] } else { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 23bc080d4c..4cc6f230d1 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -111,6 +111,8 @@ pub enum Tool { QuickSelection, ObjectSelection, Pen, + FreeformPen, + CurvaturePen, AddAnchorPoint, DeleteAnchorPoint, PathSelection, @@ -126,7 +128,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 58] = [ + pub const ALL: [Tool; 60] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, @@ -173,6 +175,8 @@ impl Tool { Tool::QuickSelection, Tool::ObjectSelection, Tool::Pen, + Tool::FreeformPen, + Tool::CurvaturePen, Tool::AddAnchorPoint, Tool::DeleteAnchorPoint, Tool::PathSelection, @@ -237,6 +241,8 @@ impl Tool { Tool::QuickSelection => "Quick Selection Tool", Tool::ObjectSelection => "Object Selection Tool", Tool::Pen => "Pen Tool", + Tool::FreeformPen => "Freeform Pen Tool", + Tool::CurvaturePen => "Curvature Pen Tool", Tool::AddAnchorPoint => "Add Anchor Point Tool", Tool::DeleteAnchorPoint => "Delete Anchor Point Tool", Tool::PathSelection => "Path Selection Tool", @@ -301,7 +307,7 @@ impl Tool { Tool::SingleRowMarquee | Tool::SingleColumnMarquee => '\0', Tool::Dodge | Tool::Burn | Tool::Sponge => 'O', Tool::QuickSelection | Tool::ObjectSelection => 'W', - Tool::Pen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint => 'P', + Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint => 'P', Tool::PathSelection | Tool::DirectSelection => 'A', Tool::Rectangle | Tool::EllipseShape | Tool::Triangle | Tool::Polygon | Tool::Line | Tool::CustomShape => 'U', } @@ -532,6 +538,9 @@ pub struct ToolOptions { pub corner_radius: f32, pub polygon_sides: u32, pub line_weight: f32, + /// Freeform Pen › Curve Fit: how far (px) the fitted path may stray from the pointer trace. + #[serde(default = "default_curve_fit")] + pub curve_fit: f32, /// Marquee options bar: "normal" | "fixedRatio" | "fixedSize", with the ratio or size. #[serde(default = "default_marquee_style")] pub marquee_style: String, @@ -645,6 +654,10 @@ fn default_move_target() -> String { "layer".into() } +fn default_curve_fit() -> f32 { + 2.0 +} + fn default_eraser_mode() -> String { "brush".into() } @@ -724,6 +737,7 @@ impl Default for ToolOptions { corner_radius: 0.0, polygon_sides: 5, line_weight: 3.0, + curve_fit: default_curve_fit(), marquee_style: default_marquee_style(), marquee_width: 1.0, marquee_height: 1.0, @@ -1243,7 +1257,7 @@ mod tests { assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 58); + assert_eq!(Tool::ALL.len(), 60); assert_eq!(Tool::from_name("addAnchorPoint"), Some(Tool::AddAnchorPoint)); assert_eq!(Tool::from_name("Delete Anchor Point Tool"), Some(Tool::DeleteAnchorPoint)); assert_eq!(Tool::DeleteAnchorPoint.key(), 'P'); diff --git a/crates/ui-egui/src/tool_reset.rs b/crates/ui-egui/src/tool_reset.rs index eb8cd5816c..4a17eb95d3 100644 --- a/crates/ui-egui/src/tool_reset.rs +++ b/crates/ui-egui/src/tool_reset.rs @@ -159,7 +159,8 @@ pub fn reset_options(o: &mut ToolOptions, tool: Tool) { 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), + Tool::Pen | Tool::CurvaturePen => reset!(vector_mode), + Tool::FreeformPen => reset!(vector_mode, 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 463a7f2993..4a9bf71fc7 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -26,6 +26,20 @@ pub struct PenPath { /// Re-dragging the final anchor changes its outgoing control only; the incoming curve stays put. #[serde(skip)] pub adjusting_last: bool, + /// Drawn with the Curvature Pen: handles follow the anchors (`curvature_refresh`) and + /// `unlinked` lists its corner points. + #[serde(default)] + pub curvature: bool, + /// Curvature Pen: the anchor being dragged. + #[serde(skip)] + pub moving: Option, +} + +impl PenPath { + /// Does `tool` keep drawing this path? Another tool finishes it. + pub fn drawn_with(&self, tool: Tool) -> bool { + tool == if self.curvature { Tool::CurvaturePen } else { Tool::Pen } + } } pub fn is_shape_tool(t: Tool) -> bool { @@ -220,6 +234,11 @@ pub fn pen_undo_last_point(app: &mut PhotocraftApp) -> bool { if pen.knots.pop().is_none() { return false; } + pen.unlinked.retain(|&i| i < pen.knots.len()); + if pen.curvature { + pen.moving = None; + curvature_refresh(pen, false); + } pen.dragging = false; if pen.knots.is_empty() { app.ui.pen = None; @@ -236,11 +255,20 @@ pub fn pen_up(app: &mut PhotocraftApp) { /// Finish the pen path: a work path (Path mode) or a new shape layer (Shape mode). pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { - let Some(pen) = app.ui.pen.take() else { return }; + let Some(mut pen) = app.ui.pen.take() else { return }; + if pen.curvature { + curvature_refresh(&mut pen, closed); + } + 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. +fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, closed: bool) { if pen.knots.len() < 2 { return; } - let path = pen_to_json(&pen, closed); + let path = pen_to_json(pen, closed); let r = if app.ui.tool_options.vector_mode == "shape" { let fill = if closed && app.ui.tool_options.shape_fill { json!(hex(app.session.tools.foreground)) } else { Value::Null }; let stroke = if closed { @@ -269,6 +297,90 @@ pub fn pen_commit(app: &mut PhotocraftApp, closed: bool) { } } +// --------------------------------------------------------------------------------------------- +// Freeform Pen and Curvature Pen + +/// Freeform Pen release: fit the drag's trace within the options bar's Curve Fit and commit it +/// as the Pen does. Releasing on the start point closes the path; a click draws nothing. +pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]]) { + let trace: Vec<[f64; 2]> = points.iter().map(|p| [p[0], p[1]]).collect(); + let (Some(first), Some(last)) = (trace.first(), trace.last()) else { return }; + let tol = 6.0 / app.point_zoom().max(0.01) as f64; + let length: f64 = trace.windows(2).map(|w| (w[1][0] - w[0][0]).hypot(w[1][1] - w[0][1])).sum(); + let closed = (first[0] - last[0]).hypot(first[1] - last[1]) < tol && length > 4.0 * tol; + let Some(sub) = photocraft_vector::trace::fit_trace(&trace, f64::from(app.ui.tool_options.curve_fit), closed) else { return }; + let knots = sub.knots.iter().map(|k| [[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]]).collect(); + let unlinked = sub.knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect(); + commit_path(app, &PenPath { knots, unlinked, ..Default::default() }, sub.closed); +} + +/// Recompute a Curvature Pen path's handles from its anchors and corner points. +fn curvature_refresh(pen: &mut PenPath, closed: bool) { + let anchors: Vec<[f64; 2]> = pen.knots.iter().map(|k| k[0]).collect(); + let corners: Vec = (0..anchors.len()).map(|i| pen.unlinked.contains(&i)).collect(); + pen.knots = photocraft_vector::edit::curvature_knots(&anchors, &corners, closed); +} + +/// The Curvature Pen's anchor within a few screen pixels of (x, y). +fn curvature_hit(app: &PhotocraftApp, x: f64, y: f64) -> Option { + let tol = 6.0 / app.point_zoom().max(0.01) as f64; + let pen = app.ui.pen.as_ref().filter(|p| p.curvature)?; + pen.knots + .iter() + .enumerate() + .map(|(i, k)| (i, (k[0][0] - x).hypot(k[0][1] - y))) + .filter(|(_, d)| *d < tol) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) +} + +/// Curvature Pen press: clicking the first anchor closes the path, pressing another anchor +/// grabs it to move, anywhere else adds a smooth anchor (still draggable until the release). +pub fn curvature_down(app: &mut PhotocraftApp, x: f64, y: f64) { + if app.ui.pen.as_ref().is_some_and(|p| !p.curvature) { + pen_commit(app, false); + } + let hit = curvature_hit(app, x, y); + if hit == Some(0) && app.ui.pen.as_ref().is_some_and(|p| p.knots.len() >= 3) { + pen_commit(app, true); + return; + } + let pen = app.ui.pen.get_or_insert_with(|| PenPath { curvature: true, ..Default::default() }); + if hit.is_none() { + pen.knots.push([[x, y]; 3]); + } + pen.moving = hit.or(pen.knots.len().checked_sub(1)); + curvature_refresh(pen, false); +} + +pub fn curvature_move(app: &mut PhotocraftApp, x: f64, y: f64) { + if let Some(pen) = app.ui.pen.as_mut().filter(|p| p.curvature) + && let Some(k) = pen.moving.and_then(|i| pen.knots.get_mut(i)) + { + k[0] = [x, y]; + curvature_refresh(pen, false); + } +} + +pub fn curvature_up(app: &mut PhotocraftApp) { + if let Some(pen) = app.ui.pen.as_mut() { + pen.moving = None; + } +} + +/// Curvature Pen double-click: toggle the anchor under the pointer between smooth and corner +/// (double-clicking empty canvas therefore places a corner point, as in Photoshop). +pub fn curvature_toggle(app: &mut PhotocraftApp, x: f64, y: f64) { + let Some(i) = curvature_hit(app, x, y) else { return }; + let Some(pen) = app.ui.pen.as_mut() else { return }; + if let Some(at) = pen.unlinked.iter().position(|&c| c == i) { + pen.unlinked.remove(at); + } else { + pen.unlinked.push(i); + } + curvature_refresh(pen, false); +} + /// The path that path commands act on without a name: the one selected in the Paths panel, else /// the work path, else the selected layer's shape path or vector mask; `None` when there's none. pub fn active_path_name(app: &PhotocraftApp) -> Option { @@ -469,7 +581,7 @@ pub(crate) fn draw_handle(painter: &egui::Painter, anchor: Pos2, handle: Pos2, a /// Work path / active shape path outlines, anchors, and the pen path in progress. pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform, doc: &Document) { let tool = app.ui.tool; - let vector_tool = matches!(tool, Tool::Pen | Tool::PathSelection) || is_shape_tool(tool); + let vector_tool = matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::PathSelection) || is_shape_tool(tool); let accent = Tokens::get(painter.ctx()).accent; let to_scr = |q: [f64; 2]| xf.to_screen(q[0] as f32, q[1] as f32); let draw_path = |p: &Path, anchors: bool| { @@ -517,13 +629,25 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform painter.add(egui::Shape::line(pts, Stroke::new(1.5, accent))); if let (Some(last), Some(h)) = (pen.knots.last(), app.hover_doc) && !pen.dragging + && pen.moving.is_none() { - painter.add(egui::Shape::dashed_line(&[to_scr(last[0]), to_scr(h)], Stroke::new(1.0, accent), 4.0, 3.0)); + if pen.curvature && pen.knots.len() >= 2 { + // The Curvature Pen previews the curve the next click makes through the pointer. + let mut next = pen.clone(); + next.knots.push([h; 3]); + curvature_refresh(&mut next, false); + if let [.., a, b] = next.knots.as_slice() { + let pts: Vec = (0..=24).map(|k| to_scr(bezier(a[0], a[2], b[1], b[0], k as f64 / 24.0))).collect(); + painter.add(egui::Shape::dashed_line(&pts, Stroke::new(1.0, accent), 4.0, 3.0)); + } + } else { + painter.add(egui::Shape::dashed_line(&[to_scr(last[0]), to_scr(h)], Stroke::new(1.0, accent), 4.0, 3.0)); + } } for (i, k) in pen.knots.iter().enumerate() { let c = to_scr(k[0]); let last = i + 1 == pen.knots.len(); - if last && k[2] != k[0] { + if last && k[2] != k[0] && !pen.curvature { for hnd in [k[1], k[2]] { draw_handle(painter, c, to_scr(hnd), accent); } @@ -538,7 +662,12 @@ pub fn draw_overlay(app: &PhotocraftApp, painter: &egui::Painter, xf: &ViewXform /// Options bar for vector tools; false for other tools. pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bool { - if !(is_shape_tool(tool) || matches!(tool, Tool::Pen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::PathSelection | Tool::DirectSelection)) { + if !(is_shape_tool(tool) + || matches!( + tool, + Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::PathSelection | Tool::DirectSelection + )) + { return false; } let t = Tokens::get(ui.ctx()); @@ -570,7 +699,7 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo lbl(ui, &crate::i18n::fmt(hint, &[("alt", &crate::shortcuts::pretty("Alt"))])); return true; } - if tool == Tool::Pen { + if matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen) { let opts = [("path".to_string(), tl!("Path")), ("shape".to_string(), tl!("Shape"))]; crate::widgets::dropdown(ui, "pen-mode", &mut o.vector_mode, &opts, 80.0); crate::widgets::vline(ui, 22.0); @@ -580,13 +709,23 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo o.shape_stroke = style; } } - lbl( - ui, - &crate::i18n::fmt( - tl!("Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel"), - &[("key", &crate::shortcuts::pretty("Enter"))], + let enter = crate::shortcuts::pretty("Enter"); + match tool { + Tool::FreeformPen => { + crate::widgets::vline(ui, 22.0); + lbl(ui, tl!("Curve Fit:")); + crate::widgets::value_field(ui, &mut o.curve_fit, 0.5..=10.0, "px", 58.0); + lbl(ui, tl!("Drag: draw freehand · Release on the start point: close")); + } + Tool::CurvaturePen => lbl( + ui, + &crate::i18n::fmt( + tl!("Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish"), + &[("key", &enter)], + ), ), - ); + _ => lbl(ui, &crate::i18n::fmt(tl!("Click: corner · Drag: smooth · Click first point: close · {key} finish · Esc cancel"), &[("key", &enter)])), + } return true; } let mut mode = "shape".to_string(); diff --git a/crates/vector/src/edit.rs b/crates/vector/src/edit.rs index c0ac2367e7..b0846382e5 100644 --- a/crates/vector/src/edit.rs +++ b/crates/vector/src/edit.rs @@ -239,6 +239,53 @@ pub fn anchors_in(path: &Path, [x0, y0, x1, y1]: [f64; 4]) -> Vec<[usize; 2]> { out } +/// The Curvature Pen's knots, `[anchor, in, out]`, through `anchors`: a smooth curve that passes +/// through every anchor, Catmull-Rom style. An inner anchor's handles lie on the line parallel +/// to its neighbours' chord (so they are collinear), each a third of the distance to the +/// neighbour on its side long (the chordal scaling that keeps uneven spacing from overshooting). +/// The ends of an open path aim at their neighbour's nearer handle. `corners[i]` retracts anchor +/// `i`'s handles (a corner point); a missing entry is smooth. Coincident points give retracted +/// handles, never NaN. +pub fn curvature_knots(anchors: &[[f64; 2]], corners: &[bool], closed: bool) -> Vec<[[f64; 2]; 3]> { + let n = anchors.len(); + let mut knots: Vec<[[f64; 2]; 3]> = anchors.iter().map(|&a| [a; 3]).collect(); + let dist = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]).hypot(b[1] - a[1]); + let unit = |a: [f64; 2], b: [f64; 2]| { + let l = dist(a, b); + (l > 1e-9).then(|| [(b[0] - a[0]) / l, (b[1] - a[1]) / l]) + }; + let corner = |i: usize| corners.get(i).copied().unwrap_or(false); + let inner = |i: usize| closed || (i > 0 && i + 1 < n); + for (i, &p) in anchors.iter().enumerate() { + if n < 3 || corner(i) || !inner(i) { + continue; + } + let prev = anchors[(i + n - 1) % n]; + let next = anchors[(i + 1) % n]; + let Some(d) = unit(prev, next).or_else(|| unit(p, next)) else { continue }; + let (li, lo) = (dist(prev, p) / 3.0, dist(p, next) / 3.0); + knots[i] = [p, [p[0] - d[0] * li, p[1] - d[1] * li], [p[0] + d[0] * lo, p[1] + d[1] * lo]]; + } + if !closed && n >= 3 { + for (end, nb, take_in) in [(0, 1, true), (n - 1, n - 2, false)] { + if corner(end) { + continue; + } + let p = anchors[end]; + let aim = if take_in { knots[nb][1] } else { knots[nb][2] }; + let Some(d) = unit(p, aim) else { continue }; + let l = dist(p, anchors[nb]) / 3.0; + let h = [p[0] + d[0] * l, p[1] + d[1] * l]; + if take_in { + knots[end][2] = h; + } else { + knots[end][1] = h; + } + } + } + knots +} + /// The point at parameter `t` of the cubic segment `[anchor, out, in, anchor]`. pub fn eval(seg: &[Point; 4], t: f64) -> Point { let u = 1.0 - t; diff --git a/crates/vector/src/trace.rs b/crates/vector/src/trace.rs index e06c2ee441..5aa5c03722 100644 --- a/crates/vector/src/trace.rs +++ b/crates/vector/src/trace.rs @@ -9,6 +9,9 @@ //! //! Contours are emitted as subpaths: the first combines, the rest *exclude*, which is the //! even-odd area for nested, non-crossing contours regardless of their orientation. +//! +//! [`fit_trace`] runs steps 2 and 3 on a freehand pointer trace (the Freeform Pen), open or +//! closed, with a corner window that grows with the tolerance. use std::collections::HashMap; @@ -27,7 +30,7 @@ pub fn trace_mask(values: &[f32], rect: Rect, outside: f32, tolerance: f64) -> P continue; } // Fit to half the tolerance: the worst point may deviate by the full amount only rarely. - let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05)); + let mut s = fit_closed(lp, (tolerance * 0.5).max(0.05), 1.5); s.op = if i == 0 { PathOp::Combine } else { PathOp::Exclude }; subpaths.push(s); } @@ -151,12 +154,16 @@ fn norm(a: P) -> P { if l > 1e-12 { (a.0 / l, a.1 / l) } else { (0.0, 0.0) } } -/// Point `window` px of arc length away from `i` (forwards or backwards) on a closed loop. -fn walk(lp: &[P], i: usize, window: f64, fwd: bool) -> P { +/// Point `window` px of arc length away from `i` (forwards or backwards) on a closed loop, or on +/// an open polyline, where the walk stops at the ends. +fn walk(lp: &[P], i: usize, window: f64, fwd: bool, closed: bool) -> P { let n = lp.len(); let mut acc = 0.0; let mut cur = i; for _ in 0..n { + if !closed && (if fwd { cur + 1 >= n } else { cur == 0 }) { + break; + } let nx = if fwd { (cur + 1) % n } else { (cur + n - 1) % n }; let d = len(sub(lp[nx], lp[cur])); if acc + d >= window { @@ -169,15 +176,18 @@ fn walk(lp: &[P], i: usize, window: f64, fwd: bool) -> P { lp[cur] } -/// Indices of corner points. -fn corners(lp: &[P]) -> Vec { +/// Indices of corner points: where the direction over ±`window` px turns by more than 60°. The +/// ends of an open polyline are never corners. +fn corners(lp: &[P], window: f64, closed: bool) -> Vec { let n = lp.len(); - let window = 1.5; let cos_thresh = (60f64).to_radians().cos(); let turn: Vec = (0..n) .map(|i| { - let a = norm(sub(lp[i], walk(lp, i, window, false))); - let b = norm(sub(walk(lp, i, window, true), lp[i])); + if !closed && (i == 0 || i + 1 == n) { + return 1.0; + } + let a = norm(sub(lp[i], walk(lp, i, window, false, closed))); + let b = norm(sub(walk(lp, i, window, true, closed), lp[i])); dot(a, b) }) .collect(); @@ -191,6 +201,9 @@ fn corners(lp: &[P]) -> Vec { let mut acc = 0.0; let mut j = i; while acc < window { + if !closed && j + 1 >= n { + break; + } let nx = (j + 1) % n; acc += len(sub(lp[nx], lp[j])); j = nx; @@ -205,6 +218,9 @@ fn corners(lp: &[P]) -> Vec { acc = 0.0; j = i; while is_min && acc < window { + if !closed && j == 0 { + break; + } let pv = (j + n - 1) % n; acc += len(sub(lp[j], lp[pv])); j = pv; @@ -222,10 +238,10 @@ fn corners(lp: &[P]) -> Vec { out } -/// Fits a closed loop with cubic Béziers. -fn fit_closed(lp: &[P], tol: f64) -> Subpath { +/// Fits a closed loop with cubic Béziers; `window` is the corner detection window. +fn fit_closed(lp: &[P], tol: f64, window: f64) -> Subpath { let n = lp.len(); - let cs = corners(lp); + let cs = corners(lp, window, true); let mut segs: Vec<[P; 4]> = Vec::new(); let mut corner_at_start: Vec = Vec::new(); if cs.is_empty() { @@ -268,6 +284,98 @@ fn fit_closed(lp: &[P], tol: f64) -> Subpath { Subpath { closed: true, knots, op: PathOp::Combine } } +/// Most pointer samples [`fit_trace`] fits: a longer trace is thinned evenly first, so the work +/// stays bounded for any stroke. +pub const MAX_TRACE_POINTS: usize = 4096; + +/// Fits a freehand pointer trace (the Freeform Pen's drag) with cubic Béziers that stay within +/// `tolerance` px of every sample: Photoshop's Curve Fit, clamped to 0.1–100 px (2 px when not +/// finite). Turns sharper than 60° over ±2 × `tolerance` become corner anchors, the other inner +/// anchors are smooth. A `closed` trace becomes a closed subpath. Non-finite samples are skipped; +/// `None` when fewer than two distinct points remain (three when `closed`). +pub fn fit_trace(points: &[[f64; 2]], tolerance: f64, closed: bool) -> Option { + let tol = if tolerance.is_finite() { tolerance.clamp(0.1, 100.0) } else { 2.0 }; + let window = (2.0 * tol).max(1.5); + let min_step = (tol * 0.25).min(0.5); + let stride = points.len().div_ceil(MAX_TRACE_POINTS).max(1); + let last = points.len().saturating_sub(1); + let mut lp: Vec

= Vec::with_capacity(points.len().min(MAX_TRACE_POINTS + 1)); + for (i, p) in points.iter().enumerate() { + // Far-out coordinates would overflow the least-squares sums. + if (i % stride != 0 && i != last) || !p.iter().all(|v| v.is_finite() && v.abs() < 1e9) { + continue; + } + let q = (p[0], p[1]); + match lp.last_mut() { + // The release point is kept: it replaces a sample just before it. + Some(l) if len(sub(q, *l)) < min_step => { + if i == last && lp.len() > 1 { + *lp.last_mut()? = q; + } + } + _ => lp.push(q), + } + } + if closed { + while lp.len() > 3 && lp.last().zip(lp.first()).is_some_and(|(a, b)| len(sub(*a, *b)) < min_step) { + lp.pop(); + } + return (lp.len() >= 3).then(|| fit_closed(&lp, tol, window)); + } + (lp.len() >= 2).then(|| fit_open(&lp, tol, window)) +} + +/// Fits an open polyline with cubic Béziers, one run between consecutive corners at a time. +fn fit_open(lp: &[P], tol: f64, window: f64) -> Subpath { + let n = lp.len(); + let mut cuts = vec![0]; + cuts.extend(corners(lp, window, false)); + cuts.push(n - 1); + cuts.dedup(); + let mut segs: Vec<[P; 4]> = Vec::new(); + let mut corner_at_start: Vec = Vec::new(); + for w in cuts.windows(2) { + let Some(pts) = lp.get(w[0]..=w[1]) else { continue }; + let m = pts.len(); + if m < 2 { + continue; + } + let (a, b) = (pts[0], pts[m - 1]); + let before = segs.len(); + if pts.iter().all(|&q| segment_distance(q, a, b) <= tol) { + // A straight run (a drag along a ruler, a zig-zag leg) is one straight segment: end + // tangents estimated from jittery samples would bend it. + let third = mul(sub(b, a), 1.0 / 3.0); + segs.push([a, add(a, third), sub(b, third), b]); + } else { + // End tangents over a stretch of the trace, not the first jittery sample. + let reach = (2.0 * window).min(len(sub(b, a)) / 3.0).max(window); + let t1 = norm(sub(walk(pts, 0, reach, true, false), a)); + let t2 = norm(sub(walk(pts, m - 1, reach, false, false), b)); + fit_cubic(pts, t1, t2, tol * tol, &mut segs, 0); + } + corner_at_start.extend((before..segs.len()).map(|k| k == before)); + } + let pt = |p: P| Point::new(p.0, p.1); + let mut knots: Vec = Vec::with_capacity(segs.len() + 1); + for (i, s) in segs.iter().enumerate() { + let in_ctrl = if i == 0 { s[0] } else { segs[i - 1][2] }; + knots.push(Knot { anchor: pt(s[0]), in_ctrl: pt(in_ctrl), out_ctrl: pt(s[1]), smooth: i > 0 && !corner_at_start[i] }); + } + if let Some(s) = segs.last() { + knots.push(Knot { anchor: pt(s[3]), in_ctrl: pt(s[2]), out_ctrl: pt(s[3]), smooth: false }); + } + Subpath { closed: false, knots, op: PathOp::Combine } +} + +/// Distance from `q` to the segment `a`–`b`. +fn segment_distance(q: P, a: P, b: P) -> f64 { + let ab = sub(b, a); + let l2 = dot(ab, ab); + let t = if l2 > 1e-18 { (dot(sub(q, a), ab) / l2).clamp(0.0, 1.0) } else { 0.0 }; + len(sub(q, add(a, mul(ab, t)))) +} + fn bezier(b: &[P; 4], t: f64) -> P { let mt = 1.0 - t; let (a, bb, c, d) = (mt * mt * mt, 3.0 * mt * mt * t, 3.0 * mt * t * t, t * t * t); @@ -443,4 +551,72 @@ mod tests { assert!(k.len() >= 4 && k.len() <= 12, "{} knots", k.len()); assert!(k.iter().filter(|k| !k.smooth).count() >= 4); } + + /// Largest distance from a sample to the fitted subpath (each segment densely sampled). + fn max_deviation(sp: &Subpath, samples: &[[f64; 2]]) -> f64 { + let curve: Vec

= sp + .segments() + .iter() + .flat_map(|s| { + let b = [(s[0].x, s[0].y), (s[1].x, s[1].y), (s[2].x, s[2].y), (s[3].x, s[3].y)]; + (0..=400).map(move |k| bezier(&b, k as f64 / 400.0)) + }) + .collect(); + samples.iter().map(|q| curve.iter().map(|c| len(sub(*c, (q[0], q[1])))).fold(f64::MAX, f64::min)).fold(0.0, f64::max) + } + + /// Freeform Pen fixtures at the default 2 px Curve Fit. Every sample stays within the + /// tolerance (plus 0.05 px for sampling the fitted curve); the anchor bounds follow from the + /// geometry: a straight drag needs no inner anchor, the zig-zag's five legs need their four + /// corners and nothing between, and a circle of radius 80 is within 0.03 px of four cubic + /// quarter arcs, so the fit never needs more than eight anchors; a sine never more than two + /// segments per half period. + #[test] + fn fit_trace_follows_fixtures_with_few_anchors() { + use std::f64::consts::TAU; + let tol = 2.0; + // Straight drag with ±0.5 px of hand jitter, 3 px apart. + let line: Vec<[f64; 2]> = (0..=100).map(|i| [10.0 + 3.0 * i as f64, 50.0 + if i % 2 == 0 { 0.5 } else { -0.5 }]).collect(); + let sp = fit_trace(&line, tol, false).unwrap(); + assert!(!sp.closed); + assert_eq!(sp.knots.len(), 2, "a straight drag is one segment"); + assert!(max_deviation(&sp, &line) <= tol + 0.05); + // Zig-zag: five 60 px legs at right angles, sampled every 2 px. + let turns = [[0.0, 0.0], [60.0, 0.0], [60.0, 60.0], [120.0, 60.0], [120.0, 120.0], [180.0, 120.0]]; + let zig: Vec<[f64; 2]> = turns + .windows(2) + .flat_map(|w| (0..30).map(move |k| [w[0][0] + (w[1][0] - w[0][0]) * k as f64 / 30.0, w[0][1] + (w[1][1] - w[0][1]) * k as f64 / 30.0])) + .chain([turns[5]]) + .collect(); + let sp = fit_trace(&zig, tol, false).unwrap(); + let anchors: Vec<[f64; 2]> = sp.knots.iter().map(|k| [k.anchor.x, k.anchor.y]).collect(); + assert_eq!(anchors, turns, "the corners are kept as anchors, nothing between"); + assert!(sp.knots[1..5].iter().all(|k| !k.smooth)); + assert!(max_deviation(&sp, &zig) <= tol + 0.05); + // Circle, radius 80, 180 samples, closed. + let circle: Vec<[f64; 2]> = (0..180).map(|i| (i as f64).to_radians() * 2.0).map(|a| [100.0 + 80.0 * a.cos(), 100.0 + 80.0 * a.sin()]).collect(); + let sp = fit_trace(&circle, tol, true).unwrap(); + assert!(sp.closed); + assert!((2..=8).contains(&sp.knots.len()), "{} anchors", sp.knots.len()); + assert!(sp.knots.iter().all(|k| k.smooth), "a circle has no corners"); + assert!(max_deviation(&sp, &circle) <= tol + 0.05); + // Two periods of a sine wave (amplitude 40, wavelength 200) with the same jitter: at most + // two segments per half period. + let sine: Vec<[f64; 2]> = + (0..=200).map(|i| i as f64 * 2.0).enumerate().map(|(i, x)| [x, 100.0 + 40.0 * (x / 200.0 * TAU).sin() + 0.5 - (i % 2) as f64]).collect(); + let sp = fit_trace(&sine, tol, false).unwrap(); + assert!((3..=9).contains(&sp.knots.len()), "{} anchors", sp.knots.len()); + assert!(sp.knots[1..sp.knots.len() - 1].iter().all(|k| k.smooth)); + assert!(max_deviation(&sp, &sine) <= tol + 0.05); + } + + #[test] + fn fit_trace_survives_hostile_input() { + assert!(fit_trace(&[], 2.0, false).is_none()); + assert!(fit_trace(&[[1.0, 1.0]; 50], 2.0, false).is_none()); + assert!(fit_trace(&[[f64::NAN, 0.0], [f64::INFINITY, 1.0], [1e300, 2.0]], f64::NAN, true).is_none()); + let long: Vec<[f64; 2]> = (0..200_000).map(|i| [i as f64 * 0.01, ((i as f64) * 0.001).sin() * 40.0]).collect(); + let sp = fit_trace(&long, -5.0, false).unwrap(); + assert!(sp.knots.len() >= 2 && sp.knots.iter().all(|k| k.anchor.x.is_finite() && k.out_ctrl.y.is_finite())); + } } diff --git a/docs/gaps.md b/docs/gaps.md index e6f4a3c848..af1911959b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -54,10 +54,10 @@ what an agent can't finish alone. scripted check there) is the only real oracle. ### G6. Missing tools -- **Missing (13 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color - Replacement (engine command exists), Art History Brush, Freeform Pen and Curvature Pen (#1054), - Add Anchor Point, Delete Anchor Point, Convert Point (only as Pen + ⌥), Horizontal Type Mask, - Vertical Type Mask. +- **Missing (11 of 68):** Artboard, Perspective Crop, Frame, Color Sampler (#1046), Color + Replacement (engine command exists), Art History Brush, Add Anchor Point, Delete Anchor Point, + Convert Point (only as Pen + ⌥), Horizontal Type Mask, Vertical Type Mask. Freeform Pen and + Curvature Pen landed (#1054). - **Evidence:** `Tool` enum (`crates/ui-egui/src/state.rs`) against the 67 `` entries of Photoshop's `Default Keyboard Shortcuts.kys` plus Remove, Triangle and Curvature Pen. - **Impact:** users look for them by name and shortcut; several are daily tools for designers. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4be29aa691..97a42dd41e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -61,7 +61,7 @@ architecture and the PSD writer are the serial parts. | Component | Weight | Ours / Photoshop | % | Kind | |---|---:|---|---:|---| | Menu commands | 30% | 628 / 628 catalog items dispatch a live command | 100% | measured (`cargo xtask parity`) | -| Toolbox tools | 15% | 53 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 78% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | +| Toolbox tools | 15% | 55 / 68 (Photoshop's 67 `.kys` tools plus Remove, Triangle, Curvature Pen; minus legacy Rounded Rectangle and Targeted Adjustment) | 81% | measured (`Tool` enum in `ui-egui/src/state.rs`, `TOOL_SECTIONS` in `panels.rs`) | | Panels | 10% | 30 / 35 Window-menu panels | 86% | measured (`view_cmds.rs`, panel modules) | | Filters and adjustments | 10% | every Filter and Image › Adjustments item live; Filter Gallery 47 looks | 100% | measured (menu catalog) | | File formats (usage-weighted) | 15% | 13 of 31 Photoshop formats supported in Photoshop's directions, 4 partial; the common ones (PSD/PSB, TIFF, JPEG, PNG, WebP) all present | ~80% (48% by count) | measured list, estimated weights ([file-format-parity.md](file-format-parity.md)) | @@ -174,7 +174,7 @@ parity for the area and are already contained in the dimension rows above. | Transform, warp, puppet, perspective, liquify | 55% | 100–180 | Free Transform modifiers right; no transform context menu (TOOL-214-5), no skew fields, puppet mesh is a block grid (TOOL-214-6), Liquify called "useless" by a user (#2606) | | Adjustments and filters | 70% | 120–200 | All present; smart-filter re-render matches Photoshop on 5 of 30 files; Filter Gallery looks approximate; no Neural Filters | | Type | 40% | 120–200 | Engine works; no type on a path, manual kerning, RTL paragraphs, OpenType panel UI, live Warp Text preview; Type tool freeze on macOS (#2518) | -| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Missing Freeform/Curvature Pen, anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | +| Vector shapes and paths | 55% | 80–140 | Pen, Direct/Path Selection, shapes, shape strokes. Freeform and Curvature Pen (#1054). Missing anchor tools, on-canvas corner radius, path-point nudge; shape parity tracking issue #2496 | | Colour management and modes | 70% | 60–100 | Own ICC engine, soft proof, CMYK/Lab/Indexed/Duotone/Multichannel; no Adobe CMYK profiles, no spot plates (FILE-215-17), monitor profile only at launch | | Camera Raw and computational photo (Photomerge, HDR, lens) | 45% | 120–200 | Raw for DNG/CR2/NEF/ARW/RW2/ORF/RAF(uncompressed)/PEF; no CR3; ACR dialog partial (no masking, calibration); generic lens profiles only | | Automation, actions, batch, scripting | 45% | 120–200 | CLI/MCP/control channel ahead of Photoshop; Actions panel and recording not document-independent (AUTO-219-1/2), no .atn, no ExtendScript/UXP | @@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b078fd3f86..d1fefd83b3 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -40,7 +40,7 @@ Triangle and Curvature Pen (minus legacy Rounded Rectangle and Targeted Adjustme | Eraser | Eraser, Background Eraser, Magic Eraser | | | Fill | Gradient, Paint Bucket | | | Focus, toning | Blur, Sharpen, Smudge, Dodge, Burn, Sponge | | -| Pen | Pen | Freeform Pen, Curvature Pen (#1054), Add Anchor Point, Delete Anchor Point, Convert Point (⌥ with Pen only) | +| Pen | Pen, Freeform Pen, Curvature Pen (#1054) | Add Anchor Point, Delete Anchor Point, Convert Point (⌥ with Pen only) | | Type | Horizontal, Vertical | Horizontal Type Mask, Vertical Type Mask | | Path selection | Path Selection, Direct Selection | | | Shapes | Rectangle, Ellipse, Triangle, Polygon, Line, Custom Shape | (Star is a Polygon option in Photoshop) |