Merge remote-tracking branch 'origin/main' into HEAD

This commit is contained in:
Claude
2026-10-09 02:00:52 +00:00
19 changed files with 370 additions and 157 deletions
+4 -2
View File
@@ -439,8 +439,10 @@ fn main() -> eframe::Result {
// Paths on the command line (Linux/Windows file associations, `photocraft a.psd`).
app.open_paths(&files);
if !unreadable_paths.is_empty() {
let lines = unreadable_paths.iter().map(|p| format!("{p}: the path is not valid Unicode; rename the file and open it again.")).collect();
photocraft_ui_egui::notices::post(&mut app, "Could not open", lines, true, None);
use photocraft_ui_egui::i18n;
let line = i18n::t("{path}: the path is not valid Unicode; rename the file and open it again.");
let lines = unreadable_paths.iter().map(|p| i18n::fmt(line, &[("path", p.as_str())])).collect();
photocraft_ui_egui::notices::post(&mut app, i18n::t("Could not open"), lines, true, None);
}
// Portable marker found but its data folder isn't writable (#228): say where settings went.
if let Some(w) = &app_dirs::current().warning {
+265 -125
View File
@@ -303,10 +303,19 @@ fn clone_stamp(s: &mut Session, p: &Value) -> Result<Value> {
Ok(clone_result(dmg, off, aligned, p, &stroke))
}
/// A Clone Stamp stroke shown while it is drawn: the dabs so far composited from the clone source
/// onto a copy of the active document, as `paint.cloneStamp` with the same params commits them
/// (the same coverage, sampling and blend; a smoothing catch-up appears on release).
pub struct LiveClone {
/// What a live retouching stroke paints through its coverage.
enum LivePaint {
/// The clone source (Clone Stamp; the Healing Brush shows its texture while drawing and
/// blends it on release).
Clone { which: SampleLayers, map: crate::presets::clone_source::Mapping, cmd: &'static str },
/// The History Brush's history state.
History(Surface),
}
/// A retouching stroke shown while it is drawn: the dabs so far composited onto a copy of the
/// active document as the command with the same params commits them (the same coverage,
/// sampling and blend): Clone Stamp, History Brush, and the Healing Brush's texture.
pub struct LiveRetouch {
/// The active document with the stroke so far.
pub doc: std::sync::Arc<Document>,
renderer: photocraft_paint::StrokeRenderer,
@@ -314,8 +323,7 @@ pub struct LiveClone {
pre_surf: Surface,
id: Option<LayerId>,
params: Value,
which: SampleLayers,
map: crate::presets::clone_source::Mapping,
paint: LivePaint,
mode: BlendMode,
opacity: f32,
sel: Option<Surface>,
@@ -325,16 +333,25 @@ pub struct LiveClone {
tail: Rect,
}
impl LiveClone {
/// Start from `paint.cloneStamp` params; their `points` are rendered.
pub fn begin(s: &Session, p: &Value) -> Result<Self> {
const CMD: &str = "paint.cloneStamp";
impl LiveRetouch {
/// Start a live stroke of `cmd` (`paint.cloneStamp`, `paint.healingBrush` or
/// `paint.historyBrush`) from its params; their `points` are rendered.
pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
has_pixel_layer(s).map_err(EngineError::Other)?;
let (stroke, id) = parse_brush(s, p, CMD)?;
let which = sample_layers(p, CMD)?;
let mode = blend_param(p, CMD)?;
let f = *stroke.points.first().ok_or_else(|| bad(CMD, "`points` is empty"))?;
let (map, _) = crate::presets::clone_source::resolve(s, p, (f.x, f.y), CMD)?;
let (stroke, id) = parse_brush(s, p, cmd)?;
let mode = blend_param(p, cmd)?;
let paint = match cmd {
"paint.cloneStamp" | "paint.healingBrush" => {
let f = *stroke.points.first().ok_or_else(|| bad(cmd, "`points` is empty"))?;
LivePaint::Clone {
which: sample_layers(p, cmd)?,
map: crate::presets::clone_source::resolve(s, p, (f.x, f.y), cmd)?.0,
cmd: if cmd == "paint.healingBrush" { "paint.healingBrush" } else { "paint.cloneStamp" },
}
}
"paint.historyBrush" => LivePaint::History(history_source(s, id, p, cmd)?),
other => return Err(bad(other, "live retouching strokes are the Clone Stamp, Healing Brush and History Brush")),
};
let pre = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
let mut doc = (*pre).clone();
let sel = doc.selection.clone();
@@ -343,8 +360,12 @@ impl LiveClone {
// As `stroke_coverage`, which the commit uses.
let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0);
let opacity = stroke.brush.opacity;
let paint = match paint {
LivePaint::History(src) if src.format() != pre_surf.format() => LivePaint::History(src.convert(pre_surf.format())),
other => other,
};
let mut live =
Self { doc: std::sync::Arc::new(doc), renderer, pre, pre_surf, id, params: p.clone(), which, map, mode, opacity, sel, lock, tail: Rect::EMPTY };
Self { doc: std::sync::Arc::new(doc), renderer, pre, pre_surf, id, params: p.clone(), paint, mode, opacity, sel, lock, tail: Rect::EMPTY };
live.push(&stroke.points)?;
Ok(live)
}
@@ -387,7 +408,10 @@ impl LiveClone {
if r.is_empty() {
return Ok(r);
}
let paint = clone_sample(&self.pre, self.id, &self.pre_surf, self.which, r, &self.map, "paint.cloneStamp")?;
let paint = match &self.paint {
LivePaint::Clone { which, map, cmd } => clone_sample(&self.pre, self.id, &self.pre_surf, *which, r, map, cmd)?,
LivePaint::History(src) => Region::read(src, r),
};
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
surf.write_region(r, &self.pre_surf.read_region(r));
Ok(apply_coverage(surf, r, cov, self.opacity, self.sel.as_ref(), self.lock, &paint, self.mode).union(&r))
@@ -455,23 +479,27 @@ fn healing_brush(s: &mut Session, p: &Value) -> Result<Value> {
Ok(clone_result(dmg, off, aligned, p, &stroke))
}
fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "paint.historyBrush";
let (stroke, id) = parse_brush(s, p, CMD)?;
let mode = blend_param(p, CMD)?;
/// The History Brush's source: the targeted surface (pixels, mask, channel) as it was in history
/// state `state` (default: the oldest state still held, the "Open" snapshot unless trimmed).
fn history_source(s: &Session, id: Option<LayerId>, p: &Value, cmd: &str) -> Result<Surface> {
let st = s.active().ok_or(EngineError::NoDocument)?;
let explicit = p.get("state").and_then(Value::as_u64).map(|v| v as usize);
let source_doc = match explicit {
Some(i) if i == st.history.past_len() => st.doc.clone(),
Some(i) => st.history.state(i).ok_or_else(|| bad(CMD, format!("no history state {i} (0..={})", st.history.past_len())))?,
// Default: the oldest state still held (the "Open" snapshot unless it was trimmed).
Some(i) => st.history.state(i).ok_or_else(|| bad(cmd, format!("no history state {i} (0..={})", st.history.past_len())))?,
None => st.history.state(0).unwrap_or_else(|| st.doc.clone()),
};
// The same target (pixels, mask, channel) as it was in that state.
let mut source_doc = (*source_doc).clone();
let src_surface = crate::channel_cmds::target_surface(&mut source_doc, id, p)
crate::channel_cmds::target_surface(&mut source_doc, id, p)
.map(|(surf, _)| surf.clone())
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))?;
.map_err(|_| EngineError::Other("the target did not exist (as pixels, a mask or a channel) in that history state".into()))
}
fn history_brush(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "paint.historyBrush";
let (stroke, id) = parse_brush(s, p, CMD)?;
let mode = blend_param(p, CMD)?;
let src_surface = history_source(s, id, p, CMD)?;
let dmg = run_stroke(s, "History Brush", id, p, |_, surf, sel, lock| {
let fmt = surf.format();
let src = if src_surface.format() == fmt { src_surface } else { src_surface.convert(fmt) };
@@ -639,37 +667,217 @@ fn tone_range(p: &Value, cmd: &str) -> Result<ToneRange> {
}
}
fn dodge_burn_cmd(s: &mut Session, p: &Value, burn: bool) -> Result<Value> {
let cmd = if burn { "paint.burn" } else { "paint.dodge" };
/// A sequential-dab tool's settings (Dodge, Burn, Sponge, Blur, Sharpen, Smudge), parsed once and
/// turned into its per-dab effect, for the commit and the live preview alike.
#[derive(Clone, Debug)]
enum DabTool {
Tone { burn: bool, range: ToneRange, exposure: f32, protect: bool },
Sponge { saturate: bool, vibrance: bool },
Focus { sharpen: bool, strength: f32, protect: bool, sigma: f32 },
Smudge { strength: f32, finger: Option<[f32; 4]>, max_size: f32 },
}
/// One dab's edit of the working copy.
type DabEffect = Box<dyn FnMut(&mut Region, &Footprint)>;
impl DabTool {
/// The tool for `cmd` and its history label.
fn parse(s: &Session, cmd: &str, p: &Value, stroke: &Stroke) -> Result<(Self, &'static str)> {
Ok(match cmd {
"paint.dodge" | "paint.burn" => {
let burn = cmd == "paint.burn";
let tool = Self::Tone {
burn,
range: tone_range(p, cmd)?,
exposure: num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0,
protect: flag(p, "protectTones", true),
};
(tool, if burn { "Burn Tool" } else { "Dodge Tool" })
}
"paint.sponge" => {
let saturate = match string(p, "mode", "desaturate") {
"desaturate" => false,
"saturate" => true,
o => return Err(bad(cmd, format!("unknown mode `{o}` (desaturate|saturate)"))),
};
(Self::Sponge { saturate, vibrance: flag(p, "vibrance", true) }, "Sponge Tool")
}
"paint.blur" | "paint.sharpen" => {
let sharpen = cmd == "paint.sharpen";
let tool = Self::Focus {
sharpen,
strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0,
protect: flag(p, "protectDetail", true),
sigma: (stroke.brush.size * 0.03).clamp(1.0, 4.0),
};
(tool, if sharpen { "Sharpen Tool" } else { "Blur Tool" })
}
"paint.smudge" => {
let finger = flag(p, "fingerPainting", false).then_some(s.tools.foreground);
(Self::Smudge { strength: num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0, finger, max_size: stroke.brush.size }, "Smudge Tool")
}
o => return Err(bad(o, "not a sequential-dab tool")),
})
}
/// How far around each dab the effect reads (Blur and Sharpen read a neighbourhood).
fn halo(&self) -> i32 {
match self {
Self::Focus { sigma, .. } => (sigma * 3.0).ceil() as i32 + 1,
_ => 0,
}
}
/// The per-dab effect on a working copy in `fmt`, for a brush of `spacing`.
fn effect(&self, fmt: PixelFormat, spacing: f32) -> DabEffect {
let sp = spacing;
match self.clone() {
Self::Tone { burn, range, exposure, protect } => {
Box::new(move |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect)))
}
// Flow is already in each dab's coverage; a full-flow pass moves colours half-way.
Self::Sponge { saturate, vibrance } => Box::new(move |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))),
Self::Focus { sharpen, strength, protect, sigma } => {
let a = alpha_index(&fmt);
Box::new(move |work, fp| {
let r = fp.rect.intersect(&work.rect);
if r.is_empty() {
return;
}
let (w, h, n) = (work.width(), work.height(), work.ch);
let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize);
let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) };
let rw = r.width() as usize;
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let k = per_dab(fp.at(x, y) * strength, sp);
if k <= 0.0 {
continue;
}
let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n;
let px = work.px_mut(x, y);
for c in 0..n {
px[c] += (out[o + c] - px[c]) * k;
}
}
}
})
}
Self::Smudge { strength, finger, max_size } => {
let finger_px = finger.map(|fg| photocraft_raster::from_rgba(&fmt, fg));
// Premultiplied mixing: transparent pixels carry no colour, so no dark fringes.
let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt));
Box::new(move |work, fp| sm.dab(work, fp))
}
}
}
}
/// Dodge, Burn, Sponge, Blur, Sharpen and Smudge: each dab edits a working copy in order
/// (`apply_dab_stroke`). Blur, Sharpen and Smudge can sample all layers.
fn dab_cmd(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
let (stroke, id) = parse_brush(s, p, cmd)?;
let range = tone_range(p, cmd)?;
let exposure = num(p, "exposure", 50.0).clamp(1.0, 100.0) / 100.0;
let protect = flag(p, "protectTones", true);
let label = if burn { "Burn Tool" } else { "Dodge Tool" };
let dmg = run_stroke(s, label, id, p, |_, surf, sel, lock| {
let (tool, label) = DabTool::parse(s, cmd, p, &stroke)?;
let all = matches!(tool, DabTool::Focus { .. } | DabTool::Smudge { .. }) && sample_all_layers(p);
let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| {
let fmt = surf.format();
let sp = stroke.brush.spacing;
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, exposure, sp, |c, k| dodge_burn(c, k, range, burn, protect))))
let mut effect = tool.effect(fmt, stroke.brush.spacing);
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
Ok(apply_dab_stroke(surf, &stroke, sel, lock, tool.halo(), |work, fp| match &mut sampler {
Some(all) => all.dab(work, fp, |w, f| effect(w, f)),
None => effect(work, fp),
}))
})?;
Ok(json!({ "damage": damage_json(dmg) }))
}
fn sponge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "paint.sponge";
let (stroke, id) = parse_brush(s, p, CMD)?;
let saturate = match string(p, "mode", "desaturate") {
"desaturate" => false,
"saturate" => true,
o => return Err(bad(CMD, format!("unknown mode `{o}` (desaturate|saturate)"))),
};
let vibrance = flag(p, "vibrance", true);
let dmg = run_stroke(s, "Sponge Tool", id, p, |_, surf, sel, lock| {
let fmt = surf.format();
let sp = stroke.brush.spacing;
// Flow is already in each dab's coverage; a full-flow pass moves colours half-way.
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| color_dab(&fmt, work, fp, 0.5, sp, |c, k| sponge(c, k, saturate, vibrance))))
})?;
Ok(json!({ "damage": damage_json(dmg) }))
/// A Dodge, Burn, Sponge, Blur, Sharpen or Smudge stroke shown while it is drawn: each new dab
/// runs the tool's effect on a working copy of the target, in order, and the changed area is
/// mixed back over the original at the brush opacity, as `apply_dab_stroke` does on release.
/// Sample All Layers isn't previewed (its composite spans the whole stroke).
pub struct LiveDab {
/// The active document with the stroke so far.
pub doc: std::sync::Arc<Document>,
renderer: photocraft_paint::StrokeRenderer,
done: usize,
effect: DabEffect,
halo: i32,
pre_surf: Surface,
work: Surface,
id: Option<LayerId>,
params: Value,
opacity: f32,
sel: Option<Surface>,
lock: bool,
bounds: Rect,
}
impl LiveDab {
/// Start a live stroke of `cmd` from its params; their `points` are rendered.
pub fn begin(s: &Session, cmd: &str, p: &Value) -> Result<Self> {
has_pixel_layer(s).map_err(EngineError::Other)?;
if sample_all_layers(p) {
return Err(bad(cmd, "Sample All Layers strokes aren't previewed live"));
}
let (stroke, id) = parse_brush(s, p, cmd)?;
let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?;
let mut doc = (*s.active().ok_or(EngineError::NoDocument)?.doc).clone();
let sel = doc.selection.clone();
let (surf, lock) = crate::channel_cmds::target_surface(&mut doc, id, p)?;
let pre_surf = surf.clone();
let effect = tool.effect(pre_surf.format(), stroke.brush.spacing);
// `apply_dab_stroke` generates the same dabs (`dabs`: zoom 1, no smoothing scale).
let renderer = photocraft_paint::StrokeRenderer::new(&stroke.brush, None, 1.0).record_dabs();
let mut live = Self {
doc: std::sync::Arc::new(doc),
renderer,
done: 0,
effect,
halo: tool.halo(),
work: pre_surf.clone(),
pre_surf,
id,
params: p.clone(),
opacity: stroke.brush.opacity,
sel,
lock,
bounds: Rect::EMPTY,
};
live.push(&stroke.points)?;
Ok(live)
}
/// Everything the stroke has touched so far.
pub fn bounds(&self) -> Rect {
self.bounds
}
/// Render more points; returns the rectangle that changed.
pub fn push(&mut self, pts: &[StrokePoint]) -> Result<Rect> {
self.renderer.push(pts);
let ctx = self.renderer.ctx.clone();
let new: Vec<_> = self.renderer.dabs().get(self.done..).unwrap_or_default().to_vec();
let mut dmg = Rect::EMPTY;
for (k, d) in new.iter().enumerate() {
let r = ctx.dab_rect(d, false);
if r.is_empty() {
continue;
}
let mut region = Region::read(&self.work, r.inflate(self.halo));
(self.effect)(&mut region, &ctx.footprint(d, self.done + k));
region.crop(r).write(&mut self.work);
dmg = dmg.union(&r);
}
self.done += new.len();
if dmg.is_empty() {
return Ok(dmg);
}
self.bounds = self.bounds.union(&dmg);
let (orig, work) = (Region::read(&self.pre_surf, dmg), Region::read(&self.work, dmg));
let (surf, _) = crate::channel_cmds::target_surface(std::sync::Arc::make_mut(&mut self.doc), self.id, &self.params)?;
photocraft_paint::retouch::mix_back(surf, &orig, &work, dmg, self.opacity, self.sel.as_ref(), self.lock);
Ok(dmg)
}
}
/// "Sample All Layers" for Spot Healing, Blur, Sharpen and Smudge (layer pixels only: a mask or
@@ -711,74 +919,6 @@ impl<'a> AllLayers<'a> {
}
}
/// Blur (`sharpen = false`) or Sharpen each dab's footprint in the working copy.
fn focus_cmd(s: &mut Session, p: &Value, sharpen: bool) -> Result<Value> {
let cmd = if sharpen { "paint.sharpen" } else { "paint.blur" };
let (stroke, id) = parse_brush(s, p, cmd)?;
let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0;
let protect = flag(p, "protectDetail", true);
let sigma = (stroke.brush.size * 0.03).clamp(1.0, 4.0);
let halo = (sigma * 3.0).ceil() as i32 + 1;
let label = if sharpen { "Sharpen Tool" } else { "Blur Tool" };
let all = sample_all_layers(p);
let dmg = run_stroke(s, label, id, p, |pre, surf, sel, lock| {
let fmt = surf.format();
let a = alpha_index(&fmt);
let sp = stroke.brush.spacing;
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
let focus = |work: &mut Region, fp: &Footprint| {
let r = fp.rect.intersect(&work.rect);
if r.is_empty() {
return;
}
let (w, h, n) = (work.width(), work.height(), work.ch);
let win = ((r.x0 - work.rect.x0) as usize, (r.y0 - work.rect.y0) as usize, (r.x1 - work.rect.x0) as usize, (r.y1 - work.rect.y0) as usize);
let out = if sharpen { local_sharpen(&work.data, w, h, n, a, win, 1.0, protect) } else { local_blur(&work.data, w, h, n, a, win, sigma) };
let rw = r.width() as usize;
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let k = per_dab(fp.at(x, y) * strength, sp);
if k <= 0.0 {
continue;
}
let o = ((y - r.y0) as usize * rw + (x - r.x0) as usize) * n;
let px = work.px_mut(x, y);
for c in 0..n {
px[c] += (out[o + c] - px[c]) * k;
}
}
}
};
Ok(apply_dab_stroke(surf, &stroke, sel, lock, halo, |work, fp| match &mut sampler {
Some(all) => all.dab(work, fp, focus),
None => focus(work, fp),
}))
})?;
Ok(json!({ "damage": damage_json(dmg) }))
}
fn smudge_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "paint.smudge";
let (stroke, id) = parse_brush(s, p, CMD)?;
let strength = num(p, "strength", 50.0).clamp(1.0, 100.0) / 100.0;
let finger = flag(p, "fingerPainting", false);
let fg = s.tools.foreground;
let all = sample_all_layers(p);
let dmg = run_stroke(s, "Smudge Tool", id, p, |pre, surf, sel, lock| {
let fmt = surf.format();
let finger_px = finger.then(|| photocraft_raster::from_rgba(&fmt, fg));
let max_size = stroke.brush.size;
// Premultiplied mixing: transparent pixels carry no colour, so no dark fringes.
let mut sm = Smudge::new(strength, finger_px, max_size).with_alpha(alpha_index(&fmt));
let mut sampler = all.then(|| AllLayers::new(pre, fmt));
Ok(apply_dab_stroke(surf, &stroke, sel, lock, 0, |work, fp| match &mut sampler {
Some(all) => all.dab(work, fp, |w, f| sm.dab(w, f)),
None => sm.dab(work, fp),
}))
})?;
Ok(json!({ "damage": damage_json(dmg) }))
}
// ---------------------------------------------------------------------------------------------
// Registry
// ---------------------------------------------------------------------------------------------
@@ -866,7 +1006,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#),
enabled: has_pixel_layer,
run: |s, p| dodge_burn_cmd(s, p, false),
run: |s, p| dab_cmd(s, p, "paint.dodge"),
journal: true,
},
CommandSpec {
@@ -876,7 +1016,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","range":"shadows|midtones|highlights"="midtones","exposure":1..100=50,"protectTones":bool=true"#),
enabled: has_pixel_layer,
run: |s, p| dodge_burn_cmd(s, p, true),
run: |s, p| dab_cmd(s, p, "paint.burn"),
journal: true,
},
CommandSpec {
@@ -886,7 +1026,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","mode":"desaturate|saturate"="desaturate","vibrance":bool=true (flow = sponge strength)"#),
enabled: has_pixel_layer,
run: sponge_cmd,
run: |s, p| dab_cmd(s, p, "paint.sponge"),
journal: true,
},
CommandSpec {
@@ -896,7 +1036,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","strength":1..100=50,"sampleAllLayers":bool=false"#),
enabled: has_pixel_layer,
run: |s, p| focus_cmd(s, p, false),
run: |s, p| dab_cmd(s, p, "paint.blur"),
journal: true,
},
CommandSpec {
@@ -906,7 +1046,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","strength":1..100=50,"protectDetail":bool=true,"sampleAllLayers":bool=false"#),
enabled: has_pixel_layer,
run: |s, p| focus_cmd(s, p, true),
run: |s, p| dab_cmd(s, p, "paint.sharpen"),
journal: true,
},
CommandSpec {
@@ -916,7 +1056,7 @@ pub fn specs() -> Vec<CommandSpec> {
shortcut: None,
params: brush_params!(r#","strength":1..100=50,"fingerPainting":bool=false (starts with the foreground colour),"sampleAllLayers":bool=false"#),
enabled: has_pixel_layer,
run: smudge_cmd,
run: |s, p| dab_cmd(s, p, "paint.smudge"),
journal: true,
},
CommandSpec {
+2 -1
View File
@@ -1099,7 +1099,8 @@ fn live_clone_matches_the_commit() {
paint_layer(&mut s, texture);
let p = json!({"points": [[50, 30], [60, 34], [70, 30], [80, 36]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70});
let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::<Vec<_>>();
let mut live = LiveClone::begin(&s, &json!({"points": [[50, 30]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70})).unwrap();
let mut live =
LiveRetouch::begin(&s, "paint.cloneStamp", &json!({"points": [[50, 30]], "source": [20, 20], "size": 14, "hardness": 40, "opacity": 70})).unwrap();
live.push(&pts(&[[60.0, 34.0], [70.0, 30.0]])).unwrap();
live.push(&pts(&[[80.0, 36.0]])).unwrap();
let shown = live.doc.clone();
+12 -5
View File
@@ -240,13 +240,21 @@ pub fn apply_dab_stroke(
for (i, d) in ds.iter().enumerate() {
effect(&mut work, &ctx.footprint(d, i));
}
mix_back(target, &orig, &work, bounds, stroke.brush.opacity, selection, lock_transparency);
bounds
}
/// Mix a sequential-dab stroke's working pixels `work` back over the original `orig` at
/// `opacity × selection` inside `rect`, and write that area to `target`: the last step of
/// [`apply_dab_stroke`], shared with live previews that run the dabs as they come.
pub fn mix_back(target: &mut Surface, orig: &Region, work: &Region, rect: Rect, opacity: f32, selection: Option<&Surface>, lock_transparency: bool) {
let fmt = target.format();
let a = alpha_index(&fmt);
let mut out = orig.crop(bounds);
let mut out = orig.crop(rect);
let n = out.ch;
let opacity = stroke.brush.opacity.clamp(0.0, 1.0);
for y in bounds.y0..bounds.y1 {
for x in bounds.x0..bounds.x1 {
let opacity = opacity.clamp(0.0, 1.0);
for y in rect.y0..rect.y1 {
for x in rect.x0..rect.x1 {
let k = opacity * selection.map_or(1.0, |s| s.sample_channel(x, y, 0));
if k <= 0.0 {
continue;
@@ -269,7 +277,6 @@ pub fn apply_dab_stroke(
}
}
out.write(target);
bounds
}
/// `dst ← lerp(dst, src, k)` in premultiplied space (straight in, straight out; `a` = alpha index).
+40 -16
View File
@@ -237,37 +237,42 @@ pub(crate) fn draw_readout(ctx: &egui::Context, id: &str, cursor: Pos2, labels:
});
}
/// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs, the Clone Stamp
/// composites its source.
/// The engine's live stroke of a tool: the Brush, Pencil and Eraser paint dabs; the Clone Stamp,
/// Healing Brush and History Brush composite their source.
enum EngineStroke {
Brush(Box<photocraft_engine::brush_cmds::LiveStroke>),
Clone(Box<photocraft_engine::retouch_cmds::LiveClone>),
Retouch(photocraft_engine::retouch_cmds::LiveRetouch),
Dab(photocraft_engine::retouch_cmds::LiveDab),
}
impl EngineStroke {
fn doc(&self) -> &std::sync::Arc<photocraft_doc::Document> {
match self {
Self::Brush(s) => &s.doc,
Self::Clone(s) => &s.doc,
Self::Retouch(s) => &s.doc,
Self::Dab(s) => &s.doc,
}
}
fn bounds(&self) -> DRect {
match self {
Self::Brush(s) => s.bounds(),
Self::Clone(s) => s.bounds(),
Self::Retouch(s) => s.bounds(),
Self::Dab(s) => s.bounds(),
}
}
fn push(&mut self, pts: &[photocraft_engine::paint::StrokePoint]) -> photocraft_engine::Result<DRect> {
match self {
Self::Brush(s) => s.push(pts),
Self::Clone(s) => s.push(pts),
Self::Retouch(s) => s.push(pts),
Self::Dab(s) => s.push(pts),
}
}
/// The jitter seed the commit must reuse (the Clone Stamp's comes from the session brush).
fn seed(&self) -> Option<u64> {
match self {
Self::Brush(s) => Some(s.seed),
Self::Clone(_) => None,
Self::Retouch(_) => None,
Self::Dab(_) => None,
}
}
}
@@ -316,7 +321,19 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec<f64
/// Tools whose strokes the engine renders while they are drawn (`LiveStroke`).
pub(crate) fn strokes_live(tool: Tool) -> bool {
matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser | Tool::CloneStamp)
matches!(tool, Tool::Brush | Tool::Pencil | Tool::Eraser | Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge)
|| live_retouch_command(tool).is_some()
}
/// The retouching tools drawn live (`LiveRetouch`), by the command their live stroke previews.
/// The Healing Brush shows its texture while drawing; the blend comes on release.
fn live_retouch_command(tool: Tool) -> Option<&'static str> {
match tool {
Tool::CloneStamp => Some("paint.cloneStamp"),
Tool::Healing => Some("paint.healingBrush"),
Tool::HistoryBrush => Some("paint.historyBrush"),
_ => None,
}
}
/// The command a live-stroking tool commits: the Pencil's `paint.pencil`, else `paint.stroke`.
@@ -416,12 +433,17 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option<LiveStroke> {
static STROKES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let st = app.session.active()?;
let d = app.drag.as_ref()?;
let stroke = if d.tool == Tool::CloneStamp {
// The params `retouch_ui::finish_stroke` commits.
let mut p = crate::retouch_ui::clone_params(app)?;
let stroke = if let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) {
// The params `retouch_ui::finish_stroke` commits; Sample All Layers isn't previewed.
p["points"] = json!(d.points);
p["target"] = paint_target(app);
EngineStroke::Clone(Box::new(photocraft_engine::retouch_cmds::LiveClone::begin(&app.session, &p).ok()?))
EngineStroke::Dab(photocraft_engine::retouch_cmds::LiveDab::begin(&app.session, cmd, &p).ok()?)
} else if let Some(cmd) = live_retouch_command(d.tool) {
// The params `retouch_ui::finish_stroke` commits.
let mut p = if d.tool == Tool::HistoryBrush { json!({}) } else { crate::retouch_ui::clone_params(app)? };
p["points"] = json!(d.points);
p["target"] = paint_target(app);
EngineStroke::Retouch(photocraft_engine::retouch_cmds::LiveRetouch::begin(&app.session, cmd, &p).ok()?)
} else {
let p = stroke_params(app, d.tool, d.erase, &app.stylus.stroke_points(&d.points));
EngineStroke::Brush(Box::new(photocraft_engine::brush_cmds::LiveStroke::begin_with(&app.session, stroke_command(d.tool), &p).ok()?))
@@ -2738,8 +2760,9 @@ fn draw_drag_preview(app: &mut PhotocraftApp, painter: &egui::Painter, xf: &View
draw_marquee_readout(painter.ctx(), xf.to_screen(last[0] as f32, last[1] as f32), marquee_readout(marquee_px(a, b)));
}
match d.tool {
// The canvas shows the live stroke itself (`LiveStroke`).
t if strokes_live(t) => {}
// The canvas shows the live stroke itself (`LiveStroke`); a stroke that couldn't start one
// (Sample All Layers, say) keeps the trail.
t if strokes_live(t) && app.live_stroke.is_some() => {}
t if t.is_brushlike() || t == Tool::QuickSelection => {
// Retouching strokes preview as a translucent trail of the brush footprint: a mask,
// not a brush-wide egui polyline (which zoomed in tessellates into wedges, #189).
@@ -3283,7 +3306,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) {
app.last_stroke_end = Some((st.doc.id, [end[0], end[1]]));
}
// A live Clone Stamp preview ends here; the commit below replaces it.
if d.tool == Tool::CloneStamp {
if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() {
app.live_stroke = None;
}
if crate::eraser_ui::finish_stroke(app, d.tool, &d.points) || crate::retouch_ui::finish_stroke(app, d.tool, &d.points, d.modifiers) {
@@ -4013,7 +4036,8 @@ mod tests {
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 120, "height": 60})).unwrap();
app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap();
app.ui.tool = Tool::Dodge;
// Spot Healing: the dab tools draw their stroke live instead of a trail.
app.ui.tool = Tool::SpotHealing;
tool_event(&mut app, ToolEvent::Down { x: 20.0, y: 30.0, pressure: 1.0 }, egui::Modifiers::NONE);
let mut pts = vec![[20.0, 30.0]];
for i in 1..80 {
+2
View File
@@ -2165,6 +2165,8 @@ cameraRaw Color Barvy
Apply At Canvas Bounds Použít efekt na hranicích plátna
Show/Hide All Panels Zobrazit/skrýt všechny panely
Show/Hide Panels Zobrazit/skrýt panely
Could not open Nelze otevřít
{path}: the path is not valid Unicode; rename the file and open it again. {path}: cesta není platný Unicode; přejmenujte soubor a otevřete jej znovu.
# Fill dialog, layer style contours and Image Size
Contents: Obsah:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2205,6 +2205,8 @@
Apply At Canvas Bounds Effekt an Arbeitsflächenbegrenzungen anwenden
Show/Hide All Panels Alle Bedienfelder ein-/ausblenden
Show/Hide Panels Bedienfelder ein-/ausblenden
Could not open Öffnen nicht möglich
{path}: the path is not valid Unicode; rename the file and open it again. {path}: Der Pfad ist kein gültiges Unicode; benennen Sie die Datei um und öffnen Sie sie erneut.
# Fill dialog, layer style contours and Image Size
Contents: Inhalt:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2236,6 +2236,8 @@ cameraRaw Amount Cantidad
Apply At Canvas Bounds Aplicar efecto en los límites del lienzo
Show/Hide All Panels Mostrar/ocultar todos los paneles
Show/Hide Panels Mostrar/ocultar paneles
Could not open No se pudo abrir
{path}: the path is not valid Unicode; rename the file and open it again. {path}: la ruta no es Unicode válido; cambia el nombre del archivo y vuelve a abrirlo.
# Fill dialog, layer style contours and Image Size
Contents: Contenido:
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2596,6 +2596,8 @@ cameraRaw Blending Fusion
Apply At Canvas Bounds Appliquer l’effet aux limites de la zone de travail
Show/Hide All Panels Afficher/masquer tous les panneaux
Show/Hide Panels Afficher/masquer les panneaux
Could not open Impossible d’ouvrir
{path}: the path is not valid Unicode; rename the file and open it again. {path} : le chemin n’est pas un Unicode valide ; renommez le fichier puis rouvrez-le.
# Fill dialog, layer style contours and Image Size
Contents: Contenu :
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2583,6 +2583,8 @@ cameraRaw Blending Pembauran
Apply At Canvas Bounds Terapkan efek di batas kanvas
Show/Hide All Panels Tampilkan/Sembunyikan Semua Panel
Show/Hide Panels Tampilkan/Sembunyikan Panel
Could not open Tidak dapat membuka
{path}: the path is not valid Unicode; rename the file and open it again. {path}: jalur bukan Unicode yang valid; ganti nama file lalu buka lagi.
# Fill dialog, layer style contours and Image Size
Contents: Isi:
Can't render this file because it contains an unexpected character in line 4 and column 47.
+2
View File
@@ -2239,6 +2239,8 @@ cameraRaw Amount Quantità
Apply At Canvas Bounds Applica effetto ai limiti dell’area di lavoro
Show/Hide All Panels Mostra/nascondi tutti i pannelli
Show/Hide Panels Mostra/nascondi pannelli
Could not open Impossibile aprire
{path}: the path is not valid Unicode; rename the file and open it again. {path}: il percorso non è Unicode valido; rinomina il file e riaprilo.
# Fill dialog, layer style contours and Image Size
Contents: Contenuto:
1 # Italian (it) catalog. Columns, separated by a TAB: context <TAB> source <TAB> translation
2239
2240
2241
2242
2243
2244
2245
2246
+2
View File
@@ -2148,6 +2148,8 @@ cameraRaw Color カラー
Apply At Canvas Bounds カンバスの境界に効果を適用
Show/Hide All Panels すべてのパネルを表示/非表示
Show/Hide Panels パネルを表示/非表示
Could not open 開けませんでした
{path}: the path is not valid Unicode; rename the file and open it again. {path}: パスが有効な Unicode ではありません。ファイル名を変更してから開き直してください。
# Fill dialog, layer style contours and Image Size
Contents: 内容:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2755,6 +2755,8 @@ cameraRaw Color 색상
Apply At Canvas Bounds 캔버스 경계에 효과 적용
Show/Hide All Panels 모든 패널 표시/숨기기
Show/Hide Panels 패널 표시/숨기기
Could not open 열 수 없음
{path}: the path is not valid Unicode; rename the file and open it again. {path}: 경로가 올바른 유니코드가 아닙니다. 파일 이름을 바꾼 뒤 다시 여세요.
Good 좋음
Best 최적
1 # Korean (ko) catalog. UTF-8: context<TAB>English source<TAB>translation.
2755
2756
2757
2758
2759
2760
2761
2762
+2
View File
@@ -3196,6 +3196,8 @@ cameraRaw Roughness Chropowatość
Camera Raw ({name}) Camera Raw ({name})
Show/Hide All Panels Pokaż/ukryj wszystkie panele
Show/Hide Panels Pokaż/ukryj panele
Could not open Nie można otworzyć
{path}: the path is not valid Unicode; rename the file and open it again. {path}: ścieżka nie jest prawidłowym Unicode; zmień nazwę pliku i otwórz go ponownie.
Native file drag-and-drop is not supported on Wayland yet. Use File › Open, or copy the image in your file manager and paste it with {paste}. Natywne przeciąganie i upuszczanie plików nie jest jeszcze obsługiwane w Wayland. Użyj Plik › Otwórz albo skopiuj obraz w menedżerze plików i wklej go za pomocą {paste}.
To drop files, start PhotoCraft under XWayland: `{command}` Aby upuszczać pliki, uruchom PhotoCraft pod XWayland: `{command}`
Edit › Paste Edycja › Wklej
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2603,6 +2603,8 @@ cameraRaw Whites Brancos
Apply At Canvas Bounds Aplicar efeito nos limites da tela
Show/Hide All Panels Mostrar/ocultar todos os painéis
Show/Hide Panels Mostrar/ocultar painéis
Could not open Não foi possível abrir
{path}: the path is not valid Unicode; rename the file and open it again. {path}: o caminho não é Unicode válido; renomeie o arquivo e abra-o novamente.
# Fill dialog, layer style contours and Image Size
Contents: Conteúdo:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2442,6 +2442,8 @@ cameraRaw Roughness Неровность
Apply At Canvas Bounds Применить эффект на границах холста
Show/Hide All Panels Показать/скрыть все панели
Show/Hide Panels Показать/скрыть панели
Could not open Не удалось открыть
{path}: the path is not valid Unicode; rename the file and open it again. {path}: путь не является допустимым Unicode; переименуйте файл и откройте его снова.
# Fill dialog, layer style contours and Image Size
Contents: Содержимое:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -2173,6 +2173,8 @@ cameraRaw Blending 混合
Apply At Canvas Bounds 应用画布边界的效果
Show/Hide All Panels 显示/隐藏所有面板
Show/Hide Panels 显示/隐藏面板
Could not open 无法打开
{path}: the path is not valid Unicode; rename the file and open it again. {path}:路径不是有效的 Unicode;请重命名文件后再打开。
# Fill dialog, layer style contours and Image Size
Contents: 内容:
1 # Simplified Chinese (zh-hans) catalog. UTF-8, TAB-separated columns:
2173
2174
2175
2176
2177
2178
2179
2180
+2
View File
@@ -2142,6 +2142,8 @@ cameraRaw Blending 混合
Apply At Canvas Bounds 套用效果至版面邊界
Show/Hide All Panels 顯示/隱藏所有面板
Show/Hide Panels 顯示/隱藏面板
Could not open 無法開啟
{path}: the path is not valid Unicode; rename the file and open it again. {path}:路徑不是有效的 Unicode;請重新命名檔案後再開啟。
# Fill dialog, layer style contours and Image Size
Contents: 內容:
Can't render this file because it contains an unexpected character in line 6 and column 47.
+21 -8
View File
@@ -39,6 +39,23 @@ pub(crate) fn clone_params(app: &PhotocraftApp) -> Option<Value> {
Some(p)
}
/// The command and options-bar params (without `points` and `target`) of a sequential-dab tool:
/// Blur, Sharpen, Smudge, Dodge, Burn, Sponge. Shared by the live preview and the commit.
pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static str, Value)> {
let o = &app.ui.tool_options;
Some(match tool {
Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})),
Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})),
Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})),
Tool::Dodge | Tool::Burn => (
if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" },
json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}),
),
Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})),
_ => return None,
})
}
/// Finish a stroke with a retouching tool. Returns false if `tool` isn't one.
pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], mods: egui::Modifiers) -> bool {
let o = app.ui.tool_options.clone();
@@ -78,14 +95,10 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m
(if tool == Tool::Healing { "paint.healingBrush" } else { "paint.cloneStamp" }, p)
}
Tool::HistoryBrush => ("paint.historyBrush", json!({})),
Tool::Blur => ("paint.blur", json!({"strength": o.strength, "sampleAllLayers": o.sample_all_layers})),
Tool::Sharpen => ("paint.sharpen", json!({"strength": o.strength, "protectDetail": o.protect_detail, "sampleAllLayers": o.sample_all_layers})),
Tool::Smudge => ("paint.smudge", json!({"strength": o.strength, "fingerPainting": o.finger_painting, "sampleAllLayers": o.sample_all_layers})),
Tool::Dodge | Tool::Burn => (
if tool == Tool::Dodge { "paint.dodge" } else { "paint.burn" },
json!({"range": o.tone_range, "exposure": o.exposure, "protectTones": o.protect_tones}),
),
Tool::Sponge => ("paint.sponge", json!({"mode": o.sponge_mode, "vibrance": o.vibrance})),
Tool::Blur | Tool::Sharpen | Tool::Smudge | Tool::Dodge | Tool::Burn | Tool::Sponge => match dab_params(app, tool) {
Some(cp) => cp,
None => return false,
},
Tool::QuickSelection => {
let size = app.session.tools.brush.size;
let mode = if mods.alt { "subtract" } else { "add" };