diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 6b5623a115..68312f1ce8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -20,13 +20,14 @@ next to them. | `crates/ui-egui/src/i18n/uk.tsv` | Ukrainian UI translations | PhotoCraft contributors | Original translations of PhotoCraft's English labels | MIT OR Apache-2.0, [`LICENSE-translations.txt`](crates/ui-egui/src/i18n/LICENSE-translations.txt) | | `assets/fonts/Inter-Regular.ttf`, `Inter-Medium.ttf`, `Inter-SemiBold.ttf` | Inter 4.001 (UI font) | The Inter Project Authors (Rasmus Andersson) | | SIL OFL 1.1, [`assets/fonts/OFL-Inter.txt`](assets/fonts/OFL-Inter.txt) | | `assets/fonts/JetBrainsMono-Regular.ttf` | JetBrains Mono 2.305 (numeric font) | The JetBrains Mono Project Authors | | SIL OFL 1.1, [`assets/fonts/OFL-JetBrainsMono.txt`](assets/fonts/OFL-JetBrainsMono.txt) | -| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/*.svg` except `slice-knife.svg`, `clip-below.svg`, `clip-release.svg`, `layer-mask.svg`, `adjustment-layer.svg`, `brush-replace.svg`, `eraser-background.svg`, `eraser-magic.svg`, `lasso-magnetic.svg`, `direct-select.svg`, `bandage-sparkle.svg`, `painting-symmetry.svg`, `symmetry-*.svg` and `marquee-{row,column}.svg` (112 files; `star-filled.svg` is `star.svg` with a solid fill) | Lucide icons | Lucide Icons and Contributors | (`icons/.svg`; `align-*` are now named `text-align-*` upstream) | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/{check,chevron-down,chevron-right,chevron-up,chevrons-left,chevrons-right,circle,clock,compass,info,link,link-2,lock,minus,monitor,moon,move,navigation,plus,search,square,trash,triangle,type,x,zoom-in}.svg` | Lucide icons derived from Feather (subset of the row above) | Cole Bemis (Feather), Lucide Contributors | via Lucide | MIT (Feather) and ISC (Lucide), [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/slice-knife.svg` | Slice tool glyph | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/marquee-row.svg`, `assets/icons/marquee-column.svg` | Single Row and Single Column Marquee tool glyphs: a dashed row (column) between two edge bars, drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/clip-below.svg`, `assets/icons/clip-release.svg` | Clipping-mask cursors (⌥ over the line between two layers): a hooked arrow onto the layer below; the same with a small "no" badge to release | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/layer-mask.svg`, `assets/icons/adjustment-layer.svg` | Layers panel footer glyphs: Add a mask (a frame around a solid disc) and New Fill or Adjustment Layer (a circle, one half solid), drawn on Lucide's 24 px grid and stroke | PhotoCraft contributors | Original work | MIT OR Apache-2.0 | | `assets/icons/eraser-background.svg`, `assets/icons/eraser-magic.svg` | Background Eraser and Magic Eraser tool glyphs: Lucide `eraser` plus added marks | Lucide Icons and Contributors; marks by PhotoCraft contributors | Derived from `icons/eraser.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | +| `assets/icons/brush-replace.svg` | Color Replacement tool glyph: Lucide `brush` plus two swap arrows | Lucide Icons and Contributors; arrows by PhotoCraft contributors | Derived from `icons/brush.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/bandage-sparkle.svg` | Remove tool glyph: Lucide `bandage` moved down, plus a sparkle mark | Lucide Icons and Contributors; mark by PhotoCraft contributors | Derived from `icons/bandage.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/direct-select.svg` | Direct Selection tool glyph: Lucide `mouse-pointer-2` plus an anchor square | Lucide Icons and Contributors; square by PhotoCraft contributors | Derived from `icons/mouse-pointer-2.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | | `assets/icons/painting-symmetry.svg` | Painting symmetry glyph: Lucide `heart` with a central axis | Lucide Icons and Contributors; adapted by @shorbank | Derived from `icons/heart.svg` | ISC, [`assets/icons/LICENSE-lucide.txt`](assets/icons/LICENSE-lucide.txt) | diff --git a/README.md b/README.md index 31fc11634f..1e550c03f0 100644 --- a/README.md +++ b/README.md @@ -178,8 +178,8 @@ Every screenshot here is the real app at work on public-domain art, rendered off
-

🧰 55 tools

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

🧰 56 tools

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

🖌️ A real brush engine

diff --git a/assets/icons/brush-replace.svg b/assets/icons/brush-replace.svg new file mode 100644 index 0000000000..9757f4f39a --- /dev/null +++ b/assets/icons/brush-replace.svg @@ -0,0 +1,19 @@ + + + + + + + + + diff --git a/crates/engine/src/brush_cmds.rs b/crates/engine/src/brush_cmds.rs index 473edbaf02..003accb8da 100644 --- a/crates/engine/src/brush_cmds.rs +++ b/crates/engine/src/brush_cmds.rs @@ -9,7 +9,7 @@ use photocraft_doc::LayerContent; use photocraft_geom::Rect; use photocraft_paint::mixer::{MixerSettings, apply_mixer_stroke}; -use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement}; +use photocraft_paint::replace::{Limits, ReplaceMode, ReplaceSettings, Sampling, apply_color_replacement, color_replacement_effect}; use photocraft_paint::{BrushPreset, BrushSettings, GrayTile, Stroke, StrokePoint, StrokeRenderer, TipShape, render_stroke}; use photocraft_raster::Surface; use serde_json::{Value, json}; @@ -656,8 +656,14 @@ fn mixer_brush(s: &mut Session, p: &Value) -> Result { Ok(damage_json(s, dmg)) } -fn color_replacement(s: &mut Session, p: &Value) -> Result { +/// `paint.colorReplacement`'s stroke and settings. The replacement colour is the brush colour +/// (the foreground unless `color` says otherwise). It paints layer pixels only: a mask, alpha +/// channel or Quick Mask `target` is refused rather than recolouring the layer instead. +fn color_replacement_setup(s: &Session, p: &Value) -> Result<(Stroke, ReplaceSettings, photocraft_doc::LayerId)> { let cmd = "paint.colorReplacement"; + if let Some(t) = p.get("target").filter(|t| t.as_str() != Some("pixels")) { + return Err(bad(cmd, format!("Color Replacement paints layer pixels only (`target` {t} is not \"pixels\")"))); + } let pts = parse_points(p, cmd)?; let brush = resolve_brush(s, p, cmd)?; fn parse(p: &Value, cmd: &str, k: &str) -> Result> { @@ -673,7 +679,11 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { background: s.tools.background, }; let id = layer_id(s, p)?; - let stroke = Stroke { brush, points: pts }; + Ok((Stroke { brush, points: pts }, rs, id)) +} + +fn color_replacement(s: &mut Session, p: &Value) -> Result { + let (stroke, rs, id) = color_replacement_setup(s, p)?; let dmg = s.edit("Color Replacement", |doc, _| { let sel = doc.selection.clone(); let lock = doc.effective_locks(id).transparency; @@ -683,6 +693,16 @@ fn color_replacement(s: &mut Session, p: &Value) -> Result { Ok(damage_json(s, dmg)) } +/// A `paint.colorReplacement` stroke shown while it is drawn: the same params, and the same +/// recolouring per dab as the commit, so the preview at the last point is the committed result. +pub fn live_color_replacement(s: &Session, p: &Value) -> Result { + let (stroke, rs, id) = color_replacement_setup(s, p)?; + crate::retouch_cmds::LiveDab::with_effect(s, "paint.colorReplacement", &json!({}), stroke, Some(id), 0, |pre| { + Box::new(color_replacement_effect(pre.clone(), rs)) + })? + .with_tail_preview() +} + fn brush_json(b: &BrushSettings) -> Value { serde_json::to_value(b).unwrap_or(Value::Null) } @@ -1000,7 +1020,7 @@ pub fn specs() -> Vec { spec!( "paint.colorReplacement", "Color Replacement", - r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"seed":u64?}"##, + r##"{"points":[…],"brush":{…}?,"size":px?,"mode":"hue|saturation|color|luminosity"="color","sampling":"continuous|once|backgroundSwatch"="continuous","limits":"contiguous|discontiguous|findEdges"="contiguous","tolerance":0..100=30,"antiAlias":bool=true,"color":"#rrggbb"?=foreground,"target":"pixels"="pixels","seed":u64?}"##, crate::commands::has_paintable, color_replacement, true diff --git a/crates/engine/src/brush_cmds/tests.rs b/crates/engine/src/brush_cmds/tests.rs index 0dfafb428a..8da49ebad3 100644 --- a/crates/engine/src/brush_cmds/tests.rs +++ b/crates/engine/src/brush_cmds/tests.rs @@ -159,6 +159,60 @@ fn color_replacement_modes() { assert!(s.execute("paint.colorReplacement", json!({"points": [[1, 1]], "mode": "bogus"})).is_err()); } +/// The live Color Replacement stroke shows what `paint.colorReplacement` commits, at 8 and 16 +/// bits: red-left/blue-right halves, a stroke from red into blue in each sampling mode. The live +/// working copy is stored at the layer's depth between dabs, so 8 bits may round a level apart. +#[test] +fn live_color_replacement_matches_the_commit() { + for depth in [8, 16] { + for sampling in ["continuous", "once", "backgroundSwatch"] { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 80, "height": 40, "background": "#ff0000", "depth": depth})).unwrap(); + s.execute("select.rect", json!({"x": 40, "y": 0, "width": 40, "height": 40})).unwrap(); + s.execute("edit.fill", json!({"color": "#0000ff"})).unwrap(); + s.execute("select.deselect", json!({})).unwrap(); + s.execute("tools.setColors", json!({"foreground": "#00ff00", "background": "#0000ff"})).unwrap(); + let all = [[10.0, 20.0], [30.0, 20.0], [50.0, 20.0], [70.0, 22.0]]; + let pts = |v: &[[f64; 2]]| v.iter().map(|q| StrokePoint::new(q[0], q[1], 1.0)).collect::>(); + let opts = |p: &[[f64; 2]]| json!({"points": p, "size": 12, "hardness": 50, "sampling": sampling, "tolerance": 30, "target": "pixels"}); + let mut live = live_color_replacement(&s, &opts(&all[..1])).unwrap(); + live.push(&pts(&all[1..3])).unwrap(); + live.push(&pts(&all[3..])).unwrap(); + let shown = live.doc.clone(); + s.execute("paint.colorReplacement", opts(&all)).unwrap(); + let id = s.active().unwrap().active_layer.unwrap(); + let (a, b) = (shown.layer(id).unwrap().surface().unwrap(), s.active().unwrap().doc.layer(id).unwrap().surface().unwrap()); + let mut worst = 0.0f32; + // Everywhere, the stroke's finishing dab included (the live stroke previews it). + for y in 0..40 { + for x in 0..80 { + let (got, want) = (a.rgba(x, y), b.rgba(x, y)); + worst = (0..4).map(|c| (got[c] - want[c]).abs()).fold(worst, f32::max); + } + } + assert!(b.rgba(10, 20)[1] > 0.5 || sampling == "backgroundSwatch", "{depth}-bit {sampling}: recoloured"); + assert!(worst <= 2.0 / 255.0, "{depth}-bit {sampling}: live and commit differ by {worst}"); + } + } +} + +/// Color Replacement recolours layer pixels only: a mask, channel or Quick Mask target is +/// refused (it would otherwise recolour the layer behind the user's back). +#[test] +fn color_replacement_refuses_non_pixel_targets() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 20, "height": 20, "background": "#ff0000"})).unwrap(); + let steps = s.active().unwrap().history.entries().len(); + for t in [json!("mask"), json!("quickMask"), json!({"channel": 0}), json!(3)] { + let p = json!({"points": [[10, 10]], "size": 6, "target": t}); + assert!(s.execute("paint.colorReplacement", p.clone()).is_err(), "{t}"); + assert!(live_color_replacement(&s, &p).is_err(), "{t}"); + } + assert_eq!(s.active().unwrap().history.entries().len(), steps); + s.execute("paint.colorReplacement", json!({"points": [[10, 10]], "size": 6, "target": "pixels"})).unwrap(); + assert_eq!(s.active().unwrap().history.entries().len(), steps + 1); +} + #[test] fn presets_save_load_delete_round_trip() { let mut s = session(10, 10); diff --git a/crates/engine/src/retouch_cmds.rs b/crates/engine/src/retouch_cmds.rs index 75eedc899d..0da474c22b 100644 --- a/crates/engine/src/retouch_cmds.rs +++ b/crates/engine/src/retouch_cmds.rs @@ -788,7 +788,7 @@ enum DabTool { } /// One dab's edit of the working copy. -type DabEffect = Box; +pub(crate) type DabEffect = Box; impl DabTool { /// The tool for `cmd` and its history label. @@ -919,6 +919,13 @@ pub struct LiveDab { sel: Option, lock: bool, bounds: Rect, + /// Show the dabs finishing the stroke would add, so the preview at the last point is the + /// commit. Only for effects that a repeated dab doesn't change (Color Replacement): the + /// tail runs on a scratch copy at every push, so an effect that carries paint from dab to + /// dab (Smudge) would drift. + preview_tail: bool, + /// Where the shown tail is. + tail: Rect, } impl LiveDab { @@ -930,11 +937,26 @@ impl LiveDab { } let (stroke, id) = parse_brush(s, p, cmd)?; let (tool, _) = DabTool::parse(s, cmd, p, &stroke)?; + let (halo, spacing) = (tool.halo(), stroke.brush.spacing); + Self::with_effect(s, cmd, p, stroke, id, halo, |pre| tool.effect(pre.format(), spacing)) + } + + /// Start a live stroke of `cmd` that runs `effect` (made from the target before the stroke) + /// at each dab of `stroke`, reading `halo` pixels around it. `p` gives the target. + pub(crate) fn with_effect( + s: &Session, + cmd: &str, + p: &Value, + stroke: Stroke, + id: Option, + halo: i32, + effect: impl FnOnce(&Surface) -> DabEffect, + ) -> Result { 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); + let effect = effect(&pre_surf); // `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 { @@ -943,7 +965,7 @@ impl LiveDab { renderer, done: 0, effect, - halo: tool.halo(), + halo, work: pre_surf.clone(), pre_surf, id, @@ -952,11 +974,20 @@ impl LiveDab { sel, lock, bounds: Rect::EMPTY, + preview_tail: false, + tail: Rect::EMPTY, }; live.push(&stroke.points)?; Ok(live) } + /// Also show the stroke's finishing dabs (see `preview_tail`). + pub(crate) fn with_tail_preview(mut self) -> Result { + self.preview_tail = true; + self.push(&[])?; + Ok(self) + } + /// Everything the stroke has touched so far. pub fn bounds(&self) -> Rect { self.bounds @@ -981,14 +1012,37 @@ impl LiveDab { dmg = dmg.union(&r); } self.done += new.len(); - if dmg.is_empty() { - return Ok(dmg); + // The finishing dabs, run on a scratch copy of the working pixels. + let mut tail = None; + if self.preview_tail { + let ds = self.renderer.tail_dabs(); + let r = ds.iter().fold(Rect::EMPTY, |a, d| a.union(&ctx.dab_rect(d, false))); + if !r.is_empty() { + let mut region = Region::read(&self.work, r.inflate(self.halo)); + for (k, d) in ds.iter().enumerate() { + (self.effect)(&mut region, &ctx.footprint(d, self.done + k)); + } + tail = Some((r, region.crop(r))); + } } - self.bounds = self.bounds.union(&dmg); - let (orig, work) = (Region::read(&self.pre_surf, dmg), Region::read(&self.work, dmg)); + // Back to the stroke without the previous tail. + let old = std::mem::replace(&mut self.tail, Rect::EMPTY); + let shown = dmg.union(&old); + let tail_rect = tail.as_ref().map_or(Rect::EMPTY, |(r, _)| *r); + if shown.is_empty() && tail_rect.is_empty() { + return Ok(Rect::EMPTY); + } + self.bounds = self.bounds.union(&dmg).union(&tail_rect); 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) + if !shown.is_empty() { + let (orig, work) = (Region::read(&self.pre_surf, shown), Region::read(&self.work, shown)); + photocraft_paint::retouch::mix_back(surf, &orig, &work, shown, self.opacity, self.sel.as_ref(), self.lock); + } + if let Some((r, work)) = tail { + photocraft_paint::retouch::mix_back(surf, &Region::read(&self.pre_surf, r), &work, r, self.opacity, self.sel.as_ref(), self.lock); + self.tail = r; + } + Ok(shown.union(&tail_rect)) } } diff --git a/crates/paint/src/render.rs b/crates/paint/src/render.rs index 9d1d7fa5fa..a4683ca756 100644 --- a/crates/paint/src/render.rs +++ b/crates/paint/src/render.rs @@ -627,6 +627,15 @@ impl StrokeRenderer { self.raster_pending(); } + /// The primary dabs finishing the stroke now would add, without finishing it: for live + /// previews of sequential-dab tools, which run each dab themselves (see [`tail_preview`](Self::tail_preview)). + pub fn tail_dabs(&self) -> Vec { + let mut generator = self.generator.clone(); + let (mut dabs, mut duals) = (Vec::new(), Vec::new()); + generator.finish(&mut dabs, &mut duals); + dabs + } + /// What finishing the stroke now would add (the smoothing catch-up tail to the last point, or /// a lone first dab), for live previews: a renderer holding copies of the coverage tiles the /// tail touches with the tail rendered in, so its `composite` draws exactly those tiles as diff --git a/crates/paint/src/replace.rs b/crates/paint/src/replace.rs index 53a90835ef..3cb02e9a24 100644 --- a/crates/paint/src/replace.rs +++ b/crates/paint/src/replace.rs @@ -8,7 +8,7 @@ use photocraft_raster::{Surface, from_rgba_into, to_rgba}; use serde::{Deserialize, Serialize}; use crate::Stroke; -use crate::retouch::{Region, apply_dab_stroke}; +use crate::retouch::{Footprint, Region, apply_dab_stroke}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -79,9 +79,16 @@ fn dist(a: &[f32; 4], b: [f32; 3]) -> f32 { /// Replace colours along a stroke. Returns the damaged rectangle. pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &ReplaceSettings, selection: Option<&Surface>, lock_transparency: bool) -> Rect { - let fmt = target.format(); + let effect = color_replacement_effect(target.clone(), rs.clone()); + apply_dab_stroke(target, stroke, selection, lock_transparency, 0, effect) +} + +/// One Color Replacement dab's edit of a working copy, matching against `pre` (the target before +/// the stroke). Shared by [`apply_color_replacement`] and live previews that run the dabs as they +/// come, so both recolour the same pixels. +pub fn color_replacement_effect(pre: Surface, rs: ReplaceSettings) -> impl FnMut(&mut Region, &Footprint) { + let fmt = pre.format(); let n = fmt.channels(); - let pre = target.clone(); let mode = match rs.mode { ReplaceMode::Hue => BlendMode::Hue, ReplaceMode::Saturation => BlendMode::Saturation, @@ -92,7 +99,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla let tol = rs.tolerance.clamp(0.0, 1.0); let soft = rs.anti_alias && rs.limits != Limits::FindEdges; let mut once: Option<[f32; 3]> = None; - let match_w = |d: f32| -> f32 { + let match_w = move |d: f32| -> f32 { if d > tol { 0.0 } else if soft && tol > 0.0 { @@ -102,7 +109,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla 1.0 } }; - apply_dab_stroke(target, stroke, selection, lock_transparency, 0, |work: &mut Region, fp| { + move |work: &mut Region, fp: &Footprint| { let (cx, cy) = (fp.dab.center.x.floor() as i32, fp.dab.center.y.floor() as i32); let sampled = match rs.sampling { Sampling::Continuous => { @@ -187,7 +194,7 @@ pub fn apply_color_replacement(target: &mut Surface, stroke: &Stroke, rs: &Repla px.copy_from_slice(&enc[..n]); } } - }) + } } #[cfg(test)] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 06d52ff886..70f0f0be76 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -467,8 +467,19 @@ fn stroke_params(app: &PhotocraftApp, tool: Tool, erase: bool, points: &[Vec bool { - 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() + matches!( + tool, + Tool::Brush + | Tool::Pencil + | Tool::Eraser + | Tool::ColorReplacement + | 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. @@ -584,7 +595,12 @@ fn begin_live_stroke(app: &PhotocraftApp) -> Option { 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 let Some((cmd, mut p)) = crate::retouch_ui::dab_params(app, d.tool) { + let stroke = if d.tool == Tool::ColorReplacement { + // The params `retouch_ui::finish_stroke` commits. + let mut p = crate::retouch_ui::color_replacement_params(app); + p["points"] = json!(d.points); + EngineStroke::Dab(photocraft_engine::brush_cmds::live_color_replacement(&app.session, &p).ok()?) + } else 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); @@ -678,6 +694,7 @@ pub(crate) fn freehand_tool(tool: Tool) -> bool { Tool::Brush | Tool::Pencil | Tool::MixerBrush + | Tool::ColorReplacement | Tool::Eraser | Tool::BackgroundEraser | Tool::HistoryBrush @@ -4572,7 +4589,7 @@ pub(crate) fn finish_gesture(app: &mut PhotocraftApp, d: Drag) { app.last_stroke_end = Some((st.doc.id, end)); } // A live Clone Stamp preview ends here; the commit below replaces it. - if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() { + if live_retouch_command(d.tool).is_some() || crate::retouch_ui::dab_params(app, d.tool).is_some() || d.tool == Tool::ColorReplacement { 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) { @@ -5431,6 +5448,7 @@ mod tests { Tool::Brush, Tool::Pencil, Tool::MixerBrush, + Tool::ColorReplacement, Tool::Eraser, Tool::BackgroundEraser, Tool::CloneStamp, diff --git a/crates/ui-egui/src/color_replacement_tests.rs b/crates/ui-egui/src/color_replacement_tests.rs new file mode 100644 index 0000000000..bfa5037df8 --- /dev/null +++ b/crates/ui-egui/src/color_replacement_tests.rs @@ -0,0 +1,264 @@ +//! The Color Replacement tool (#1045): a canvas stroke dispatches `paint.colorReplacement` with +//! the options bar's Mode, Sampling, Limits, Tolerance and Anti-alias and the foreground colour, +//! drawn live, one History step; the brush-tool gestures, `ui.set` and `ui.inspect`. + +use egui::{Modifiers, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use serde_json::json; + +use crate::PhotocraftApp; +use crate::canvas::{ToolEvent, tool_event}; +use crate::state::Tool; + +/// An 80×40 document at `depth` bits, red on the left half and blue on the right, foreground +/// green and background blue, with the Color Replacement tool picked (12 px, hard, no smoothing). +fn app(depth: u32) -> PhotocraftApp { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 80, "height": 40, "background": "#ff0000", "depth": depth})).unwrap(); + fill(&mut app, [40, 0, 40, 40], "#0000ff"); + app.run("tools.setColors", json!({"foreground": "#00ff00", "background": "#0000ff"})).unwrap(); + app.ui.tool = Tool::ColorReplacement; + crate::paint_mouse::sync_tool_brush(&mut app); + app.run("tools.setBrush", json!({"brush": {"size": 12, "hardness": 1.0, "pressureSize": false, "smoothing": {"amount": 0.0}}})).unwrap(); + let o = &mut app.ui.tool_options; + (o.cr_mode, o.cr_limits, o.cr_tolerance) = ("color".into(), "discontiguous".into(), 30.0); + app +} + +fn fill(app: &mut PhotocraftApp, [x, y, width, height]: [i32; 4], color: &str) { + app.run("select.rect", json!({"x": x, "y": y, "width": width, "height": height})).unwrap(); + app.run("edit.fill", json!({"color": color})).unwrap(); + app.run("select.deselect", json!({})).unwrap(); +} + +fn rgba(app: &PhotocraftApp, x: i32, y: i32) -> [f32; 4] { + let st = app.session.active().unwrap(); + st.doc.layer(st.active_layer.unwrap()).unwrap().surface().unwrap().rgba(x, y) +} + +fn drag(app: &mut PhotocraftApp, pts: &[(f64, f64)]) { + let m = Modifiers::NONE; + let (x, y) = pts[0]; + tool_event(app, ToolEvent::Down { x, y, pressure: 1.0 }, m); + for &(x, y) in &pts[1..] { + tool_event(app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + let &(x, y) = pts.last().unwrap(); + tool_event(app, ToolEvent::Up { x, y }, m); + assert!(!app.ui.status_error, "{}", app.ui.status); +} + +/// Red under the brush took the foreground's green hue (Color mode keeps its luminosity). +fn greened(c: [f32; 4]) -> bool { + c[1] > c[0] + 0.05 && c[1] > c[2] + 0.05 +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 1.0]; +/// From red (left half) across into blue (right half). +const STROKE: [(f64, f64); 4] = [(10.0, 20.0), (30.0, 20.0), (50.0, 20.0), (70.0, 20.0)]; + +#[test] +fn sampling_once_recolours_the_start_colour_and_continuous_follows_the_brush() { + for depth in [8, 16] { + let mut app = app(depth); + app.ui.tool_options.cr_sampling = "once".into(); + drag(&mut app, &STROKE); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!(id, "paint.colorReplacement"); + assert_eq!(p["sampling"], "once"); + assert!(greened(rgba(&app, 20, 20)), "{depth}-bit: red under the brush is recoloured: {:?}", rgba(&app, 20, 20)); + assert_eq!(rgba(&app, 20, 5), RED, "{depth}-bit: outside the brush"); + assert_eq!(rgba(&app, 60, 20), BLUE, "{depth}-bit: blue doesn't match the colour sampled once"); + + let mut app = self::app(depth); + app.ui.tool_options.cr_sampling = "continuous".into(); + drag(&mut app, &STROKE); + assert!(greened(rgba(&app, 20, 20)), "{depth}-bit"); + let blue = rgba(&app, 60, 20); + assert!(blue[1] > 0.05 && blue != BLUE, "{depth}-bit: continuous sampling recolours the blue too: {blue:?}"); + } +} + +#[test] +fn background_swatch_recolours_only_the_background_colour() { + for depth in [8, 16] { + let mut app = app(depth); + app.ui.tool_options.cr_sampling = "backgroundSwatch".into(); + drag(&mut app, &STROKE); + assert_eq!(rgba(&app, 20, 20), RED, "{depth}-bit: red isn't the background colour"); + let blue = rgba(&app, 60, 20); + assert!(blue[1] > 0.05 && blue != BLUE, "{depth}-bit: blue (the background swatch) is recoloured: {blue:?}"); + } +} + +#[test] +fn contiguous_limits_skip_a_matching_island_that_discontiguous_recolours() { + for (limits, island_recoloured) in [("contiguous", false), ("discontiguous", true)] { + let mut app = app(8); + fill(&mut app, [0, 0, 80, 40], "#0000ff"); + // A red patch under the brush centre, and a red island inside the brush beyond blue. + fill(&mut app, [16, 16, 8, 8], "#ff0000"); + fill(&mut app, [27, 18, 3, 4], "#ff0000"); + app.run("tools.setBrush", json!({"brush": {"size": 30}})).unwrap(); + app.ui.tool_options.cr_limits = limits.into(); + app.ui.tool_options.cr_sampling = "once".into(); + drag(&mut app, &[(20.0, 20.0)]); + assert!(greened(rgba(&app, 20, 20)), "{limits}: the patch under the centre"); + assert_eq!(greened(rgba(&app, 28, 20)), island_recoloured, "{limits}: the island {:?}", rgba(&app, 28, 20)); + assert_eq!(rgba(&app, 25, 20), BLUE, "{limits}: blue between them"); + } +} + +/// The preview at the last move is the committed stroke: exactly at 32 bits, within one level at +/// 8 (the live stroke keeps its working pixels in the layer's depth between dabs). +#[test] +fn a_stroke_is_drawn_live_and_is_one_history_step() { + for depth in [8, 32] { + live_stroke_at(depth); + } +} + +fn live_stroke_at(depth: u32) { + let mut app = app(depth); + let before = rgba(&app, 20, 20); + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 20.0, pressure: 1.0 }, m); + for &(x, y) in &STROKE[1..] { + tool_event(&mut app, ToolEvent::Move { x, y, pressure: 1.0 }, m); + } + assert!(app.live_stroke.is_some(), "the canvas shows the stroke while dragging"); + let (shown, _) = crate::canvas::display_doc(&mut app, 0); + let id = app.session.active().unwrap().active_layer.unwrap(); + let shown = shown.layer(id).unwrap().surface().unwrap().clone(); + assert!(greened(shown.rgba(20, 20)), "the preview recolours as it paints"); + tool_event(&mut app, ToolEvent::Up { x: 70.0, y: 20.0 }, m); + assert!(app.live_stroke.is_none()); + // The preview at the last move is the committed stroke. + let level = if depth == 8 { 1.0 / 255.0 + 1e-6 } else { 0.0 }; + for y in 0..40 { + for x in 0..80 { + let (got, want) = (shown.rgba(x, y), rgba(&app, x, y)); + assert!((0..4).all(|c| (got[c] - want[c]).abs() <= level), "{depth}-bit ({x}, {y}): {got:?} vs {want:?}"); + } + } + let st = app.session.active().unwrap(); + assert_eq!(st.history.past_len(), steps + 1, "one History step"); + assert_eq!(st.history.entries().last().map(|e| e.to_string()).as_deref(), Some("Color Replacement")); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(rgba(&app, 20, 20), before, "one undo restores the pixels"); + app.run("edit.redo", json!({})).unwrap(); + assert!(greened(rgba(&app, 20, 20)), "redo reapplies the stroke"); +} + +/// A targeted layer mask isn't recoloured, and neither is the layer behind it. +#[test] +fn a_mask_target_is_refused_without_painting() { + let mut app = app(8); + app.run("layer.new.layer", json!({})).unwrap(); + fill(&mut app, [0, 0, 80, 40], "#ff0000"); + app.run("layer.layerMask.revealAll", json!({})).unwrap(); + app.ui.mask_target = true; + let steps = app.session.active().unwrap().history.past_len(); + let m = Modifiers::NONE; + tool_event(&mut app, ToolEvent::Down { x: 10.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Move { x: 30.0, y: 20.0, pressure: 1.0 }, m); + tool_event(&mut app, ToolEvent::Up { x: 30.0, y: 20.0 }, m); + assert!(app.ui.status_error && app.ui.status.contains("layer pixels only"), "{}", app.ui.status); + assert_eq!(rgba(&app, 20, 20), RED); + assert_eq!(app.session.active().unwrap().history.past_len(), steps); +} + +/// Every options-bar control reaches `paint.colorReplacement`, with the foreground as `color`. +#[test] +fn the_options_bar_sets_every_command_parameter() { + let mut h = Harness::builder().with_size(vec2(1600.0, 400.0)).build_ui_state( + |ui, app: &mut PhotocraftApp| { + if !ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) { + return; + } + crate::panels::options_bar(app, ui); + }, + app(8), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::Studio); + h.run_steps(4); + for label in ["Mode:", "Limits:", "Tolerance:", "Anti-alias", "Sampling: Continuous", "Sampling: Once", "Sampling: Background Swatch"] { + assert!(h.get_by_label(label).rect().is_positive(), "missing Color Replacement option {label}"); + } + let combo = |h: &Harness<'static, PhotocraftApp>, current: &str| { + h.get_all_by_role(egui::accesskit::Role::ComboBox) + .find(|n| n.accesskit_node().value().as_deref() == Some(current) || n.accesskit_node().label().as_deref() == Some(current)) + .unwrap() + .click(); + }; + combo(&h, "Color"); + h.run_steps(3); + h.get_by_label("Luminosity").click(); + h.run_steps(3); + combo(&h, "Discontiguous"); + h.run_steps(3); + h.get_by_label("Find Edges").click(); + h.run_steps(3); + h.get_by_label("Sampling: Once").click(); + h.run_steps(3); + h.get_by_label("Anti-alias").click(); + h.run_steps(3); + // The Tolerance field: click into it and type a new value. + h.get_all_by_role(egui::accesskit::Role::SpinButton).find(|n| n.accesskit_node().numeric_value() == Some(30.0)).expect("Tolerance field").click(); + h.run_steps(1); + h.event(egui::Event::Text("55".into())); + h.key_press(egui::Key::Tab); + h.run_steps(2); + let o = &h.state().ui.tool_options; + assert_eq!((o.cr_mode.as_str(), o.cr_limits.as_str(), o.cr_sampling.as_str(), o.cr_anti_alias), ("luminosity", "findEdges", "once", false)); + assert_eq!(o.cr_tolerance, 55.0); + let app = h.state_mut(); + drag(app, &STROKE); + let (id, p) = app.session.journal.last().cloned().unwrap(); + assert_eq!(id, "paint.colorReplacement"); + for (k, v) in + [("mode", json!("luminosity")), ("sampling", json!("once")), ("limits", json!("findEdges")), ("tolerance", json!(55.0)), ("antiAlias", json!(false))] + { + assert_eq!(p[k], v, "{k}: {p}"); + } + // The replacement colour is the foreground. + assert_eq!(app.session.tools.foreground, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(p["color"], json!([0.0, 1.0, 0.0, 1.0]), "{p}"); +} + +#[test] +fn it_paints_like_the_brush_tools() { + assert!(Tool::ColorReplacement.is_brushlike(), "brush cursor, brush chip, [ ] and ⇧[ ⇧]"); + assert!(crate::brush_resize::applies(Tool::ColorReplacement), "Control+Alt drag and Alt+right drag resize it"); + assert!(crate::canvas::freehand_tool(Tool::ColorReplacement)); + let mut a = app(8); + let ctx = egui::Context::default(); + let size = a.session.tools.brush.size; + // ] and [ step the size of the tool's brush. + a.run("tools.increaseBrushSize", json!({})).unwrap(); + assert!(a.session.tools.brush.size > size); + a.run("tools.decreaseBrushSize", json!({})).unwrap(); + assert_eq!(a.session.tools.brush.size, size); + // Alt+right drag resizes and paints nothing. + let events = json!([{"kind": "down", "x": 40, "y": 20}, {"kind": "move", "x": 60, "y": 20}, {"kind": "up", "x": 70, "y": 20}]); + let (req, _rx) = crate::control::ControlRequest::new("ui.pointer", json!({"tool": "colorReplacement", "button": "right", "alt": true, "events": events})); + let _ = crate::control::handle(&mut a, &ctx, &req); + assert!(a.session.tools.brush.size > size, "{}", a.session.tools.brush.size); + assert!(!a.session.journal.iter().any(|(id, _)| id == "paint.colorReplacement"), "nothing painted"); +} + +#[test] +fn ui_set_selects_it_and_inspect_reports_it() { + let mut a = app(8); + a.ui.tool = Tool::Move; + let ctx = egui::Context::default(); + let (req, _rx) = crate::control::ControlRequest::new("ui.set", json!({"tool": "colorReplacement"})); + let _ = crate::control::handle(&mut a, &ctx, &req); + assert_eq!(a.ui.tool, Tool::ColorReplacement); + assert_eq!(crate::control::inspect(&a, &ctx)["tool"], "ColorReplacement"); + assert_eq!(Tool::ColorReplacement.key(), 'B'); + assert_eq!(Tool::ColorReplacement.label(), "Color Replacement Tool"); +} diff --git a/crates/ui-egui/src/hidden_layer_tests.rs b/crates/ui-egui/src/hidden_layer_tests.rs index c1753d3e1a..806aadd8a2 100644 --- a/crates/ui-egui/src/hidden_layer_tests.rs +++ b/crates/ui-egui/src/hidden_layer_tests.rs @@ -65,7 +65,7 @@ fn moving_a_hidden_target_layer_is_refused() { #[test] fn painting_a_hidden_layer_is_refused_at_the_press() { - for tool in [Tool::Brush, Tool::Eraser, Tool::PaintBucket, Tool::Gradient, Tool::CloneStamp] { + for tool in [Tool::Brush, Tool::Eraser, Tool::ColorReplacement, Tool::PaintBucket, Tool::Gradient, Tool::CloneStamp] { let (mut app, id) = app(tool); // The live Gradient makes a new Gradient Fill layer; the classic one paints the target. app.ui.tool_options.gradient_classic = true; diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index f8142bc550..d2c0aabb20 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2061,6 +2061,8 @@ Apply the pen's tilt and rotation to the tip shape Použít náklon a natočení pera na tvar hrotu Mixer Brush Míchací štětec Mixer Brush Tool Nástroj Míchací štětec + Color Replacement Tool Nástroj Nahrazení barvy + Color Replacement Nahrazení barvy Wet Vlhkost Mix Míchání Wetness Jitter Kolísání vlhkosti diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 1311a2e60e..ff967f6832 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2028,6 +2028,7 @@ Apply the pen's tilt and rotation to the tip shape Neigung und Drehung des Stifts auf die Spitzenform anwenden Mixer Brush Mischer-Pinsel Mixer Brush Tool Mischer-Pinsel-Werkzeug + Color Replacement Tool Farbersatz-Werkzeug Wet Feucht Mix Mischen Wetness Jitter Feuchtigkeitsschwankung diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 4821be08a8..2c7ecf9a79 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1898,6 +1898,8 @@ White Balance: As Shot Ισορροπία λευκού: όπως στη λήψη Mixer Brush Πινέλο ανάμειξης (Mixer Brush) Mixer Brush Tool Πινέλο ανάμειξης (Mixer Brush Tool) + Color Replacement Tool Αντικατάσταση χρώματος (Color Replacement Tool) + Color Replacement Αντικατάσταση χρώματος Wet Υγρασία Load Φόρτωση Mix Ανάμειξη diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2685492b96..20113fe7ef 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2044,6 +2044,7 @@ Apply the pen's tilt and rotation to the tip shape Aplicar la inclinación y la rotación del lápiz a la forma de la punta Mixer Brush Pincel mezclador Mixer Brush Tool Herramienta Pincel mezclador + Color Replacement Tool Herramienta Sustitución de color Wet Húmedo Mix Mezcla Wetness Jitter Variación de humedad diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index dea5222043..d5c71e0ab3 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2545,6 +2545,7 @@ Edit › Paste Édition › Coller Use File › Open, or paste a copied image with {paste}. Utilisez Fichier › Ouvrir, ou collez une image copiée avec {paste}. Mixer Brush Tool Outil Pinceau mélangeur + Color Replacement Tool Outil Remplacement de couleur Invalid {mode} profile Profil {mode} non valide Unnamed {mode} profile Profil {mode} sans nom Show bounding box when dragging layer Afficher le cadre de sélection lors du déplacement du calque diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index e86fd8a764..1cebdbc365 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2534,6 +2534,7 @@ Edit › Paste Edit › Tempel Use File › Open, or paste a copied image with {paste}. Gunakan Berkas › Buka, atau tempel gambar yang disalin dengan {paste}. Mixer Brush Tool Alat Kuas Pencampur + Color Replacement Tool Alat Penggantian Warna Invalid {mode} profile Profil {mode} tidak valid Unnamed {mode} profile Profil {mode} tanpa nama Show bounding box when dragging layer Tampilkan kotak pembatas saat menyeret layer diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 3b9089bbb9..454f59f0bf 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2042,6 +2042,7 @@ Apply the pen's tilt and rotation to the tip shape Applica inclinazione e rotazione penna alla forma punta Mixer Brush Pennello miscelatore Mixer Brush Tool Strumento Pennello miscelatore + Color Replacement Tool Strumento Sostituzione colore Wet Bagnato Mix Miscela Wetness Jitter Variazione bagnato diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 0c1da09f59..fcc77b5b82 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2045,6 +2045,7 @@ Apply the pen's tilt and rotation to the tip shape ペンの傾きと回転を先端のシェイプに適用します Mixer Brush 混合ブラシ Mixer Brush Tool 混合ブラシツール + Color Replacement Tool 色の置き換えツール Wet ウェット Mix 混合 Wetness Jitter ウェットのジッター diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 97cc92d9e7..5e8e1b8aec 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2080,6 +2080,8 @@ Show bounding box when dragging layer 레이어를 드래그할 때 테두리 상자 표시 System title bar 시스템 제목 표시줄 Mixer Brush Tool 혼합 브러시 도구 + Color Replacement Tool 색상 대체 도구 + Color Replacement 색상 대체 Rendering Mode 렌더링 모드 Automatic (recommended) 자동(권장) CPU / Compatibility CPU / 호환성 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 173af2b176..92f8690035 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2014,6 +2014,7 @@ Apply the pen's tilt and rotation to the tip shape Kanteling en rotatie van de pen toepassen op de vorm van de punt Mixer Brush Mengpenseel Mixer Brush Tool Mengpenseelgereedschap + Color Replacement Tool Kleurvervangingsgereedschap Wet Nat Mix Mengen Wetness Jitter Nattigheidsvariatie diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index cd69b75992..0290f6603d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2034,6 +2034,7 @@ Apply the pen's tilt and rotation to the tip shape Zastosuj nachylenie i obrót pióra do kształtu końcówki Mixer Brush Pędzel mieszający Mixer Brush Tool Narzędzie Pędzel mieszający + Color Replacement Tool Narzędzie Zamiana koloru Wet Wilgotność Mix Mieszanie Wetness Jitter Rozrzut wilgotności diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 333bdfa393..65add0f7e6 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2542,6 +2542,7 @@ Edit › Paste Editar › Colar Use File › Open, or paste a copied image with {paste}. Use Arquivo › Abrir ou cole uma imagem copiada com {paste}. Mixer Brush Tool Ferramenta Pincel misturador + Color Replacement Tool Ferramenta Substituição de cor Invalid {mode} profile Perfil {mode} inválido Unnamed {mode} profile Perfil {mode} sem nome Show bounding box when dragging layer Mostrar caixa delimitadora ao arrastar a camada diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7b8c168d89..0b0def8eb2 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -515,6 +515,7 @@ Magnetic Lasso Магнитное лассо Mixer Brush Смешивающая кисть Mixer Brush Tool Инструмент «Смешивающая кисть» + Color Replacement Tool Инструмент «Замена цвета» History Brush Кисть истории Art History Brush Кисть истории искусств Pattern Stamp Узорный штамп diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index da29b0059c..5acf28dc06 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -494,6 +494,7 @@ Magnetic Lasso Магнітне ласо Mixer Brush Змішувальний пензель Mixer Brush Tool Інструмент «Змішувальний пензель» + Color Replacement Tool Інструмент «Заміна кольору» History Brush Пензель історії Art History Brush Художній пензель історії Pattern Stamp Візерунковий штамп diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 3e72040e02..0528835ffb 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1198,6 +1198,8 @@ Midtones from: 中间调下限: Midtones to: 中间调上限: Mixer Brush Tool 混合画笔工具 + Color Replacement Tool 颜色替换工具 + Color Replacement 颜色替换 Minimum Depth 最小深度 Minimum Diameter 最小直径 Minimum Roundness 最小圆度 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 89710fc571..9c39f4f493 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2043,6 +2043,8 @@ Apply the pen's tilt and rotation to the tip shape 將筆的傾斜和旋轉套用至筆尖形狀 Mixer Brush 混合器筆刷 Mixer Brush Tool 混合器筆刷工具 + Color Replacement Tool 顏色取代工具 + Color Replacement 顏色取代 Wet 濕度 Mix 混合 Wetness Jitter 濕度快速變換 diff --git a/crates/ui-egui/src/icon_data.rs b/crates/ui-egui/src/icon_data.rs index 26449b7b5f..9f4f2f1e23 100644 --- a/crates/ui-egui/src/icon_data.rs +++ b/crates/ui-egui/src/icon_data.rs @@ -13,6 +13,7 @@ pub static ICONS: &[(&str, &[u8])] = &[ ("bandage-sparkle", include_bytes!("../../../assets/icons/bandage-sparkle.svg")), ("blend", include_bytes!("../../../assets/icons/blend.svg")), ("brush", include_bytes!("../../../assets/icons/brush.svg")), + ("brush-replace", include_bytes!("../../../assets/icons/brush-replace.svg")), ("brush-cleaning", include_bytes!("../../../assets/icons/brush-cleaning.svg")), ("check", include_bytes!("../../../assets/icons/check.svg")), ("chevron-down", include_bytes!("../../../assets/icons/chevron-down.svg")), diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 33b290eda3..fbe93757f4 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -92,6 +92,7 @@ pub fn tool_icon(t: Tool) -> &'static str { Tool::Brush => "brush", Tool::Pencil => "pencil", Tool::MixerBrush => "palette", + Tool::ColorReplacement => "brush-replace", Tool::Eraser => "eraser", Tool::BackgroundEraser => "eraser-background", Tool::MagicEraser => "eraser-magic", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b04560094..8b26ec837b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2045,6 +2045,8 @@ mod color_swatch_tests; #[cfg(test)] mod input_tests; +#[cfg(test)] +mod color_replacement_tests; #[cfg(test)] mod pencil_tests; diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index a0e620766d..e556aa9c66 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -25,7 +25,7 @@ const TOOL_SECTIONS: &[&[&[Tool]]] = &[ ], &[ &[Tool::Remove, Tool::SpotHealing, Tool::Healing, Tool::Patch, Tool::ContentAwareMove, Tool::RedEye], - &[Tool::Brush, Tool::Pencil, Tool::MixerBrush], + &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement], &[Tool::CloneStamp, Tool::PatternStamp], &[Tool::HistoryBrush], &[Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser], @@ -4174,6 +4174,44 @@ mod type_flyout_tests { assert_eq!(app.ui.tool, Tool::PatternStamp); } + #[test] + fn long_press_brush_selects_color_replacement() { + let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + PhotocraftApp::setup_context(&ctx, crate::theme::ThemeKind::ALL[0]); + frame(&mut app, &ctx, 0.0, vec![]); + frame(&mut app, &ctx, 0.1, vec![]); + let index = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).position(|slot| slot.contains(&Tool::Brush)).unwrap(); + let slot = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).nth(index).copied().unwrap(); + // Photoshop's Brush group, Color Replacement after the Mixer Brush. + assert_eq!(slot, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); + let bx = if Tokens::get(&ctx).pro { 30.0 } else { 36.0 }; + let mut buttons: Vec = ctx.viewport(|v| { + v.prev_pass + .widgets + .layers() + .flat_map(|(_, w)| w.iter()) + .filter(|w| w.rect.size() == egui::Vec2::splat(bx) && w.sense.senses_click()) + .map(|w| w.rect) + .collect() + }); + buttons.sort_by(|a, b| a.top().total_cmp(&b.top())); + let at = buttons[index].center(); + let pointer = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(&mut app, &ctx, 1.0, vec![egui::Event::PointerMoved(at), pointer(at, true)]); + frame(&mut app, &ctx, 1.36, vec![]); + frame(&mut app, &ctx, 1.4, vec![pointer(at, false)]); + frame(&mut app, &ctx, 1.45, vec![]); + let key = egui::Id::new(("tool-slot", index)); + let menu = ctx.memory(|m| m.area_rect(key.with("flyout"))).unwrap(); + // The last of the four 26-point rows. + let row = egui::pos2(menu.left() + 65.0, menu.bottom() - 20.0); + frame(&mut app, &ctx, 2.0, vec![egui::Event::PointerMoved(row), pointer(row, true)]); + frame(&mut app, &ctx, 2.05, vec![pointer(row, false)]); + assert_eq!(app.ui.tool, Tool::ColorReplacement); + assert_eq!(crate::icons::tool_icon(Tool::ColorReplacement), "brush-replace"); + } + #[test] fn rotate_view_is_in_the_hand_flyout() { let hand = TOOL_SECTIONS.iter().flat_map(|section| section.iter()).find(|slot| slot.contains(&Tool::Hand)).expect("Hand group"); @@ -4213,7 +4251,7 @@ mod toolbar_hidden_tests { let slots: usize = TOOL_SECTIONS.iter().map(|section| section.len()).sum(); hide(&mut app, &Tool::ALL); assert_eq!(tool_buttons(&mut app), all - slots, "every tool slot is gone when every tool is hidden"); - hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush]); + hide(&mut app, &[Tool::Brush, Tool::Pencil, Tool::MixerBrush, Tool::ColorReplacement]); assert_eq!(tool_buttons(&mut app), all - 1, "a slot whose tools are all hidden is gone"); hide(&mut app, &[Tool::Sponge]); assert_eq!(tool_buttons(&mut app), all, "a slot with a visible tool stays"); diff --git a/crates/ui-egui/src/pencil_tests.rs b/crates/ui-egui/src/pencil_tests.rs index 19255962d9..2b6f461b55 100644 --- a/crates/ui-egui/src/pencil_tests.rs +++ b/crates/ui-egui/src/pencil_tests.rs @@ -215,7 +215,7 @@ fn the_eraser_in_block_mode_erases_a_16_screen_px_square() { } #[test] -fn b_cycles_brush_pencil_and_mixer_brush() { +fn b_cycles_brush_pencil_mixer_brush_and_color_replacement() { let mut h = Harness::builder().with_size(vec2(1280.0, 800.0)).with_max_steps(64).build_eframe(|cc| { PhotocraftApp::setup_context(&cc.egui_ctx, Default::default()); let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default()); @@ -235,6 +235,8 @@ fn b_cycles_brush_pencil_and_mixer_brush() { assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::NONE); assert_eq!(h.state().ui.tool, Tool::MixerBrush); + press(&mut h, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); // Use Shift Key for Tool Switch: ⇧B cycles, B keeps the group's tool. h.state_mut().session.edit_prefs(|p| p.tools.use_shift_key_for_tool_switch = true); press(&mut h, Modifiers::SHIFT); @@ -243,8 +245,10 @@ fn b_cycles_brush_pencil_and_mixer_brush() { assert_eq!(h.state().ui.tool, Tool::Pencil); press(&mut h, Modifiers::SHIFT); assert_eq!(h.state().ui.tool, Tool::MixerBrush); + press(&mut h, Modifiers::SHIFT); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); press(&mut h, Modifiers::NONE); - assert_eq!(h.state().ui.tool, Tool::MixerBrush); + assert_eq!(h.state().ui.tool, Tool::ColorReplacement); assert_eq!(Tool::from_name("pencil"), Some(Tool::Pencil)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); } diff --git a/crates/ui-egui/src/retouch_ui.rs b/crates/ui-egui/src/retouch_ui.rs index 74d8490e20..8e78a5dd98 100644 --- a/crates/ui-egui/src/retouch_ui.rs +++ b/crates/ui-egui/src/retouch_ui.rs @@ -1,6 +1,7 @@ //! UI for the retouching tools (healing, patch, clone, history brush, blur/sharpen/smudge, -//! dodge/burn/sponge, Mixer Brush) and the smart selection tools (Quick Selection, Object -//! Selection): gesture → engine command, options bars, and the clone-source marker. +//! dodge/burn/sponge, Mixer Brush, Color Replacement) and the smart selection tools (Quick +//! Selection, Object Selection): gesture → engine command, options bars, and the clone-source +//! marker. use egui::{Color32, Stroke, vec2}; use serde_json::{Value, json}; @@ -60,6 +61,23 @@ pub(crate) fn dab_params(app: &PhotocraftApp, tool: Tool) -> Option<(&'static st }) } +/// `paint.colorReplacement` params from the options bar (without `points`): shared by the live +/// preview and the commit. The replacement `color` is the foreground, recorded so a replayed +/// stroke paints the same colour; Background Swatch sampling matches the background colour. +pub(crate) fn color_replacement_params(app: &PhotocraftApp) -> Value { + let o = &app.ui.tool_options; + json!({ + "color": app.session.tools.foreground, + "mode": o.cr_mode, + "sampling": o.cr_sampling, + "limits": o.cr_limits, + "tolerance": o.cr_tolerance, + "antiAlias": o.cr_anti_alias, + // A mask or channel target is refused: the tool recolours layer pixels only. + "target": crate::canvas::paint_target(app), + }) +} + /// 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(); @@ -68,6 +86,7 @@ pub fn finish_stroke(app: &mut PhotocraftApp, tool: Tool, points: &[[f64; 3]], m Tool::SpotHealing => ("paint.spotHealing", json!({"type": o.spot_type, "sampleAllLayers": o.sample_all_layers})), Tool::Remove => ("paint.remove", json!({"sampleAllLayers": o.sample_all_layers})), Tool::MixerBrush => ("paint.mixerBrush", json!({})), + Tool::ColorReplacement => ("paint.colorReplacement", color_replacement_params(app)), Tool::PatternStamp => { let mut p = json!({ "aligned": o.pattern_stamp_aligned, @@ -406,6 +425,40 @@ pub fn options_bar(app: &mut PhotocraftApp, ui: &mut egui::Ui, tool: Tool) -> bo crate::widgets::checkbox(ui, &mut o.finger_painting, tl!("Finger Painting")); } } + Tool::ColorReplacement => { + // Photoshop's options bar: Mode, Sampling (three icon toggles), Limits, Tolerance, Anti-alias. + opt(ui, tl!("Mode:")); + let modes = [ + ("hue".to_string(), tl!("Hue")), + ("saturation".to_string(), tl!("Saturation")), + ("color".to_string(), tl!("Color")), + ("luminosity".to_string(), tl!("Luminosity")), + ]; + crate::widgets::dropdown(ui, "color-replacement-mode", &mut o.cr_mode, &modes, 100.0); + crate::widgets::vline(ui, 22.0); + ui.spacing_mut().item_spacing.x = 2.0; + for (k, icon, tip) in [ + ("continuous", "pipette", tl!("Sampling: Continuous")), + ("once", "circle-dot", tl!("Sampling: Once")), + ("backgroundSwatch", "square", tl!("Sampling: Background Swatch")), + ] { + if crate::brush_picker::named(crate::icons::button(ui, icon, 24.0, o.cr_sampling == k, tip), tip).clicked() { + o.cr_sampling = k.into(); + } + } + ui.spacing_mut().item_spacing.x = 8.0; + crate::widgets::vline(ui, 22.0); + opt(ui, tl!("Limits:")); + let limits = [ + ("discontiguous".to_string(), tl!("Discontiguous")), + ("contiguous".to_string(), tl!("Contiguous")), + ("findEdges".to_string(), tl!("Find Edges")), + ]; + crate::widgets::dropdown(ui, "color-replacement-limits", &mut o.cr_limits, &limits, 110.0); + opt(ui, tl!("Tolerance:")); + crate::widgets::value_field(ui, &mut o.cr_tolerance, 0.0..=100.0, "%", 62.0); + crate::widgets::checkbox(ui, &mut o.cr_anti_alias, tl!("Anti-alias")); + } Tool::HistoryBrush => opt(ui, "Paints from the document's opening state"), Tool::QuickSelection => { crate::widgets::checkbox(ui, &mut o.sample_all_layers, tl!("Sample All Layers")); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index deb82b7885..6e9c531611 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -82,6 +82,7 @@ pub enum Tool { Brush, Pencil, MixerBrush, + ColorReplacement, Eraser, BackgroundEraser, MagicEraser, @@ -123,7 +124,7 @@ pub enum Tool { } impl Tool { - pub const ALL: [Tool; 55] = [ + pub const ALL: [Tool; 56] = [ Tool::Move, Tool::RectMarquee, Tool::EllipseMarquee, @@ -141,6 +142,7 @@ impl Tool { Tool::Brush, Tool::Pencil, Tool::MixerBrush, + Tool::ColorReplacement, Tool::Eraser, Tool::BackgroundEraser, Tool::MagicEraser, @@ -191,6 +193,7 @@ impl Tool { Tool::Brush => "Brush Tool", Tool::Pencil => "Pencil Tool", Tool::MixerBrush => "Mixer Brush Tool", + Tool::ColorReplacement => "Color Replacement Tool", Tool::Eraser => "Eraser Tool", Tool::BackgroundEraser => "Background Eraser Tool", Tool::MagicEraser => "Magic Eraser Tool", @@ -251,6 +254,7 @@ impl Tool { Tool::Brush | Tool::Pencil | Tool::MixerBrush + | Tool::ColorReplacement | Tool::Eraser | Tool::BackgroundEraser | Tool::Remove @@ -272,7 +276,7 @@ impl Tool { match self { Tool::Move => 'V', Tool::RectMarquee | Tool::EllipseMarquee => 'M', - Tool::Brush | Tool::Pencil | Tool::MixerBrush => 'B', + Tool::Brush | Tool::Pencil | Tool::MixerBrush | Tool::ColorReplacement => 'B', Tool::Eraser | Tool::BackgroundEraser | Tool::MagicEraser => 'E', Tool::Eyedropper | Tool::Ruler | Tool::Note | Tool::Count => 'I', Tool::Lasso | Tool::PolygonLasso | Tool::MagneticLasso => 'L', @@ -574,6 +578,14 @@ pub struct ToolOptions { pub bg_limits: String, pub bg_tolerance: f32, pub bg_protect_fg: bool, + /// Color Replacement: mode (hue | saturation | color | luminosity), sampling (continuous | + /// once | backgroundSwatch), limits (discontiguous | contiguous | findEdges), tolerance % and + /// anti-alias, as `paint.colorReplacement` takes them. + pub cr_mode: String, + pub cr_sampling: String, + pub cr_limits: String, + pub cr_tolerance: f32, + pub cr_anti_alias: bool, /// Zoom tool › Scrubby Zoom: dragging left/right zooms continuously (else a zoom rectangle). #[serde(default = "yes")] pub zoom_scrubby: bool, @@ -721,6 +733,11 @@ impl Default for ToolOptions { bg_limits: "contiguous".into(), bg_tolerance: 50.0, bg_protect_fg: false, + cr_mode: "color".into(), + cr_sampling: "continuous".into(), + cr_limits: "contiguous".into(), + cr_tolerance: 30.0, + cr_anti_alias: true, zoom_scrubby: true, pencil_auto_erase: false, eraser_mode: default_eraser_mode(), @@ -1204,13 +1221,15 @@ mod tests { assert_eq!(Tool::from_name("Eraser Tool"), Some(Tool::Eraser)); assert_eq!(Tool::from_name("mixerBrush"), Some(Tool::MixerBrush)); assert_eq!(Tool::from_name("Mixer Brush Tool"), Some(Tool::MixerBrush)); + assert_eq!(Tool::from_name("colorReplacement"), Some(Tool::ColorReplacement)); + assert_eq!(Tool::from_name("Color Replacement Tool"), Some(Tool::ColorReplacement)); assert_eq!(Tool::from_name("redEye"), Some(Tool::RedEye)); assert_eq!(Tool::from_name("Red Eye Tool"), Some(Tool::RedEye)); assert_eq!(Tool::RedEye.key(), 'J'); assert!(!Tool::RedEye.is_brushlike()); assert_eq!(Tool::from_name("patternStamp"), Some(Tool::PatternStamp)); assert_eq!(Tool::from_name("Pattern Stamp Tool"), Some(Tool::PatternStamp)); - assert_eq!(Tool::ALL.len(), 55); + assert_eq!(Tool::ALL.len(), 56); assert_eq!(Tool::from_name("Remove Tool"), Some(Tool::Remove)); assert_eq!(Tool::Remove.key(), 'J'); assert!(Tool::Remove.is_brushlike()); diff --git a/docs/scorecard.md b/docs/scorecard.md index a1e456fbd4..f1c83e8c52 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) | 5 | 11 | 11 | 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) | 21 | 17 | 4 | | +| [Tools](#tools) | 22 | 17 | 4 | | | [Workspace and UI](#workspace-and-ui) | 6 | 7 | 10 | settings that do nothing: 37 of 152 (target 0) | | [Type](#type) | 0 | 3 | 6 | | | [Automation](#automation) | 3 | 2 | 6 | 630 of 630 menu items live (100.0%) | @@ -172,6 +172,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t | TOOL-214-23 | Crop: Photoshop's default mode (the image turns and moves behind an upright box, Auto Center Preview keeps a resized box's centre in place and centres a drawn one) and Use Classic Mode (P) in the gear menu | done | [#1919](https://github.com/storytold/photocraft/issues/1919) | crop_mode.rs: the crop model (crop_rect + crop_angle) and image.crop are shared by both modes; default mode sets the view's rotation to -crop_angle while the tool shows a frame (picking the tool, in either mode, turns a Rotate View turn upright about the view centre, not restored after Enter/Esc or by P; Classic Mode keeps the camera upright with the frame drawn turned; as Photoshop 25.1), pivoting so the box stays put, moves view and frame together for drags inside and arrow keys, keeps a resized box's centre in place live (Auto Center Preview; a drawn box is centred on release, a moved one never), readout = the image's turn; gear menu order and Classic Mode greying as Photoshop 25.1; CropShield classic_mode / auto_center_preview (ui.set cropShield), P ahead of the Pen key | | TOOL-1053-1 | Shape stroke controls: solid/dashed/dotted, custom dash/gap/offset, caps, joins, alignment, miter limit and opacity | done | [#1053](https://github.com/storytold/photocraft/issues/1053) | shape_stroke_ui.rs: shared staged editor, renderer samples, cached Properties canvas preview; existing shape.create/edit/info; serde tool defaults, ui.set shapeStroke; pcraft and vstk style round trips. Gradient fill UI and saved stroke libraries remain open; Line retains filled-bar geometry | | TOOL-213-19 | Remove tool: paint over an object (or circle it) and it is filled from its surroundings | partial | [#213](https://github.com/storytold/photocraft/issues/213) | paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result | +| TOOL-1045-1 | Color Replacement tool on the toolbar | done | [#1045](https://github.com/storytold/photocraft/issues/1045) | Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs | ## Workspace and UI diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 023ac15ee7..c2b1d23f2d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -431,7 +431,7 @@ filled bar. This extends UI reachability and does not claim Photoshop-authored s | Smart filters / text / effect shapes in PSDs | Measured on our Photoshop-authored set (https://github.com/storytold/photocraft-corpus, `corpus/photoshop`, 258 files): see the per-group floors in `crates/io/tests/corpus.rs` and `crates/engine/tests/photoshop_oracles.rs`. Smart objects and smart filters now survive PSD save and open (41 corpus files, 206 smart objects, round trip strict; Photoshop opens our exports with live filters); re-rendering Photoshop's smart filters with ours matches 5/30 (was 1/30) | low–medium | Remaining re-render gaps are filter maths (Gaussian/Motion Blur, Unsharp Mask, Emboss, Add Noise RNG) and bicubic placement. | | Core editing (layers, masks, selections, adjustments, filters, transforms) | Broad engine coverage; many interaction bugs fixed after 0.2.0 (adjustment dialogs, Curves, crop, Move/Transform modifiers, gesture origin) | medium | Fixes not yet user-validated. | | UI / UX polish | Shortcut audit 214 → 0 failures; dock, Layers rows and menus reworked; first visual-QA sweep found 14 defects (#147–#157). 2026-10-07: the keyboard-only shortcuts with no menu item (⌥[ ⌥] ⌥, ⌥. layer navigation, ⇧⌥[ ⇧⌥] to extend the selection, 1–0 for opacity and ⇧ for flow or fill, ⇧[ ⇧] hardness, ⌥⌘T to transform a copy and ⌥⇧⌘T to step and repeat), ⌥-click colour sampling with painting tools, double-click a Layers row for Layer Style, File › New from Clipboard, a centred main window and remembered Liquify settings (#352, #417, #350, #368, #419, #418). 2026-10-09: canvas zoom is physical at any display scale — 100% is one document pixel per physical display pixel, and every screen-space overlay, cursor, scrollbar, navigator and fit follows it (#1943) | low–medium | Needs recurring visual QA with realistic documents. | -| Tools | 2026-10-09: Pencil, Mixer Brush, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | +| Tools | 2026-10-10: Pencil, Mixer Brush, Color Replacement, Patch, Content-Aware Move, Vertical Type, Pattern Stamp and Rotate View are toolbar tools. Remaining missing tools include Red Eye, Art History Brush, Freeform/Curvature Pen, Add/Delete Anchor Point tools, Color Sampler, Perspective Crop, type masks and Frame | low–medium | Patch has a live healing preview. Content-Aware Move is partial: Move/Extend, Structure/Color and Sample All Layers exist; Transform On Drop and a live result preview remain missing (`TOOL-213-4` in the scorecard). Magic/Background Eraser added; live gradients in progress (#180). Magnetic Lasso added 2026-10-08 (live-wire edge tracing, Width/Contrast/Frequency, `select.magneticLasso`). Pattern Stamp added 2026-10-08 (S flyout, `paint.patternStamp`, Aligned / Impressionist). Rotate View (2026-10-09) turns the canvas camera around its centre without rewriting pixels; Reset View and Match Rotation copy the angle. Crop rotation (2026-10-09, #1792): dragging outside the crop frame turns it (⇧ 15° steps, angle readout), and ↵ rotates the document and crops in one undo step (`image.crop` `angle`); the frame turns over the image as in Photoshop's Classic Mode, and the options-bar Straighten is still missing. | | Painting | Brush model and Brush Settings panel near Photoshop; .abr/.grd import; persistent presets; pen pressure/tilt on Windows, web, macOS and X11 | medium | Native Wayland pen input open (#79; a pen opens the window through Xwayland meanwhile); X11 pressure confirmed by a user, macOS not yet verified on tablet hardware. | | Text / typography | Engine works; caret placement and size editing fixed; OpenType features, text-on-path editing, composer parity partial | medium-low | Measure with the Photoshop-authored set. | | Colour management | Colour-managed canvas (document → monitor), embedded CMYK profiles, linear EXR/HDR, 16-bit float canvas | medium-high | Monitor profile follows only at launch. | diff --git a/scorecard/tools.toml b/scorecard/tools.toml index 843618e9d0..4c5817ee01 100644 --- a/scorecard/tools.toml +++ b/scorecard/tools.toml @@ -298,3 +298,10 @@ target = "Remove tool: paint over an object (or circle it) and it is filled from status = "partial" issue = 213 note = "paint.remove (retouch_cmds/remove.rs, background job) + Tool::Remove in the J flyout; loops close over what they enclose; fill by non-local patch completion with texture features, onion-peel start, best-patch copy and gradient-domain seam hiding (algo/nonlocal.rs, Newson et al. IPOL 2017); sparse holes in tiles (algo/remove.rs). remove_quality on 20 holes in public-domain photos: the fill keeps 0.89 of the texture it replaces (0.54 with Content-Aware Fill's completion), edge gradient 1.03 of the original's; remove_bench 24 MP: 100 px ring 120 ms, 1000 px ring 2.5 s, 3000 px line 1.7 s. Missing: generative AI mode, Remove after each stroke off, live preview of the result" + +[[item]] +id = "TOOL-1045-1" +target = "Color Replacement tool on the toolbar" +status = "done" +issue = 1045 +note = "Tool::ColorReplacement in the Brush (B) flyout after the Mixer Brush; Mode/Sampling/Limits/Tolerance/Anti-alias options drive paint.colorReplacement with the foreground colour; live preview (LiveDab with the stroke's finishing dab), one History step; color_replacement_tests.rs"