Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978) (#3039)

* Paths: Stroke Path with any brush-based tool and Simulate Pressure (#2978)

path.stroke now takes every brush-based tool of Photoshop's Stroke Path
dialog: besides the Brush, Pencil and Eraser it renders itself, the
Background Eraser, Clone and Pattern Stamp, Healing and Spot Healing
Brush, History Brush, Blur, Sharpen, Smudge, Dodge, Burn, Sponge, Color
Replacement and Mixer Brush run their paint.* command along each
subpath, in one history step, with only path.stroke journalled.

simulatePressure resamples each subpath and ramps the pressure from 0 at
its start to full at the middle and back to 0 at its end, so a brush
whose size follows pen pressure tapers at both ends.

The Stroke Path dialog (Pen/Path Selection right-click menu, the Paths
panel row menu, Alt-click on the panel's Stroke button) asks for the
tool and Simulate Pressure, as in Photoshop. Scorecard TOOL-214-11 done.

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

* Automation: classify path.stroke's params as a document vector path (#2978)

The new Stroke Path params doc says the tools run "along the path", which
registry_filesystem_path_params_are_classified reads as a possible host
file path. path.stroke strokes a document path and forwards only to
paint.* commands that take no files, so it joins the vector-path list.

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 17:18:39 -04:00
1 parent 6a4c48aea7
commit 55c1847f89
23 files changed
+333 -19

No files matched your search

+1
View File
@@ -806,6 +806,7 @@ mod tests {
| "path.deleteAnchor"
| "path.clippingPath.set"
| "path.rename"
| "path.stroke"
| "select.toWorkPath"
| "layer.vectorMask.add"
| "layer.vectorMask.edit"
+116 -8
View File
@@ -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<photocraft_paint::StrokePoint> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
}
let mut dmg = Rect::EMPTY;
for pl in &lines {
let mut pts: Vec<photocraft_paint::StrokePoint> = 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<CommandSpec> {
"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
),
+49
View File
@@ -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| {
+11 -2
View File
@@ -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]))));
+12
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+9
View File
@@ -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 Κινούμενο περίγραμμα
Can't render this file because it contains an unexpected character in line 1000 and column 18.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 4 and column 47.
+8
View File
@@ -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
Can't render this file because it contains an unexpected character in line 2586 and column 53.
+6
View File
@@ -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 点線の境界
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10
View File
@@ -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 점선 윤곽
Can't render this file because it contains an unexpected character in line 3095 and column 53.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 8 and column 34.
+6
View File
@@ -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
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8 -2
View File
@@ -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)
Can't render this file because it contains an unexpected character in line 6 and column 47.
+8
View File
@@ -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 Бегущие муравьи
Can't render this file because it contains an unexpected character in line 6 and column 47.
+6
View File
@@ -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 Мурашки
Can't render this file because it contains an unexpected character in line 1356 and column 18.
+12
View File
@@ -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 蚂蚁线
Can't render this file because it contains an unexpected character in line 2528 and column 53.
+12
View File
@@ -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 螞蟻線
Can't render this file because it contains an unexpected character in line 6 and column 47.
+17 -3
View File
@@ -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<String> = 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)
{
+2 -2
View File
@@ -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 |
+2 -2
View File
@@ -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"