Content-Aware Fill workspace, as Photoshop's

Edit › Content-Aware Fill… opens Photoshop's workspace (measured on 25.4)
instead of the generic dialog: a full-window dialog like Liquify's.

- Tools: Sampling Brush (B), Lasso (L), Hand (H), Zoom (Z), each with its
  options bar.
- Document view: the sampling area tinted green at 50 % and the fill area's
  marching ants.
- Preview panel: the fill computed in the background after each change,
  opening framed on the sampling window.
- Content-Aware Fill panel: Sampling Area Overlay (Show, Opacity, Color,
  Indicates), Sampling Area Options (Auto, Rectangular, Custom, Sample All
  Layers), Fill Settings, Output Settings, and the reset buttons.
- OK, Apply, Cancel.

As Photoshop's:
- The Sampling Brush subtracts in Auto and Rectangular and adds in Custom,
  which starts empty; Alt reverses. Its first size is a tenth of the
  window's side. Painting keeps the option, and added strokes may lie past
  the window.
- The lasso edits the fill area (New, Add, Subtract, Intersect, Expand,
  Contract); the window and the Auto area follow.
- Apply fills and stays open; Cancel keeps what was applied. Ctrl+Z undoes
  a stroke or an edit. Output opens on New Layer; settings are remembered.

The workspace holds plain data. `edit.contentAwareFill` takes it as
`samplingStrokes`, `fillEdits`, `sampleAllLayers` and `layer`, and both
the preview and OK run one plan (`content_aware_plan`), so the preview is
what gets written. New strings are translated in every catalog.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Unemars
2026-10-10 10:05:40 +02:00
co-authored by Claude Opus 5.5
parent 2e1f3b0a41
commit 4d7db6dabe
25 changed files with 2397 additions and 72 deletions
+46
View File
@@ -234,6 +234,52 @@ fn content_aware_fill_samples_photoshops_window() {
}
}
#[test]
fn content_aware_fill_takes_the_workspace_edits() {
// The plan the workspace previews: lasso edits change the fill area (and the window follows),
// sampling-brush strokes change what may be copied, also outside the window.
let mut s = Session::new();
s.execute("file.new", json!({"width": 600, "height": 400, "background": "white"})).unwrap();
s.execute("select.rect", json!({"x": 275, "y": 175, "width": 50, "height": 50})).unwrap();
let st = s.active().unwrap();
let (doc, id) = (st.doc.clone(), st.active_layer.unwrap());
let plan = |p: Value| content_aware_plan(&doc, id, &p, "edit.contentAwareFill");
let count = |m: &[bool]| m.iter().filter(|v| **v).count();
let base = plan(json!({"sampling": "rectangular"})).unwrap();
assert_eq!((base.window, count(&base.hole)), (Rect::new(200, 100, 400, 300), 2500));
assert_eq!(count(&base.source), 200 * 200 - 2500);
// A lasso square added beside the selection: a bigger fill area, a window around both.
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "add", "points": [[340, 175], [390, 175], [390, 225], [340, 225]]}]})).unwrap();
assert_eq!(count(&p.hole), 5000);
assert!(p.window.width() > 200 && p.window.contains_rect(&Rect::new(275, 175, 390, 225)), "{:?}", p.window);
// Expand by 2 grows the 50 px square to about 54 px (rounded corners).
let p = plan(json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}]})).unwrap();
assert!((54 * 54 - 20..=54 * 54).contains(&count(&p.hole)), "{}", count(&p.hole));
// Subtracting it all leaves nothing to fill.
assert!(plan(json!({"fillEdits": [{"op": "subtract", "points": [[0, 0], [600, 0], [600, 400], [0, 400]]}]})).is_err());
// A subtracting stroke takes a band out of the sampling area; an adding one far away reaches
// past the window, and the plan's rectangle grows to hold it.
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "subtract", "size": 10, "points": [[210, 110], [390, 110]]}]})).unwrap();
assert!((count(&base.source) - count(&p.source)).abs_diff(10 * 190) < 60);
let p = plan(json!({"sampling": "rectangular", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[540, 350], [580, 350]]}]})).unwrap();
assert!(p.rect.contains(560, 350) && !p.window.contains(560, 350));
assert!(count(&p.source) > count(&base.source) + 600);
// Custom from strokes alone; bad edits are errors.
let p = plan(json!({"sampling": "custom", "samplingStrokes": [{"mode": "add", "size": 20, "points": [[100, 100], [160, 100]]}]})).unwrap();
assert!((1450..1600).contains(&count(&p.source)), "a 20 px brush along 60 px: {}", count(&p.source));
for bad in [
json!({"samplingStrokes": [{"mode": "paint", "size": 10, "points": [[0, 0]]}]}),
json!({"samplingStrokes": [{"size": 0, "points": [[0, 0]]}]}),
json!({"fillEdits": [{"op": "add", "points": [[0, "x"]]}]}),
json!({"fillEdits": [{"op": "expand", "by": -3}]}),
] {
assert!(plan(bad.clone()).is_err(), "{bad}");
}
// The command fills with the same plan; `layer` names the layer even when another is active.
let r = s.execute("edit.contentAwareFill", json!({"sampling": "rectangular", "fillEdits": [{"op": "expand", "by": 2}], "layer": id.0})).unwrap();
assert!(r["filled"].as_u64().unwrap() > 2800);
}
#[test]
fn content_aware_fill_outputs_and_sampling() {
let mut s = blob_session(8);