Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085) (#3087)

* Tools: Freeform Pen and Curvature Pen (#1054)

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 <[email protected]>

* Freeform Pen: Alt-clicks draw straight segments; Cmd/Ctrl is Direct Selection with the new pens (#3085)

The Freeform Pen now draws through lasso_ui, as the Lasso does: Alt held at
the release keeps the path open, each Alt-click adds a straight segment,
releasing Alt with the button down goes on freehand, with it up finishes
the path; clicking the start point closes it. freeform_finish fits each
freehand run on its own and joins the runs with straight segments between
corner anchors. Cmd/Ctrl with the Freeform or Curvature Pen is the
temporary Direct Selection, as with the Pen (#790); it finishes a path in
progress first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Valentin KleinandClaude Opus 5.5 authored and GitHub committed 2026-10-11 18:16:39 -04:00
1 parent 3455234537
commit f0d3156ad6
21 files changed
+235 -54

No files matched your search

+11 -6
View File
@@ -2931,13 +2931,14 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
}
let lasso_retracted = response.secondary_clicked()
&& match tool {
Tool::Lasso => crate::lasso_ui::undo_last_vertex(app),
Tool::Lasso | Tool::FreeformPen => crate::lasso_ui::undo_last_vertex(app),
Tool::PolygonLasso => polygon_retract(app),
_ => false,
};
// The Lasso reads its own presses and drops the moves of a drag it didn't start, so an
// open Free Transform box takes the usual drag path instead (#2153).
if tool == Tool::Lasso && !transform_owns_pointer {
// The Lasso (and the Freeform Pen, which draws the same way) reads its own presses and
// drops the moves of a drag it didn't start, so an open Free Transform box takes the
// usual drag path instead (#2153).
if crate::lasso_ui::owns(tool) && !transform_owns_pointer {
crate::lasso_ui::canvas_input(app, &ctx, &xf, &response);
(buttons.started, buttons.dragged, buttons.stopped, buttons.clicked) = (false, false, false, false);
}
@@ -4100,7 +4101,11 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
}
}
Tool::FreeformPen => {
let pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
let mut pts: Vec<Pos2> = d.points.iter().map(|p| xf.to_screen(p[0] as f32, p[1] as f32)).collect();
// Between ⌥-clicks: the straight segment the next click makes.
if let Some(lasso) = &d.lasso {
pts.push(xf.to_screen(lasso.cursor[0] as f32, lasso.cursor[1] as f32));
}
painter.add(egui::Shape::line(pts, Stroke::new(1.5, crate::theme::Tokens::get(painter.ctx()).accent)));
}
Tool::Lasso | Tool::Patch | Tool::ContentAwareMove => {
@@ -4855,7 +4860,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),
Tool::FreeformPen => crate::vector_ui::freeform_finish(app, &d.points, d.lasso.as_ref().map_or(&[], |l| l.straight())),
// 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())
+14 -7
View File
@@ -1,6 +1,7 @@
//! Direct Selection tool (A, #790), the Convert Point tool (#3204), and the Pen's ⌘/Ctrl (Direct
//! Selection) and ⌥/Alt (Convert Point) modifiers, as in Photoshop. It edits the paths the canvas
//! shows: the targeted vector mask or the active shape layer's path, and the work path.
//! Direct Selection tool (A, #790), the Convert Point tool (#3204), the Pens' ⌘/Ctrl (Direct
//! Selection: the Pen, Freeform Pen and Curvature Pen) and the Pen's ⌥/Alt (Convert Point)
//! modifiers, as in Photoshop. It edits the paths the canvas shows: the targeted vector mask or
//! the active shape layer's path, and the work path.
//!
//! * Click an anchor to select it (⇧ adds or removes, ⌥ selects its whole subpath), drag on empty
//! canvas to marquee-select anchors, click empty canvas to deselect.
@@ -106,10 +107,12 @@ impl Edit {
}
}
/// Is Direct Selection what the pointer does now: its own tool, or the Pen, an anchor point tool
/// or Convert Point with ⌘/Ctrl held.
/// Is Direct Selection what the pointer does now: its own tool, or a Pen (the Pen, Freeform Pen
/// or Curvature Pen), an anchor point tool or Convert Point with ⌘/Ctrl held.
fn direct(tool: Tool, mods: Modifiers) -> bool {
tool == Tool::DirectSelection || (matches!(tool, Tool::Pen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint) && mods.command)
tool == Tool::DirectSelection
|| (matches!(tool, Tool::Pen | Tool::FreeformPen | Tool::CurvaturePen | Tool::AddAnchorPoint | Tool::DeleteAnchorPoint | Tool::ConvertPoint)
&& mods.command)
}
/// Is Convert Point what the pointer does now: its own tool, or the Pen with ⌥ held while no path
@@ -173,10 +176,14 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
if !(direct(tool, mods) || convert) {
return false;
}
if tool == Tool::Pen && !pen_idle {
if !pen_idle && tool != Tool::DirectSelection {
// ⌘-click ends the path being drawn, which becomes editable, as in Photoshop.
crate::vector_ui::pen_commit(app, false);
}
if tool == Tool::FreeformPen {
// The same for a Freeform Pen path waiting for its next ⌥-click.
crate::lasso_ui::commit(app);
}
press(app, [x, y], mods, convert)
}
ToolEvent::Move { x, y, .. } => {
+1 -1
View File
@@ -2563,7 +2563,7 @@ cameraData Lens Objektiv
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Tažení: kreslit od ruky · {alt}+klik: rovný segment · 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í…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -2594,7 +2594,7 @@ cameraData Lens Objektiv
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Ziehen: frei zeichnen · {alt}-Klick: gerades Segment · 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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -3654,7 +3654,7 @@ cameraData Lens Φακός
Freeform Pen Tool Πένα ελεύθερης σχεδίασης
Curvature Pen Tool Πένα καμπυλότητας
Curve Fit: Προσαρμογή καμπύλης:
Drag: draw freehand · Release on the start point: close Σύρσιμο: ελεύθερη σχεδίαση · Άφημα στο αρχικό σημείο: κλείσιμο
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Σύρσιμο: ελεύθερη σχεδίαση · {alt}-κλικ: ευθύ τμήμα · Άφημα στο αρχικό σημείο: κλείσιμο
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Κλικ: ομαλό σημείο · Διπλό κλικ: γωνία · Σύρσιμο σημείου: μετακίνηση · Κλικ στο πρώτο σημείο: κλείσιμο · {key}: τέλος
New Crop Preset… Νέα προεπιλογή περικοπής…
Delete Crop Preset… Διαγραφή προεπιλογής περικοπής…
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+1 -1
View File
@@ -2635,7 +2635,7 @@ cameraData Lens Objetivo
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Arrastrar: dibujar a mano alzada · {alt}-clic: segmento recto · 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…
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1 -1
View File
@@ -2972,7 +2972,7 @@ cameraData Lens Objectif
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Glisser : dessin à main levée · {alt}-clic : segment droit · 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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -2959,7 +2959,7 @@ cameraData Lens Lensa
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Seret: gambar bebas · {alt}-klik: segmen lurus · 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…
Can't render this file because it contains an unexpected character in line 4 and column 47.
+1 -1
View File
@@ -2626,7 +2626,7 @@ cameraData Lens Obiettivo
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Trascina: disegna a mano libera · {alt}-clic: segmento retto · 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…
Can't render this file because it contains an unexpected character in line 2586 and column 53.
+1 -1
View File
@@ -3568,7 +3568,7 @@ cameraData Lens レンズ
Freeform Pen Tool フリーフォームペンツール
Curvature Pen Tool 曲線ペンツール
Curve Fit: カーブフィット:
Drag: draw freehand · Release on the start point: close ドラッグ: フリーハンドで描画 · 始点で離す: 閉じる
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close ドラッグ: フリーハンドで描画 · {alt}+クリック: 直線セグメント · 始点で離す: 閉じる
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish クリック: スムーズポイント · ダブルクリック: 角 · 点をドラッグ: 移動 · 最初の点をクリック: 閉じる · {key} 完了
New Crop Preset… 新規切り抜きプリセット…
Delete Crop Preset… 切り抜きプリセットを削除…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -3137,7 +3137,7 @@ cameraData Lens 렌즈
Freeform Pen Tool 자유 형태 펜 도구
Curvature Pen Tool 곡률 펜 도구
Curve Fit: 곡선 맞춤:
Drag: draw freehand · Release on the start point: close 드래그: 자유롭게 그리기 · 시작점에서 놓기: 닫기
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close 드래그: 자유롭게 그리기 · {alt}-클릭: 직선 세그먼트 · 시작점에서 놓기: 닫기
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 클릭: 매끄러운 점 · 더블 클릭: 모서리 · 점 드래그: 이동 · 첫 점 클릭: 닫기 · {key}: 완료
New Crop Preset… 새 자르기 사전 설정…
Delete Crop Preset… 자르기 사전 설정 삭제…
Can't render this file because it contains an unexpected character in line 3095 and column 53.
+1 -1
View File
@@ -3714,7 +3714,7 @@ cameraData Lens Lens
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Slepen: uit de vrije hand tekenen · {alt}-klik: recht segment · 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…
Can't render this file because it contains an unexpected character in line 8 and column 34.
+1 -1
View File
@@ -3570,7 +3570,7 @@ cameraData Lens Obiektyw
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Przeciągnięcie: rysowanie odręczne · {alt}-klik: prosty segment · 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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -2977,7 +2977,7 @@ cameraData Lens Lente
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
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Arrastar: desenhar à mão livre · {alt}-clique: segmento reto · 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…
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -2899,7 +2899,7 @@ cameraData Lens Объектив
Freeform Pen Tool Свободное перо
Curvature Pen Tool Перо кривизны
Curve Fit: Точность кривой:
Drag: draw freehand · Release on the start point: close Перетаскивание: рисовать от руки · отпустить на начальной точке: замкнуть
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Перетаскивание: рисовать от руки · {alt}-щелчок: прямой сегмент · отпустить на начальной точке: замкнуть
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Клик: гладкая точка · двойной клик: угол · перетаскивание точки: переместить · клик по первой точке: замкнуть · {key} завершить
Scale: Масштаб:
Snap to Origin Привязать к началу координат
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -3651,7 +3651,7 @@ cameraData Lens Об'єктив
Freeform Pen Tool Інструмент «Довільне перо»
Curvature Pen Tool Інструмент «Перо кривизни»
Curve Fit: Точність кривої:
Drag: draw freehand · Release on the start point: close Перетягування: малювати від руки · Відпускання на початковій точці: замкнути
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close Перетягування: малювати від руки · {alt}+клацання: прямий сегмент · Відпускання на початковій точці: замкнути
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish Клацання: плавна точка · Подвійне клацання: кут · Перетягування точки: перемістити · Клацання першої точки: замкнути · {key}: завершити
Search settings… Пошук налаштувань…
No matching settings Немає відповідних налаштувань
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+1 -1
View File
@@ -2570,7 +2570,7 @@ cameraData Lens 镜头
Freeform Pen Tool 自由钢笔工具
Curvature Pen Tool 弯度钢笔工具
Curve Fit: 曲线拟合:
Drag: draw freehand · Release on the start point: close 拖动:自由绘制 · 在起点松开:闭合
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close 拖动:自由绘制 · {alt}-单击:直线段 · 在起点松开:闭合
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 单击:平滑点 · 双击:角点 · 拖动点:移动 · 单击起点:闭合 · {key} 完成
Scale: 缩放:
Snap to Origin 贴紧原点
Can't render this file because it contains an unexpected character in line 2528 and column 53.
+1 -1
View File
@@ -2540,7 +2540,7 @@ cameraData Lens 鏡頭
Freeform Pen Tool 自由筆型工具
Curvature Pen Tool 曲線筆工具
Curve Fit: 曲線擬合:
Drag: draw freehand · Release on the start point: close 拖曳:手繪 · 在起點放開:閉合
Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close 拖曳:手繪 · {alt}-按一下:直線段 · 在起點放開:閉合
Click: smooth point · Double-click: corner · Drag a point: move · Click first point: close · {key} finish 按一下:平滑點 · 按兩下:轉角 · 拖曳點:移動 · 按一下第一點:閉合 · {key} 完成
Scale: 縮放:
Snap to Origin 靠齊原點
Can't render this file because it contains an unexpected character in line 6 and column 47.
+58 -18
View File
@@ -4,6 +4,10 @@
//! Selection intent is latched at the first press: Alt held then subtracts from an existing
//! selection; with no selection there is nothing to subtract, so it starts straight segments.
//! Alt pressed later changes drawing mode.
//!
//! The Freeform Pen draws the same way (Photoshop): a drag is freehand, Alt-clicks add straight
//! segments, releasing Alt with the button down goes on freehand and with it up finishes the
//! path (`vector_ui::freeform_finish`, which keeps the straight segments straight).
use crate::{
PhotocraftApp,
@@ -18,20 +22,34 @@ pub struct Lasso {
down: bool,
polygonal: bool,
document: Option<photocraft_doc::DocId>,
/// Indices of the points an Alt-click added: the segment into each one is straight.
straight: Vec<usize>,
}
impl Lasso {
/// Indices into the drag's points whose incoming segment is straight (an Alt-click).
pub fn straight(&self) -> &[usize] {
&self.straight
}
}
/// The tools this module draws with: the Lasso and the Freeform Pen.
pub fn owns(tool: Tool) -> bool {
matches!(tool, Tool::Lasso | Tool::FreeformPen)
}
pub fn active(app: &PhotocraftApp) -> bool {
app.drag.as_ref().is_some_and(|d| d.tool == Tool::Lasso && d.lasso.is_some())
app.drag.as_ref().is_some_and(|d| owns(d.tool) && d.lasso.is_some())
}
/// Between polygonal clicks, Backspace/right-click retracts the last fixed vertex.
/// Do not affect a freehand stroke whose primary button is still held.
pub fn waiting_for_vertex(app: &PhotocraftApp) -> bool {
app.ui.tool == Tool::Lasso
owns(app.ui.tool)
&& app
.drag
.as_ref()
.is_some_and(|d| d.tool == Tool::Lasso && d.lasso.as_ref().is_some_and(|l| !l.down && l.document == app.session.active().map(|st| st.doc.id)))
.is_some_and(|d| d.tool == app.ui.tool && d.lasso.as_ref().is_some_and(|l| !l.down && l.document == app.session.active().map(|st| st.doc.id)))
}
pub fn undo_last_vertex(app: &mut PhotocraftApp) -> bool {
@@ -43,6 +61,10 @@ pub fn undo_last_vertex(app: &mut PhotocraftApp) -> bool {
app.drag = None;
} else if let Some(d) = app.drag.as_mut() {
d.points.pop();
let n = d.points.len();
if let Some(lasso) = &mut d.lasso {
lasso.straight.retain(|&i| i < n);
}
}
true
}
@@ -54,19 +76,20 @@ fn moving_selection(app: &PhotocraftApp) -> bool {
}
/// A drag the canvas handles rather than this module, which only feeds it the events: moving the
/// selection, a guide that ⌘ took (`canvas::command_guide_at`, #2690), or the layer that ⌘ moves
/// (`canvas::command_moves_layer`, refused on a locked layer: `move_blocked`, #2725).
/// selection, a guide that ⌘ took (`canvas::command_guide_at`, #2690), the layer that ⌘ moves
/// (`canvas::command_moves_layer`, refused on a locked layer: `move_blocked`, #2725), or a path
/// that the Freeform Pen's ⌘ (Direct Selection) edits.
fn handed_over(app: &PhotocraftApp) -> bool {
moving_selection(app) || app.guide_drag.is_some_and(|d| d.index.is_some()) || app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move) || app.move_blocked
moving_selection(app)
|| app.guide_drag.is_some_and(|d| d.index.is_some())
|| app.drag.as_ref().is_some_and(|d| d.tool == Tool::Move)
|| app.move_blocked
|| app.ui.direct_selection.drag.is_some()
}
pub fn cancel_stale(app: &mut PhotocraftApp) {
if app
.drag
.as_ref()
.and_then(|d| d.lasso.as_ref())
.is_some_and(|lasso| app.ui.tool != Tool::Lasso || lasso.document != app.session.active().map(|st| st.doc.id))
{
let document = app.session.active().map(|st| st.doc.id);
if app.drag.as_ref().is_some_and(|d| d.lasso.as_ref().is_some_and(|lasso| app.ui.tool != d.tool || lasso.document != document)) {
app.drag = None;
}
}
@@ -78,7 +101,7 @@ fn append(d: &mut Drag, p: [f64; 2]) {
}
fn modifiers(app: &mut PhotocraftApp, mods: Modifiers) {
let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::Lasso) else { return };
let Some(d) = app.drag.as_mut().filter(|d| owns(d.tool)) else { return };
d.track(mods);
// Alt held before the initial press still means subtract, until released and pressed again.
let polygonal = mods.alt && (!d.modifiers.alt || d.released.alt);
@@ -107,7 +130,8 @@ pub fn commit(app: &mut PhotocraftApp) {
pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool {
cancel_stale(app);
if app.ui.tool != Tool::Lasso || handed_over(app) {
let tool = app.ui.tool;
if !owns(tool) || handed_over(app) {
return false;
}
let p = match ev {
@@ -116,6 +140,7 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
// A press inside the selection (no outline in progress) drags the selection instead; a ⌘
// press outside it moves the layer, as the Move tool (#2725).
if matches!(ev, ToolEvent::Down { .. })
&& tool == Tool::Lasso
&& !active(app)
&& p.iter().all(|n| n.is_finite())
&& (crate::canvas::selection_drag_kind(app, Tool::Lasso, p, mods).is_some() || crate::canvas::command_moves_layer(app, Tool::Lasso, p, mods))
@@ -134,30 +159,45 @@ pub fn pointer(app: &mut PhotocraftApp, ev: ToolEvent, mods: Modifiers) -> bool
let near = |q: [f64; 2]| (q[0] - p[0]).hypot(q[1] - p[1]) < reach;
let close = app.drag.as_ref().is_some_and(|d| d.points.len() >= 3 && (near(d.start) || d.points.last().is_some_and(|l| near([l[0], l[1]]))));
if close {
// A path closes on its start point (a selection outline always closes).
if let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::FreeformPen && near(d.start)) {
let (start, n) = (d.start, d.points.len());
append(d, start);
if let Some(lasso) = d.lasso.as_mut().filter(|_| d.points.len() > n) {
lasso.straight.push(n);
}
}
commit(app);
return true;
}
if let Some(d) = &mut app.drag {
let n = d.points.len();
append(d, p);
let added = d.points.len() > n;
if let Some(lasso) = &mut d.lasso {
lasso.cursor = p;
lasso.down = true;
if added {
lasso.straight.push(n);
}
}
}
} else {
// Alt with no selection has nothing to subtract from: the outline starts with
// straight segments, as a new selection.
let nothing_selected = app.session.active().is_none_or(|st| st.doc.selection.is_none());
// A path has nothing to subtract from either.
let nothing_selected = tool == Tool::FreeformPen || app.session.active().is_none_or(|st| st.doc.selection.is_none());
let polygonal = mods.alt && nothing_selected;
let intent = if polygonal { Modifiers { alt: false, ..mods } } else { mods };
let mut d = Drag::new(Tool::Lasso, p, vec![[p[0], p[1], 1.0]], intent, false);
d.lasso = Some(Lasso { cursor: p, down: true, polygonal, document: app.session.active().map(|st| st.doc.id) });
let mut d = Drag::new(tool, p, vec![[p[0], p[1], 1.0]], intent, false);
let document = app.session.active().map(|st| st.doc.id);
d.lasso = Some(Lasso { cursor: p, down: true, polygonal, document, straight: Vec::new() });
app.drag = Some(d);
}
}
ToolEvent::Move { .. } | ToolEvent::Up { .. } => {
let up = matches!(ev, ToolEvent::Up { .. });
let Some(d) = app.drag.as_mut().filter(|d| d.tool == Tool::Lasso) else { return true };
let Some(d) = app.drag.as_mut().filter(|d| d.tool == tool) else { return true };
let Some(lasso) = &d.lasso else { return true };
let (down, polygonal, previous) = (lasso.down, lasso.polygonal, lasso.cursor);
if d.reposition {
+65
View File
@@ -104,3 +104,68 @@ fn curvature_pen_clicks_make_a_smooth_path_through_every_point() {
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);
}
fn ev(app: &mut PhotocraftApp, kind: &str, x: f64, y: f64, mods: Modifiers) {
let e = match kind {
"down" => ToolEvent::Down { x, y, pressure: 1.0 },
"up" => ToolEvent::Up { x, y },
_ => ToolEvent::Move { x, y, pressure: 1.0 },
};
tool_event(app, e, mods);
}
#[test]
fn freeform_pen_alt_clicks_draw_straight_segments_between_freehand_runs() {
// Photoshop: ⌥ held at the release keeps the path open, each ⌥-click adds a straight segment,
// releasing ⌥ with the button down goes on freehand.
let mut app = app(Tool::FreeformPen);
app.sync_views();
let before = app.session.active().unwrap().history.entries().len();
ev(&mut app, "down", 20.0, 20.0, Modifiers::NONE);
for x in (22..=80).step_by(2) {
ev(&mut app, "move", f64::from(x), 20.0, Modifiers::NONE);
}
ev(&mut app, "move", 80.0, 20.0, Modifiers::ALT);
ev(&mut app, "up", 80.0, 20.0, Modifiers::ALT);
assert!(app.session.active().unwrap().doc.work_path.is_none(), "⌥ at the release keeps the path open");
// Hovering only moves the rubber band; the click adds a straight segment.
ev(&mut app, "move", 120.0, 40.0, Modifiers::ALT);
ev(&mut app, "down", 80.0, 100.0, Modifiers::ALT);
// ⌥ released with the button down: freehand again, a half circle down to (20, 100).
for i in 0..=36 {
let a = (f64::from(i) * 5.0).to_radians();
ev(&mut app, "move", 50.0 + 30.0 * a.cos(), 100.0 + 30.0 * a.sin(), Modifiers::NONE);
}
ev(&mut app, "up", 20.0, 100.0, Modifiers::NONE);
let path = work_path(&app);
let knots = &path.subpaths[0].knots;
let anchors: Vec<[f64; 2]> = knots.iter().map(|k| [k.anchor.x.round(), k.anchor.y.round()]).collect();
assert_eq!(anchors[..3], [[20.0, 20.0], [80.0, 20.0], [80.0, 100.0]], "{anchors:?}");
assert_eq!(anchors.last(), Some(&[20.0, 100.0]));
assert!(!anchors.contains(&[120.0, 40.0]), "the hover is not a point");
// The ⌥-click segment is straight: no handles on either end of it.
assert_eq!((knots[1].out_ctrl, knots[2].in_ctrl), (knots[1].anchor, knots[2].anchor));
assert!(!knots[1].smooth && !knots[2].smooth);
// The freehand run after it curves.
assert!(knots[2..].iter().any(|k| k.out_ctrl != k.anchor), "{knots:?}");
assert_eq!(app.session.active().unwrap().history.entries().len(), before + 1, "one History step");
}
#[test]
fn new_pens_with_command_edit_paths_as_direct_selection() {
for tool in [Tool::FreeformPen, Tool::CurvaturePen] {
let mut app = app(Tool::Pen);
click(&mut app, 30.0, 30.0);
click(&mut app, 90.0, 30.0);
crate::vector_ui::pen_commit(&mut app, false);
app.ui.tool = tool;
// ⌘-drag (Ctrl off the Mac) on an anchor moves it; the tool stays.
ev(&mut app, "down", 90.0, 30.0, Modifiers::COMMAND);
ev(&mut app, "move", 90.0, 70.0, Modifiers::COMMAND);
ev(&mut app, "up", 90.0, 70.0, Modifiers::COMMAND);
let wp = work_path(&app);
assert_eq!(wp.subpaths.len(), 1, "{tool:?} drew nothing");
assert_eq!([wp.subpaths[0].knots[1].anchor.x, wp.subpaths[0].knots[1].anchor.y], [90.0, 70.0], "{tool:?}");
assert_eq!(app.ui.tool, tool);
}
}
+71 -7
View File
@@ -379,17 +379,80 @@ fn commit_path(app: &mut PhotocraftApp, pen: &PenPath, 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]]) {
/// as the Pen does. Releasing on the start point closes the path; a click draws nothing. The
/// points at indices `straight` were ⌥-clicked: the segment into each one stays straight.
pub fn freeform_finish(app: &mut PhotocraftApp, points: &[[f64; 3]], straight: &[usize]) {
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);
let fit = f64::from(app.ui.tool_options.curve_fit);
let pen = if straight.is_empty() {
let Some(sub) = photocraft_vector::trace::fit_trace(&trace, fit, closed) else { return };
let knots = sub.knots.iter().map(knot_points).collect();
let unlinked = sub.knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| i).collect();
(PenPath { knots, unlinked, ..Default::default() }, sub.closed)
} else {
fit_with_straight_segments(&trace, straight, fit, closed.then_some(tol))
};
commit_path(app, &pen.0, pen.1);
}
fn knot_points(k: &photocraft_doc::Knot) -> [[f64; 2]; 3] {
[[k.anchor.x, k.anchor.y], [k.in_ctrl.x, k.in_ctrl.y], [k.out_ctrl.x, k.out_ctrl.y]]
}
/// A Freeform Pen trace with ⌥-clicked straight segments (into the points at `straight`): each
/// freehand run between them is fitted on its own, and the runs are joined by straight lines
/// between corner anchors. `close` (a reach) closes the path when it ends on its start point.
fn fit_with_straight_segments(trace: &[[f64; 2]], straight: &[usize], fit: f64, close: Option<f64>) -> (PenPath, bool) {
let mut cuts: Vec<usize> = straight.iter().copied().filter(|&i| i > 0 && i < trace.len()).collect();
cuts.sort_unstable();
cuts.dedup();
cuts.push(trace.len());
let (mut knots, mut corners): (Vec<[[f64; 2]; 3]>, Vec<usize>) = (Vec::new(), Vec::new());
let mut start = 0;
for end in cuts {
let run = trace.get(start..end).unwrap_or_default();
let first = knots.len();
match photocraft_vector::trace::fit_trace(run, fit, false) {
Some(sub) => {
corners.extend(sub.knots.iter().enumerate().filter(|(_, k)| !k.smooth).map(|(i, _)| first + i));
knots.extend(sub.knots.iter().map(knot_points));
}
// A single click (or a run that never moved): one corner anchor.
None => knots.extend(run.first().map(|p| [*p; 3])),
}
// Where a straight segment meets the run: a corner with no handle on that side.
if let Some(k) = knots.get_mut(first).filter(|_| first > 0) {
k[1] = k[0];
corners.push(first);
}
if end < trace.len()
&& let Some(k) = knots.last_mut()
{
k[2] = k[0];
corners.push(knots.len() - 1);
}
start = end;
}
// Ended on the start point: the last anchor is the first one.
let ends_on_start = |k: &[[[f64; 2]; 3]], reach: f64| match (k.first(), k.last()) {
(Some(a), Some(b)) => k.len() > 3 && (a[0][0] - b[0][0]).hypot(a[0][1] - b[0][1]) < reach,
_ => false,
};
let closed = close.is_some_and(|reach| ends_on_start(&knots, reach));
if closed && let Some(last) = knots.pop() {
if let Some(k) = knots.first_mut() {
k[1] = [last[1][0] - last[0][0] + k[0][0], last[1][1] - last[0][1] + k[0][1]];
}
corners.retain(|&i| i < knots.len());
corners.push(0);
}
corners.sort_unstable();
corners.dedup();
(PenPath { knots, unlinked: corners, ..Default::default() }, closed)
}
/// Recompute a Curvature Pen path's handles from its anchors and corner points.
@@ -806,7 +869,8 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo
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"));
let alt = crate::shortcuts::pretty("Alt");
lbl(ui, &crate::i18n::fmt(tl!("Drag: draw freehand · {alt}-click: straight segment · Release on the start point: close"), &[("alt", &alt)]));
}
Tool::CurvaturePen => lbl(
ui,