Review of f116c32 was right that the guard did not establish the bound it
advertised. reject_bounds ran on the value stroke_coverage() returned, and that
function calls dense_coverage() internally, so the dense Vec was already
allocated by the time it was checked. reject_huge_stroke, the only check that
ran first, estimated from the raw points and half the tip, which brush dynamics
exceed: Scattering offsets a dab centre by its amount times the tip size.
Split rendering from dense extraction. stroke_coverage_within renders the stroke
into the renderer's tiles, which hold only what the dabs touch, reads the extent
once every dab is placed and refuses before allocating the width x height
buffer. retouch_cmds::coverage wraps it at MAX_STROKE_PIXELS and every stroke
command goes through it, so the bound now holds whatever the dynamics do.
reject_bounds is removed; it could not do the job it was named for.
spot_heal_surface returned a bare Rect and so had nowhere to refuse; it returns
Result<Rect> now. reject_huge_stroke stays as the cheap early-out and pads by
the scatter reach and the dual-brush radius so it no longer falls short.
The allowance is unchanged, so no stroke that worked before starts failing.
Tests: coverage_within takes the budget as a parameter, and a scattering brush
proves the rendered extent exceeds what the points alone suggest, is refused one
pixel under it and fits at it. Every command's refusal now asserts the pixels
and the history step count are untouched, and the ordinary stroke uses a valid
fixture and must succeed rather than merely avoid the size error.
Pattern Stamp was the only retouch command that refused a stroke it could not
render. Clone Stamp, Healing Brush, Spot Healing and Remove all reach the same
dense conversion: coverage is accumulated in tiles, but dense_coverage()
materialises the whole bounding box, and the retouch paths read a Region over
that box for every channel. The box comes from the stroke's own points, which
are validated against MAX_COORD rather than the document, so it is not bounded
by the canvas. Both allocations go through the infallible vec!, which aborts
instead of returning an error.
With the guards now shared in retouch_cmds, apply them to the rest:
reject_huge_stroke before anything is rendered, and reject_bounds before the
dense region read where the command can return one. spot_heal_surface returns a
Rect, so Spot Healing charges the stroke in the command; Remove builds a list,
so each stroke is charged as it is parsed.
The allowance is unchanged, so no stroke that worked before starts failing.
Test: every retouch stroke refuses the oversized box, leaves the pixels alone,
and still accepts a stroke across the whole canvas.
The History Brush renders its coverage and reads its source region densely
over the stroke's bounds. Coverage is accumulated in tiles, so a thin stroke
holds only the tiles it touches, but dense_coverage() materialises the whole
bounding box and Region::read allocates that box again for every channel.
Neither is admitted first, and both go through the infallible vec!, which
aborts the process instead of returning an error.
Pattern Stamp already refuses a stroke it cannot render: reject_huge_stroke
bounds it from the points and the brush size before anything is rendered,
reject_bounds checks the rendered box, and MAX_STROKE_PIXELS (8192 squared)
leaves room for a stroke that runs corner to corner across a 24-36 MP canvas.
The three were private to that module.
Move them to the shared retouch_cmds module and apply them in the History
Brush: reject_huge_stroke before the source surface is cloned or any coverage
is rendered, reject_bounds before the dense source read. The budget is
unchanged, so every stroke that worked before still works.
Clone Stamp, Healing Brush, Spot Healing and Remove reach the same conversion
and are left for a follow-up.
Test: a stroke whose box dwarfs the canvas is refused with the size error and
leaves the pixels as they were, while one that runs corner to corner across
the canvas still paints, at 8, 16 and 32 bits.
* Tests: bad-layer-id checks use an id that can't exist (#3343)
Layer ids come from one process-wide counter (shared with document and
smart-object ids, and bumped by ensure_ids_above on file loads). A full
`cargo test` run passes ids like 9999, so a layer a test just created can
get the "bad" id, the command succeeds and the assertion fails at random
(seen on CI in type_extra_cmds::tests::vertical_orientation_renders_columns_inside_the_canvas).
Use u64::MAX, which the counter never reaches, for every "no such layer"
sentinel in the engine and ui-egui tests. Test-only change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: use u64::MAX for bad-layer-id sentinels added on main since (#3343)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop 27.11's round tip, measured on single dabs (hardness 0-100 in
steps of 10 at 40 px, checked at 100, 9, 7, 5, 3, 2 and 1 px): a solid
core of radius r0*R, then a Gaussian shoulder exp(-((d - r0) / s)^2) of
width s*R, both from a table by hardness. A soft tip reaches well past its
nominal radius (hardness 0 is a Gaussian of sigma 0.47 R: 8 % at 1.05 R);
PhotoCraft's (1 - t^2)^4 ended at R and kept far less coverage (a 40 px
hardness-0 dab held 251 coverage units against Photoshop's 542). The edge
stays at least ~1 px wide (0.3 R on small tips, never under 1/4 px).
exp is (1 - u/64)^64 by squaring, so it is the same on every platform.
Dab rectangles, reaches and the retouch footprint grow to the tip's reach
(tip_reach); the brush cursor's 50 % ring (half_coverage_radius) follows the
new curve. Tests that hard-coded the old curve now check measured values;
tests that bounded a stroke by the nominal radius use the reach.
Measured against Photoshop (max levels / mean): 40 px dabs, hardness 0-70:
70-156 / 12-15 -> 2-6.5 / <0.7; 100 px dabs 70-177 -> 2-27; strokes
(probe 13) soft and medium 20-60 px 133-178 / up to 17 -> 4-26 / <=0.5;
spacing sweeps 134-221 -> 7-9; soft Flow build-up 85 / 7.5 -> 5 / 0.16;
small 1-7 px total error lower in 21 of 30 cases (medium hardness at
2-5 px a little higher: a follow-up).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Content-Aware Move: live preview of the result while the pointer rests (#4385)
A cancellable, debounced background job shows the move/extend result during the drag and in the Transform On Drop box.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs: regenerate scorecard after merging main
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Releasing a Clone Stamp or History Brush stroke rendered the whole
stroke again, although the live stroke had already drawn exactly those
pixels. The canvas now offers the live stroke to its commit as the Brush
does (#2131), through the same LiveCommit: a commit with the same
command, document revision, params and points takes the live surface
and reports fromLive; anything else renders as before.
LiveRetouch renders only the position and pressure of its points, which
is all the commands parse, and the offer is refused when the brush, layer
or clone source changed during the stroke (the revision doesn't cover
them). The Healing Brush, which blends on release, is left out.
* Format: check pattern sizes on load (#3478)
A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.
- format: reject patterns 0 px on a side (Corrupt) or above the PSD
reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
and a bounded corner.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* gpu: skip unpaintable patterns in the Bevel texture too
The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
* Content-Aware Move: Transform On Drop (#3071)
The Content-Aware Move tool gains Photoshop's Transform On Drop checkbox. With it on, the drop
puts a transform box on the moved patch (the Free Transform box's handles, limited to move,
scale and rotate); Enter, the check button or a double-click computes the content-aware blend
with the transformed patch, Esc or the cancel button drops the whole move. The document is
untouched until the commit, so the move stays one History step.
Engine: paint.contentAwareMove takes scaleX / scaleY (%, 1..10000, negative flips) and rotate
(degrees, clockwise) about the selection's centre before the offset, validated (Err, not a
panic, for non-finite, out-of-range or off-canvas placements). The content and the selection's
shape are resampled through the inverse map on float samples (any depth); the selection is
transformed with the content. A plain move keeps the old, exact code path.
UI: cam_transform.rs holds the box (UiState.cam_transform, view state); the option is remembered
in ToolOptions and settable with ui.set camTransformOnDrop; ui.inspect reports camTransform.
Scorecard TOOL-213-4 stays partial: no live preview of the blended result yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tests: keep Transform On Drop cases in the Content-Aware Move test, not Patch (#3071)
paint.patch takes no scaleX/scaleY/rotate, so those cases (which only
paint.contentAwareMove rejects) failed patch_fails_gracefully. They are
already covered by content_aware_move_fails_gracefully.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Create Texture (inpaint::synthesize) averaged each block 50/50 into the
already-synthesised overlap. Averaging two unrelated grain patterns cuts
their standard deviation by about 1/sqrt(2), so the fill came out smoother
than its surroundings (75 % of the grain for one 30 px dab), and every
overlapping stroke, which samples the previous ones, lost more (57 % after
eight). Blocks are now joined along Efros & Freeman's minimum-error
boundary cut (Image Quilting, SIGGRAPH 2001): each pixel is copied from
one block or the other, never averaged.
Content-Aware and Proximity Match already keep the grain on main since
#2202 (patch copies instead of voting blur); the new test guards all three.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Content-Aware tools: Photoshop's behaviour, measured
Photoshop 25.4 was measured as a black box (synthetic and public-domain
images, results read back exactly) and the content-aware family follows it:
- Fill (inpaint): coarse to fine with the coarser level's matches scaled up
and voted with the finer level's own pixels, the coarsest level filled
from the hole's edge inwards, a shallower pyramid. The fill is pieces of
the image copied pixel for pixel with narrow seams, as Photoshop's;
before, it was visibly blurred. Pixels have roles (hole, context,
source, void), so excluded areas stay context instead of being filled.
- Colour adaptation: Image Melding's per-patch gain and bias, limited per
level, instead of a low-frequency membrane pull.
- Sampling window: Photoshop's square of side 4·√(max(w,50)·max(h,50))
centred on the selection, slid inside the canvas; Edit › Fill,
Rectangular, Auto, Delete and Fill and Spot Healing use it.
- Auto sampling area: what looks like the selection's surroundings (local
colour and contrast, one min cut), fitted to Photoshop's overlay.
- Patch Tool: a Content-Aware mode (Structure, Color, Sample All Layers).
- Content-Aware Move: Structure 3..7 alike, a band about a patch wide;
Color as a gain/bias limited to about 0.04 per step.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Remove tool: keep its own sampling margin (fill_cmds::sampling_margin is gone)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Add the Remove tool with non-local patch completion
Tool::Remove (J flyout) paints a hole that paint.remove fills from its
surroundings as a background job, in one undo step. A stroke around an
object also removes what it encloses; the selection limits the hole.
The fill (photocraft_algo::nonlocal) follows Newson, Almansa, Gousseau and
Pérez (IPOL 2017): texture features in the patch distance, onion-peel
initialisation, match fields upsampled between levels and a best-patch
final copy, plus gradient-domain hiding of edge and interior seams.
Sparse holes are filled in tiles. inpaint::complete (Content-Aware Fill,
Spot Healing, Content-Aware Move) is unchanged.
On 20 holes in public-domain photos (remove_quality) the fill keeps 0.89
of the texture it replaces, against 0.54 with the Wexler/PatchMatch
completion. 24 MP (remove_bench): 100 px ring 120 ms, 1000 px ring 2.5 s.
* Translate the Remove tool's label and tighten comments
Adds "Remove Tool" to every UI catalog, following each one's pattern for
tool names and its existing word for Remove. Corrects comments that no
longer matched the code: the benchmark's description, the tile and margin
minimums, and the reasons given for two parameters.
* Put the Remove tool first in the J group
The J flyout's slot now shows Remove until another J tool is picked, and
J from another group selects it; repeated J goes on to Spot Healing Brush,
Healing Brush, Patch, Content-Aware Move and Red Eye.
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
parse_brush, shared by Smudge, Blur, Sharpen, Dodge, Burn, Sponge, the
clone/healing tools and the History Brush, defaulted `spacing` to a fixed
25% instead of the session brush's spacing (Brush Settings > Brush Tip
Shape > Spacing). The UI never sends `spacing` for these tools, so the
Brush Settings value was ignored. Default to the session brush spacing,
as Photoshop does; an explicit `spacing` param still wins.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Proximity Match copied its translated patch within the buffer it was writing, so overlapping offsets
re-copied fresh output and left streaks. It now reads from the pre-heal pixels, with checked bounds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The live brush, pencil, retouch and dab previews fed pointer batches to the
stroke renderer unchecked, so a NaN or huge coordinate could hang the path
walker. Every push now runs the shared check_coords first and rejects the whole
batch without touching the preview; tests cover 8/16/32-bit and every live tool.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The painting and retouch tools now share one check_coords helper for the ±1e6 point bound,
and a regression test sends far-off points to all eleven retouch commands at every depth.
Fixes#976.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each dab re-applied the tone curve to pixels earlier dabs had already toned, so overlaps and
scrubbing compounded into blotchy results. A stroke now tones each pixel once from its original
colour, mixed in by the stroke's coverage, which builds up as a brush stroke's does, capped at
the set Exposure. Further strokes build on it, as in Photoshop. The originals are kept in dense
64 px cells: a 24 MP, size 500 stroke takes 1.7 s (6.0 s before). The live stroke matches the
commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Live strokes for the History Brush, Healing Brush and the dab tools
The Clone Stamp's live stroke now covers every brush-based retouching tool:
- History Brush and Healing Brush: LiveClone becomes LiveRetouch, which
paints from the clone source or the History Brush's history state
(history_source, shared with the commit). The Healing Brush shows its
cloned texture while drawing and blends it on release; it also gets the
source preview under the brush.
- Dodge, Burn, Sponge, Blur, Sharpen and Smudge: their settings are parsed
once into DabTool, whose per-dab effect both the command (dab_cmd) and the
new LiveDab use. LiveDab runs each new dab's effect on a working copy of
the target, in order, and mixes the changed area back at the brush
opacity with mix_back (split out of apply_dab_stroke), as the commit does.
Sample All Layers strokes keep the trail (their composite spans the whole
stroke).
Part of #213 (live previews for brush-based tools).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Format live retouch test
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Trail test: use Spot Healing, which still previews a trail
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* LiveDab::push: read new dabs with get(), no slice-index panic path
Rebased onto #1026 as rebased on main; LiveRetouch::begin already reads the
first stroke point with first() (resolved in the rebase) and the Healing
Brush's own source overlay is dropped with clone_overlay.rs in favour of
main's Clone Source preview (#1042).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Clone Stamp: show the stroke live while it is drawn
The Clone Stamp painted a translucent grey trail while dragging and only
showed the cloned pixels on release. It now renders live, like the Brush:
`retouch_cmds::LiveClone` feeds the same StrokeRenderer the commit's
coverage uses and, for the tiles each step changes, redraws them from the
pre-stroke pixels with the clone source sampled through the coverage so far
(the same `clone_sample` / `apply_coverage` as `paint.cloneStamp`), so the
preview is what the commit paints. `StrokeRenderer` gains
`take_dirty_rect` / `coverage_in`; `clone_source::resolve` resolves the
mapping without storing the aligned anchor (the commit still stores it).
The canvas's live stroke holds either engine stroke; the Clone Stamp's
params come from one place shared with the commit (`clone_params`).
Part of #213 (live previews for brush-based tools).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Painting strokes start on the press
The Brush, Pencil, Eraser and Clone Stamp started their stroke only once the
pointer moved past egui's click distance, so nothing showed on the press and
the first few pixels of movement arrived at once (a visible jump). A press
on the canvas with a live-stroking tool now starts the stroke immediately:
the first dab shows at once, the drag egui recognises later continues the
same stroke, and the release ends it, whether egui saw a drag, a click (no
second dab) or a long press that never moved. Other tools keep the click
distance.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Clone Stamp live stroke: show the first dab on the press
A single point makes no dab until the stroke finishes, so the live Clone
Stamp drew nothing on the press and the stamp only appeared on release.
LiveClone now also draws what finishing the stroke would add (the renderer's
tail_preview: a lone first dab, the smoothing catch-up) and redraws it on
every step, as the Brush's live stroke does: the stamp shows on the press.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Press-start strokes use the press event's modifiers
A Shift that arrives with the click (not yet in the frame's modifier state)
was dropped, so a Shift-click no longer connected to the previous stroke.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Box the live brush stroke in EngineStroke (clippy large_enum_variant)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Format live Clone Stamp code
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Live Clone Stamp: follow clone_sample's checked signature
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Live Clone Stamp: use main's source preview (#1042), no panic path on the first point
The rebase drops this PR's own source overlay (clone_overlay.rs) in favour of
the Clone Source panel preview that landed in #1042; remove its leftover module
line. LiveClone::begin reads the first stroke point with first() rather than
indexing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Add Pattern Stamp as a Clone Stamp flyout sibling
Introduce paint.patternStamp to paint the current pattern through a
brush stroke. Aligned strokes keep a document-space tile origin;
unaligned strokes restart at the first point; Impressionist jitters
each dab with a seeded RNG. Empty patterns and strokes larger than
4096² pixels return an error instead of allocating.
* Record Pattern Stamp on the tools scorecard
Allow the aligned stamp helper's argument count under clippy.
* Pattern Stamp: raise the stroke cap to 8192² so a 24 MP diagonal fits
The 4096² bounding-box cap refused a corner-to-corner stroke on a 6016x4000 canvas (~24M px).
Unit test covers that diagonal and a still-rejected 9000² box; the hostile-input test uses a
footprint above the new cap. Scorecard note labels Impressionist as an approximation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n(pl): translate the Pattern Stamp strings
The Polish catalog landed on main after this PR was written; the
translation-coverage tests need every language to carry new UI strings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tool::ALL holds 51 tools now that Red Eye and Pattern Stamp both landed
The rebase onto main (with #1084's Red Eye tool) leaves Tool::ALL one entry
longer; bump the array length and the count test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Flyout test: qualify egui::Vec2 (panels.rs no longer imports Vec2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The automatic sampling margin was three quarters of the selection's larger side, computed
as `ext * 3` in u32 (edit.fill) or i32 (edit.contentAwareFill) before the window cap, so a
loaded document with a far-flung selection overflowed and panicked. edit.contentAwareFill
also had no window cap at all and could try to allocate a canvas-sized window.
The margin is now one shared function computed in u64 and saturated to i32, and both
commands check the window against the same 400 M-pixel cap before reading or allocating.
Content-Aware Move uses the shared margin too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Show Clone Stamp source preview inside the brush cursor
* Drop the Clone Stamp preview screenshots and doc page
Binary screenshots and a standalone doc page aren't wanted in-tree (review):
remove docs/clone-stamp-preview.md, the two PNGs, their licence file and the
ATTRIBUTION.md row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Clone preview: pass the command name to clone_sample and propagate its far-source error
clone_sample gained a cmd parameter and returns Result since #1165.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
`Mapping::offset()` saturates its `as i32` cast and `Rect::translate` saturates
each edge, so a far `source` or `offset` (3e9, i32::MAX) produced a sample
window narrower than the stroke bounds. `clone_sample` then relabelled that
region as the full-size rect and `Region::px` indexed past the buffer:
`range start index 120 out of range for slice of length 92` at
`paint/src/retouch.rs:54`, which the dispatch guard reported as an internal
error. #716 fixed the rotated/scaled branch (`source_rect`); this is the pure
translation branch.
`clone_sample` is now fallible: when the translated window does not keep the
stroke rect's size, the command returns a bad-params error naming the offset
and the document and history are untouched. A source far outside the canvas
but inside the i32 range still clones transparency, as before.
Test: `clone_and_heal_with_a_far_source_are_plain_errors` (both tools, the
issue's inputs, a negative far source, the untouched-layer and no-history-step
assertions, and the in-range far source that must keep working).
Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)
New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.
Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.
Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.
generated with Claude Code
* Content-Aware Move Tool in the J group (#213)
Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).
generated with Claude Code
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add the Patch tool (#213)
paint.patch heals the selected area with texture from where the
selection is dragged (Source) or copies the selection onto the
dragged-to area (Destination), seamless-cloned like the Healing Brush.
The solve is clipped to the canvas, so patches at the edge stay opaque.
Tool::Patch joins the J group: outside the selection it draws a lasso
(Shift/Alt add/subtract), inside it drags the patch, the outline follows
the pointer and the offset is clamped to the canvas. Options bar:
Source / Destination. Strings translated in all six catalogs.
Tests at 8/16/32 bit, a UI gesture test, panic_hunt green. Scorecard
TOOL-213-3 is partial: the drag previews the outline, not the pixels.
retouch_bench: patch 250x250 22 ms, 1000x1000 362 ms (release).
generated with Claude Code
* Patch tool: live preview of the heal while dragging (#213)
The canvas shows the document healed at the pointer's offset
(patch_preview.rs, modelled on the Move tool's preview) and
recomposites only the healed area; release commits the exact
paint.patch and its damage rect refreshes the same area.
The preview (retouch_cmds::patch_preview) solves the colour fit on a
coarse grid (at most 128x128 cells) and interpolates it back: the
Poisson result is source + a harmonic correction, so the texture stays
exact and only the smooth correction is approximated (worst pixel 0.022
at 8x coarser in the test). Selection coverage is read tile-wise and
the interpolation runs per row in parallel.
retouch_bench (release, 6016x6016): preview 250x250 7 ms, 500x500
17 ms, 1000x1000 52 ms (exact patch: 21 / 90 / 354 ms). Scorecard
TOOL-213-3 done. Also applies rustfmt to the first commit's code.
generated with Claude Code
* Patch tool: cap patches at 16M pixels, drop product names from comments (#213)
The solve needs roughly 150 bytes per pixel, so the old 64M cap allowed
about 10 GB and risked an allocation abort instead of an error.
generated with Claude Code
* Regenerate docs/scorecard.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Tool::ALL holds both the Patch tool and the Mixer Brush (rebase onto #433)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>