diff --git a/crates/automation/src/workspace.rs b/crates/automation/src/workspace.rs index d95c95632..51a510ca5 100644 --- a/crates/automation/src/workspace.rs +++ b/crates/automation/src/workspace.rs @@ -806,6 +806,7 @@ mod tests { | "path.deleteAnchor" | "path.clippingPath.set" | "path.rename" + | "path.stroke" | "select.toWorkPath" | "layer.vectorMask.add" | "layer.vectorMask.edit" diff --git a/crates/engine/src/vector_cmds.rs b/crates/engine/src/vector_cmds.rs index 21cc0a16d..b732ff262 100644 --- a/crates/engine/src/vector_cmds.rs +++ b/crates/engine/src/vector_cmds.rs @@ -1192,9 +1192,119 @@ fn paint_coverage(surf: &mut photocraft_raster::Surface, area: Rect, cov: &[f32] surf.prune(); } +/// Stroke Path's brush-based tools besides the Brush, Pencil and Eraser it renders itself: each +/// paints the path with its own `paint.*` command (Photoshop's Stroke Path dialog Tool list). +pub const STROKE_PATH_TOOLS: &[(&str, &str)] = &[ + ("backgroundEraser", "paint.backgroundEraser"), + ("cloneStamp", "paint.cloneStamp"), + ("patternStamp", "paint.patternStamp"), + ("healingBrush", "paint.healingBrush"), + ("spotHealingBrush", "paint.spotHealing"), + ("historyBrush", "paint.historyBrush"), + ("blur", "paint.blur"), + ("sharpen", "paint.sharpen"), + ("smudge", "paint.smudge"), + ("dodge", "paint.dodge"), + ("burn", "paint.burn"), + ("sponge", "paint.sponge"), + ("colorReplacement", "paint.colorReplacement"), + ("mixerBrush", "paint.mixerBrush"), +]; + +/// At most this many points per subpath when Simulate Pressure resamples it. +const MAX_PRESSURE_POINTS: f64 = 200_000.0; + +/// The stroke points along one flattened subpath. With `simulate` (Stroke Path's Simulate +/// Pressure) the subpath is resampled about every pixel and the pressure rises from 0 at its start +/// to full at the middle and falls back to 0 at its end, so a brush whose size or opacity follows +/// pen pressure tapers at both ends, as in Photoshop. Otherwise every point has full pressure. +fn stroke_points(pts: &[(f64, f64)], closed: bool, simulate: bool) -> Vec { + use photocraft_paint::StrokePoint; + let mut line = pts.to_vec(); + if closed && let Some(&first) = pts.first() { + line.push(first); + } + let full = |line: Vec<(f64, f64)>| line.into_iter().map(|(x, y)| StrokePoint::new(x, y, 1.0)).collect(); + if !simulate { + return full(line); + } + let seg = |a: &(f64, f64), b: &(f64, f64)| (b.0 - a.0).hypot(b.1 - a.1); + let total: f64 = line.iter().zip(line.iter().skip(1)).map(|(a, b)| seg(a, b)).sum(); + if !(total.is_finite() && total > 0.0) { + return full(line); + } + let step = (total / MAX_PRESSURE_POINTS).max(1.0); + let pressure = |d: f64| (d / total * std::f64::consts::PI).sin().clamp(0.0, 1.0) as f32; + let mut out = Vec::new(); + let mut walked = 0.0; + for (a, b) in line.iter().zip(line.iter().skip(1)) { + let len = seg(a, b); + let n = (len / step).ceil().clamp(1.0, MAX_PRESSURE_POINTS) as usize; + for i in 0..n { + let t = i as f64 / n as f64; + out.push(StrokePoint::new(a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t, pressure(walked + len * t))); + } + walked += len; + } + if let Some(&(x, y)) = line.last() { + out.push(StrokePoint::new(x, y, 0.0)); + } + out +} + +/// Stroke Path with a tool that has its own `paint.*` command: the command runs once per subpath, +/// with the caller's params (size, opacity, the tool's options) and the path's points, all in one +/// history step. Only `path.stroke` itself is journalled, so a recorded action replays it once. +fn stroke_with_command(s: &mut Session, p: &Value, cmd: &str, lines: &[vector::Polyline], simulate: bool) -> Result { + let mut params = p.as_object().cloned().unwrap_or_default(); + for k in ["name", "tool", "simulatePressure", "coalesce"] { + params.remove(k); + } + let journal = s.journal.len(); + let r = crate::analysis_cmds::compound(s, "Stroke Path", |s| { + // The union of what each subpath's command changed; None when one of them didn't say. + let mut dmg = Some(Rect::EMPTY); + for pl in lines { + let pts = stroke_points(&pl.pts, pl.closed, simulate); + if pts.is_empty() { + continue; + } + let mut call = params.clone(); + call.insert("points".into(), Value::Array(pts.iter().map(|q| json!([q.x, q.y, q.pressure])).collect())); + s.execute(cmd, Value::Object(call))?; + let r = s.active().and_then(|d| d.last_damage); + dmg = dmg.zip(r).map(|(a, b)| { + if a.is_empty() { + b + } else if b.is_empty() { + a + } else { + a.union(&b) + } + }); + } + Ok(dmg) + }); + s.journal.truncate(journal); + let dmg = r?; + if let Some(st) = s.active_mut() { + st.last_damage = dmg; + } + let dmg = dmg.unwrap_or_else(|| s.active().map_or(Rect::EMPTY, |d| d.doc.bounds())); + Ok(json!({ "damage": [dmg.x0, dmg.y0, dmg.width(), dmg.height()] })) +} + fn path_stroke(s: &mut Session, p: &Value) -> Result { let path = resolve_path(s, p.get("name").and_then(Value::as_str))?; let tool = p.get("tool").and_then(Value::as_str).unwrap_or("brush"); + let simulate = match p.get("simulatePressure") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad("path.stroke", "`simulatePressure` must be true or false"))?, + }; + if let Some(&(_, cmd)) = STROKE_PATH_TOOLS.iter().find(|(t, _)| *t == tool) { + let lines = vector::flatten_path(&path, 0.1); + return stroke_with_command(s, p, cmd, &lines, simulate); + } let base = s.tools.brush.clone(); let fg = s.tools.foreground; let mut brush = photocraft_paint::BrushSettings { @@ -1213,7 +1323,10 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { match tool { "pencil" => brush.hardness = 1.0, "brush" | "eraser" => {} - o => return Err(bad("path.stroke", format!("unknown tool `{o}` (brush|pencil|eraser)"))), + o => { + let tools: Vec<&str> = ["brush", "pencil", "eraser"].into_iter().chain(STROKE_PATH_TOOLS.iter().map(|(t, _)| *t)).collect(); + return Err(bad("path.stroke", format!("unknown tool `{o}` ({})", tools.join("|")))); + } } crate::brush_cmds::validate_brush(&brush, "path.stroke")?; let lines = vector::flatten_path(&path, 0.1); @@ -1237,12 +1350,7 @@ fn path_stroke(s: &mut Session, p: &Value) -> Result { } let mut dmg = Rect::EMPTY; for pl in &lines { - let mut pts: Vec = pl.pts.iter().map(|&(x, y)| photocraft_paint::StrokePoint::new(x, y, 1.0)).collect(); - if pl.closed - && let Some(first) = pts.first().copied() - { - pts.push(first); - } + let pts = stroke_points(&pl.pts, pl.closed, simulate); if pts.is_empty() { continue; } @@ -1689,7 +1797,7 @@ pub fn specs() -> Vec { "path.stroke", "Stroke Path", [], - r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer),"tool":"brush|pencil|eraser"="brush","size":0.5..5000 px?,"hardness":0..1?,"opacity":0..100?,"color":"#rrggbb"=foreground} (current brush settings otherwise)"##, + r##"{"name":str|"work"|"layer"="work","layer":id? (pixel layer),"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false (taper both ends when the brush follows pen pressure),"size":0.5..5000 px?,"hardness":0..1?,"opacity":0..100?,"color":"#rrggbb"=foreground} (current brush settings otherwise; tools past the Eraser run their paint.* command along the path and take its params)"##, has_layer, path_stroke ), diff --git a/crates/engine/src/vector_cmds/tests.rs b/crates/engine/src/vector_cmds/tests.rs index 582033dcf..dfb8205aa 100644 --- a/crates/engine/src/vector_cmds/tests.rs +++ b/crates/engine/src/vector_cmds/tests.rs @@ -389,6 +389,55 @@ fn fill_and_stroke_path_on_pixel_layer() { assert!(s.execute("path.fill", json!({})).is_err()); } +/// #2978: Simulate Pressure tapers the stroke at both ends (the brush's size follows pen +/// pressure); without it the stroke is as wide near its ends as in the middle. +#[test] +fn stroke_path_simulate_pressure_tapers_both_ends() { + let coverage = |simulate: bool, x: i32| -> usize { + let mut s = session(200, 60, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"closed": false, "knots": [[20, 30], [180, 30]]}]}})).unwrap(); + let p = json!({"tool": "brush", "size": 20, "hardness": 1.0, "color": "#000000", "opacity": 100, "simulatePressure": simulate}); + s.execute("path.stroke", p).unwrap(); + let d = doc(&s); + let surf = d.layer(s.active().unwrap().active_layer.unwrap()).unwrap().surface().unwrap(); + (0..60).filter(|&y| surf.rgba(x, y)[3] > 0.5).count() + }; + let (end, middle) = (coverage(true, 26), coverage(true, 100)); + assert!(middle >= 18 && end * 3 < middle, "simulated: {end} px near the start, {middle} px in the middle"); + let (end, middle) = (coverage(false, 26), coverage(false, 100)); + assert!(middle >= 18 && end + 2 >= middle, "plain: {end} px near the start, {middle} px in the middle"); +} + +/// #2978: Stroke Path runs the other brush-based tools along the path (here the Blur tool) as one +/// history step, and only the path.stroke call is journalled, so an action replays it once. +#[test] +fn stroke_path_with_the_blur_tool_is_one_step() { + let mut s = session(100, 60, 8); + s.execute("layer.new.layer", json!({})).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[0, 0], [100, 0], [100, 60], [0, 60]]}]}})).unwrap(); + s.execute("path.fill", json!({"color": "#ffffff"})).unwrap(); + s.execute("path.set", json!({"path": {"subpaths": [{"knots": [[0, 0], [50, 0], [50, 60], [0, 60]]}]}})).unwrap(); + s.execute("path.fill", json!({"color": "#000000"})).unwrap(); + let px = |s: &Session, x: i32| doc(s).layer(id).unwrap().surface().unwrap().rgba(x, 30)[0]; + assert!(px(&s, 49) < 0.01 && px(&s, 50) > 0.99); + // Two subpaths along the edge: still one step. + let edge = json!({"subpaths": [{"closed": false, "knots": [[50, 5], [50, 28]]}, {"closed": false, "knots": [[50, 28], [50, 55]]}]}); + s.execute("path.set", json!({"path": edge})).unwrap(); + let steps = s.active().unwrap().history.past_len(); + let journal = s.journal.len(); + s.execute("path.stroke", json!({"tool": "blur", "size": 10, "strength": 100})).unwrap(); + assert!(px(&s, 49) > 0.02 && px(&s, 50) < 0.98, "the edge is blurred: {} {}", px(&s, 49), px(&s, 50)); + assert_eq!(s.active().unwrap().history.past_len(), steps + 1); + let ids: Vec<&str> = s.journal[journal..].iter().map(|(c, _)| c.as_str()).collect(); + assert_eq!(ids, ["path.stroke"]); + s.execute("edit.undo", json!({})).unwrap(); + assert!(px(&s, 49) < 0.01 && px(&s, 50) > 0.99); + assert!(s.execute("path.stroke", json!({"tool": "blur", "simulatePressure": "yes"})).is_err()); + assert!(s.execute("path.stroke", json!({"tool": "airbrush"})).is_err()); +} + #[test] fn fill_path_stays_inside_the_selection() { let alpha = |s: &Session, x, y| { diff --git a/crates/ui-egui/src/canvas_tool_menu.rs b/crates/ui-egui/src/canvas_tool_menu.rs index 5674e909b..13af25d84 100644 --- a/crates/ui-egui/src/canvas_tool_menu.rs +++ b/crates/ui-egui/src/canvas_tool_menu.rs @@ -554,12 +554,21 @@ fn spec_dialog(app: &mut PhotocraftApp, command: &str, label: &str, spec: &str, app.ui.open_dialog(crate::state::DialogKind::Command, fields); } +/// Photoshop's Stroke Path dialog: the brush-based tool to stroke with (painting with that tool's +/// current options) and Simulate Pressure (#2978). +const STROKE_PATH_SPEC: &str = r##"{"tool":"brush|pencil|eraser|backgroundEraser|cloneStamp|patternStamp|healingBrush|spotHealingBrush|historyBrush|blur|sharpen|smudge|dodge|burn|sponge|colorReplacement|mixerBrush"="brush","simulatePressure":bool=false}"##; + +/// The Stroke Path dialog for the path `name` (Paths panel menu, Alt-click on its Stroke button). +pub(crate) fn stroke_path_dialog(app: &mut PhotocraftApp, name: &str) { + pen_dialog(app, "path.stroke", "Stroke Path…", name); +} + fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { let spec = match command { "edit.defineCustomShape" => r##"{"name":text}"##, "path.toSelection" => r##"{"feather":0..250=0,"antiAlias":bool=true,"mode":"replace|add|subtract|intersect"="replace"}"##, "path.fill" => r##"{"color":text,"opacity":0..100=100,"feather":0..250=0,"antiAlias":bool=true}"##, - "path.stroke" => r##"{"tool":"brush|pencil|eraser"="brush","size":1..500=10,"color":text,"opacity":0..100=100}"##, + "path.stroke" => STROKE_PATH_SPEC, "path.clippingPath.set" => r##"{"flatness":0..100=0}"##, "path.transform" => r##"{"translateX":-30000..30000=0,"translateY":-30000..30000=0,"scaleX":0.01..100=1,"scaleY":0.01..100=1,"angle":-360..360=0}"##, _ => "{}", @@ -571,7 +580,7 @@ fn pen_dialog(app: &mut PhotocraftApp, command: &str, label: &str, name: &str) { } else { fields.insert("name".into(), json!(name)); } - if matches!(command, "path.fill" | "path.stroke") { + if command == "path.fill" { let fg = app.session.tools.foreground; let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; fields.insert("color".into(), json!(format!("#{:02x}{:02x}{:02x}", byte(fg[0]), byte(fg[1]), byte(fg[2])))); diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 95d7926ab..1723b29dc 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2576,6 +2576,18 @@ cameraData Lens Objektiv Search settings… Hledat nastavení… No matching settings Žádná odpovídající nastavení Loading fonts… Načítání písem… + Tool Nástroj + Simulate Pressure Simulovat přítlak + Stroke Path… Vytáhnout cestu… + Background Eraser Guma pozadí + Clone Stamp Klonovací razítko + Healing Brush Retušovací štětec + Spot Healing Brush Bodový retušovací štětec + History Brush Štětec historie + Smudge Rozmazání + Dodge Zesvětlení + Burn Ztmavení + Sponge Houba # Select and Mask view modes (#1754) Onion Skin Cibulová slupka Marching Ants Pochodující mravenci diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b2dc76df9..1ca2933fe 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2607,6 +2607,14 @@ cameraData Lens Objektiv Search settings… Einstellungen durchsuchen… No matching settings Keine passenden Einstellungen Loading fonts… Schriften werden geladen… + Tool Werkzeug + Simulate Pressure Druck simulieren + Stroke Path… Pfad nachziehen… + Background Eraser Hintergrund-Radiergummi + Healing Brush Korrektur-Pinsel + Spot Healing Brush Punktkorrektur-Pinsel + Smudge Wischfinger + Sponge Schwamm # Select and Mask view modes (#1754) Onion Skin Zwiebelschicht Marching Ants Laufende Ameisen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 3d9bdb553..83de4aaf1 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -3667,6 +3667,15 @@ cameraData Lens Φακός Search settings… Αναζήτηση ρυθμίσεων… No matching settings Δεν βρέθηκαν ρυθμίσεις Loading fonts… Φόρτωση γραμματοσειρών… + Simulate Pressure Προσομοίωση πίεσης + Stroke Path… Περίγραμμα διαδρομής… + Background Eraser Γόμα φόντου + Healing Brush Πινέλο επιδιόρθωσης + Spot Healing Brush Πινέλο τοπικής επιδιόρθωσης + Smudge Μουντζούρωμα + Dodge Φωτισμός + Burn Σκοτείνιασμα + Sponge Σφουγγάρι # Select and Mask view modes (#1754) Onion Skin Φλούδα κρεμμυδιού Marching Ants Κινούμενο περίγραμμα diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 390c7cfd2..1e596d51b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2648,6 +2648,14 @@ cameraData Lens Objetivo Search settings… Buscar ajustes… No matching settings No hay ajustes que coincidan Loading fonts… Cargando fuentes… + Tool Herramienta + Simulate Pressure Simular presión + Stroke Path… Contornear trazado… + Background Eraser Borrador de fondo + Healing Brush Pincel corrector + Spot Healing Brush Pincel corrector puntual + Smudge Dedo + Sponge Esponja # Select and Mask view modes (#1754) Onion Skin Papel cebolla Marching Ants Hormigas en marcha diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index ff5fcfcf2..628f9717d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2985,6 +2985,14 @@ cameraData Lens Objectif Search settings… Rechercher des réglages… No matching settings Aucun réglage correspondant Loading fonts… Chargement des polices… + Tool Outil + Simulate Pressure Simuler la pression + Stroke Path… Contourner le tracé… + Background Eraser Gomme d'arrière-plan + Healing Brush Pinceau correcteur + Spot Healing Brush Correcteur localisé + Smudge Doigt + Sponge Éponge # Select and Mask view modes (#1754) Onion Skin Pelure d'oignon Marching Ants Fourmis en marche diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 53bc462b3..644330f84 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2972,6 +2972,14 @@ cameraData Lens Lensa Search settings… Cari pengaturan… No matching settings Tidak ada pengaturan yang cocok Loading fonts… Memuat font… + Tool Alat + Simulate Pressure Simulasikan Tekanan + Stroke Path… Stroke Path… + Background Eraser Background Eraser + Healing Brush Healing Brush + Spot Healing Brush Spot Healing Brush + Smudge Smudge + Sponge Sponge # Select and Mask view modes (#1754) Onion Skin Kulit Bawang Marching Ants Semut Berbaris diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index d94f42d7a..ef72361be 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2639,6 +2639,14 @@ cameraData Lens Obiettivo Search settings… Cerca impostazioni… No matching settings Nessuna impostazione corrispondente Loading fonts… Caricamento font… + Tool Strumento + Simulate Pressure Simula pressione + Stroke Path… Traccia tracciato… + Background Eraser Gomma per sfondo + Healing Brush Pennello correttivo + Spot Healing Brush Pennello correttivo al volo + Smudge Sfumino + Sponge Spugna # Select and Mask view modes (#1754) Onion Skin Velina Marching Ants Formiche in marcia diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 031503933..dbffc5ae4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3581,6 +3581,12 @@ cameraData Lens レンズ Search settings… 設定を検索… No matching settings 一致する設定はありません Loading fonts… フォントを読み込み中… + Simulate Pressure 筆圧をシミュレート + Stroke Path… パスのストローク… + Background Eraser 背景消しゴム + Healing Brush 修復ブラシ + Spot Healing Brush スポット修復ブラシ + Smudge 指先 # Select and Mask view modes (#1754) Onion Skin オニオンスキン Marching Ants 点線の境界 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index e3c750c96..11a3a9631 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -3150,6 +3150,16 @@ cameraData Lens 렌즈 Search settings… 설정 검색… No matching settings 일치하는 설정 없음 Loading fonts… 글꼴 불러오는 중… + Simulate Pressure 압력 시뮬레이션 + Stroke Path… 패스 획… + Background Eraser 배경 지우개 + Clone Stamp 복제 도장 + Healing Brush 복구 브러시 + Spot Healing Brush 스팟 복구 브러시 + History Brush 작업 내역 브러시 + Smudge 손가락 + Dodge 닷지 + Burn 번 # Select and Mask view modes (#1754) Onion Skin 어니언 스킨 Marching Ants 점선 윤곽 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 03b50a626..c13a7eef8 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -3726,6 +3726,12 @@ cameraData Lens Lens No matching settings Geen overeenkomende instellingen PhotoCraft — Screen Color Picker PhotoCraft — Schermkleurkiezer Loading fonts… Lettertypen laden… + Simulate Pressure Druk simuleren + Stroke Path… Pad omlijnen… + Background Eraser Achtergrondgum + Healing Brush Herstelpenseel + Spot Healing Brush Snel herstelpenseel + Smudge Uitsmeren # Select and Mask view modes (#1754) Onion Skin Uienschil Marching Ants Mierenmars diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 1762c0279..066786463 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3583,6 +3583,12 @@ cameraData Lens Obiektyw Search settings… Szukaj ustawień… No matching settings Brak pasujących ustawień Loading fonts… Wczytywanie czcionek… + Simulate Pressure Symuluj nacisk + Stroke Path… Obrysuj ścieżkę… + Background Eraser Gumka tła + Healing Brush Pędzel korygujący + Spot Healing Brush Punktowy pędzel korygujący + Smudge Palec # Select and Mask view modes (#1754) Onion Skin Skórka cebuli Marching Ants Maszerujące mrówki diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 74d42e981..b4bc1de6e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2990,6 +2990,14 @@ cameraData Lens Lente Search settings… Pesquisar configurações… No matching settings Nenhuma configuração encontrada Loading fonts… Carregando fontes… + Tool Ferramenta + Simulate Pressure Simular pressão + Stroke Path… Traçar demarcador… + Background Eraser Borracha de plano de fundo + Healing Brush Pincel de recuperação + Spot Healing Brush Pincel de recuperação para manchas + Smudge Borrar + Sponge Esponja # Select and Mask view modes (#1754) Onion Skin Papel vegetal Marching Ants Formigas marchando @@ -3734,7 +3742,6 @@ layerLabel No Color Sem cor Texturizer Texturizador Tiles Ladrilhos Tilt-Shift Tilt-Shift - Tool Ferramenta Trace Contour Traçar contorno Trap Encaixe Tree Árvore @@ -3766,7 +3773,6 @@ layerLabel No Color Sem cor Prime105 Prime 105 mm Prime35 Prime 35 mm Rgb RGB - Sponge Esponja Warming L B A Filtro quente LBA Warming81 Filtro quente (81) Warming85 Filtro quente (85) diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b400dc05e..f830338f4 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2908,6 +2908,14 @@ cameraData Lens Объектив Search settings… Поиск настроек… No matching settings Нет подходящих настроек Loading fonts… Загрузка шрифтов… + Tool Инструмент + Simulate Pressure Имитировать нажим + Stroke Path… Обвести контур… + Background Eraser Фоновый ластик + Healing Brush Восстанавливающая кисть + Spot Healing Brush Точечная восстанавливающая кисть + Smudge Палец + Sponge Губка # Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Бегущие муравьи diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f3800b53d..aba87af15 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -3657,6 +3657,12 @@ cameraData Lens Об'єктив No matching settings Немає відповідних налаштувань PhotoCraft — Screen Color Picker PhotoCraft — Піпетка кольору екрана Loading fonts… Завантаження шрифтів… + Simulate Pressure Імітувати натиск + Stroke Path… Обвести контур… + Background Eraser Фонова гумка + Healing Brush Відновлювальний пензель + Spot Healing Brush Точковий відновлювальний пензель + Smudge Розмазування # Select and Mask view modes (#1754) Onion Skin Калька Marching Ants Мурашки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b7db3aa34..b537d011c 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2577,6 +2577,18 @@ cameraData Lens 镜头 Search settings… 搜索设置… No matching settings 没有匹配的设置 Loading fonts… 正在加载字体… + Tool 工具 + Simulate Pressure 模拟压力 + Stroke Path… 描边路径… + Background Eraser 背景橡皮擦 + Clone Stamp 仿制图章 + Healing Brush 修复画笔 + Spot Healing Brush 污点修复画笔 + History Brush 历史记录画笔 + Smudge 涂抹 + Dodge 减淡 + Burn 加深 + Sponge 海绵 # Select and Mask view modes (#1754) Onion Skin 洋葱皮 Marching Ants 蚂蚁线 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 10e1266d4..5ade20a63 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2547,6 +2547,18 @@ cameraData Lens 鏡頭 Search settings… 搜尋設定… No matching settings 沒有符合的設定 Loading fonts… 正在載入字體… + Tool 工具 + Simulate Pressure 模擬壓力 + Stroke Path… 筆畫路徑… + Background Eraser 背景橡皮擦 + Clone Stamp 仿製印章 + Healing Brush 修復筆刷 + Spot Healing Brush 污點修復筆刷 + History Brush 步驟記錄筆刷 + Smudge 指尖 + Dodge 加亮 + Burn 加深 + Sponge 海綿 # Select and Mask view modes (#1754) Onion Skin 洋蔥皮 Marching Ants 螞蟻線 diff --git a/crates/ui-egui/src/vector_ui.rs b/crates/ui-egui/src/vector_ui.rs index f80c929af..b7ade76bd 100644 --- a/crates/ui-egui/src/vector_ui.rs +++ b/crates/ui-egui/src/vector_ui.rs @@ -1338,7 +1338,7 @@ fn path_context_actions(entry: &PathEntry, doc: &Document) -> Vec<(&'static str, let mut actions = vec![ ("Make Selection", "path.toSelection", json!({"name": key})), ("Fill Path", "path.fill", json!({"name": key})), - ("Stroke Path", "path.stroke", json!({"name": key, "tool": "brush"})), + ("Stroke Path…", "path.stroke", json!({"name": key})), ]; if entry.kind == PathRow::Work { let mut n = doc.paths.len().saturating_add(1); @@ -1378,6 +1378,7 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let rows = path_rows(&doc, st.active_layer); let has_layer_path = rows.iter().any(|r| r.kind == PathRow::Layer); let mut action: Option<(&str, Value)> = None; + let mut stroke_dialog: Option = None; // The buttons sit in a footer at the panel's bottom, like Photoshop's. let footer = crate::widgets::footer_height(ui) + ui.spacing().item_spacing.y; let fill = ui.available_height() > footer + 60.0; @@ -1430,7 +1431,12 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { for (label, cmd, params) in path_context_actions(&entry, &doc) { let enabled = !matches!(cmd, "path.fill" | "path.stroke") || can_paint; if ui.add_enabled(enabled, egui::Button::new(tl!(label))).clicked() { - action = Some((cmd, params)); + if cmd == "path.stroke" { + // Stroke Path… asks for the tool first, as in Photoshop (#2978). + stroke_dialog = Some(params.get("name").and_then(Value::as_str).unwrap_or("work").to_string()); + } else { + action = Some((cmd, params)); + } ui.close(); } } @@ -1475,13 +1481,21 @@ pub fn paths_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) { let icon = if crate::icons::exists(icon) { icon } else { "square" }; let b = crate::icons::button(ui, icon, 26.0, false, tip); if b.clicked() { - action = Some((cmd, p)); + if cmd == "path.stroke" && ui.input(|i| i.modifiers.alt) { + // Alt-click opens the Stroke Path dialog, as in Photoshop (#2978). + stroke_dialog = Some(sel.clone()); + } else { + action = Some((cmd, p)); + } } buttons = buttons.union(b.rect); } buttons }); ctx_data_footer(ui.ctx(), buttons); + if let Some(name) = stroke_dialog { + crate::canvas_tool_menu::stroke_path_dialog(app, &name); + } if let Some((cmd, p)) = action && let Err(e) = app.run(cmd, p) { diff --git a/docs/scorecard.md b/docs/scorecard.md index fdba39bd4..c1e7fca88 100644 --- a/docs/scorecard.md +++ b/docs/scorecard.md @@ -8,7 +8,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js |---|---:|---:|---:|---| | [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 17 not run, 5 not measurable yet (baseline 2026-10-05) | | [File compatibility](#file-compatibility) | 6 | 12 | 9 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 | -| [Tools](#tools) | 25 | 16 | 2 | | +| [Tools](#tools) | 26 | 15 | 2 | | | [Workspace and UI](#workspace-and-ui) | 9 | 8 | 6 | settings that do nothing: 30 of 154 (target 0) | | [Type](#type) | 3 | 3 | 3 | | | [Automation](#automation) | 3 | 2 | 6 | 631 of 631 menu items live (100.0%) | @@ -155,7 +155,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-8 | A measured ruler angle prefills Image Rotation > Arbitrary (0.01 deg) | done | [#214](https://github.com/storytold/photocraft/issues/214) | analysis_ui.rs: image.rotation.arbitrary opens seeded with the ruler angle and the direction that straightens it; arbitrary_rotation_starts_at_the_ruler_angle checks 5.7106 deg within 1e-3 and cw/ccw for three measured lines | | TOOL-214-9 | Crop: W x H x Resolution with units, saved crop presets, Straighten in the options bar | done | [#214](https://github.com/storytold/photocraft/issues/214) | Ratio dropdown and fields, Straighten (TOOL-214-21); W x H x Resolution with units, built-in size presets and Front Image (crop_size.rs, image.crop targetWidth/targetHeight/resolution, #2443); New / Delete Crop Preset save and delete crop tool presets (crop_presets.rs, tool.presets.* with tool crop, persisted with the preferences) | | TOOL-214-10 | Pen: Alt breaks a handle, Shift 45 deg, Ctrl direct-selects, Backspace, close-drag handle | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (direct_select.rs, #790); Backspace/Delete retracts the last point while drawing (#1466); Alt during a drag breaks the handle pair, Alt-drag turns the last anchor's outgoing handle alone, and dragging on the first anchor closes with that anchor's handles (vector_ui.rs, #3306); no Shift 45 deg | -| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | partial | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog | +| TOOL-214-11 | Stroke Path dialog: pick any brush-based tool, Simulate Pressure | done | [#214](https://github.com/storytold/photocraft/issues/214) | path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978) | | TOOL-214-12 | Shapes: on-canvas corner radius handles, shape feather, selection to shape with tolerance | partial | [#214](https://github.com/storytold/photocraft/issues/214) | select.convertToShape with tolerance (vector_cmds.rs:1256); radius as a field only | | TOOL-214-13 | Mask Properties: Density and Feather controls | partial | [#214](https://github.com/storytold/photocraft/issues/214) | Vector-mask density/feather in the engine (vector_cmds.rs:948); no UI controls | | TOOL-214-14 | Vanishing Point window: draw and edit planes, paste, clone in perspective | partial | [#214](https://github.com/storytold/photocraft/issues/214) | filter.vanishingPoint (vp_cmds.rs:15) behind a parameter dialog | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 51e377192..57f10e88e 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -183,9 +183,9 @@ note = "Ctrl/Cmd = Direct Selection and Alt = Convert Point on existing paths (d [[item]] id = "TOOL-214-11" target = "Stroke Path dialog: pick any brush-based tool, Simulate Pressure" -status = "partial" +status = "done" issue = 214 -note = "path.stroke brush, pencil, eraser (vector_cmds.rs:1170); brush-only button, no dialog" +note = "path.stroke tool: Brush, Pencil, Eraser and every other brush-based tool (Background Eraser, Clone/Pattern Stamp, Healing/Spot Healing Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color Replacement, Mixer Brush: their paint.* command along the path, one step) plus simulatePressure (taper both ends); Stroke Path dialog from the Pen menu, the Paths panel menu and Alt-click on its Stroke button (#2978)" [[item]] id = "TOOL-214-12"