The pixel-grid walker stepped between the pixels holding each segment's ends. Smoothing splits
a Shift-click line into pieces along it (so does the pointer on a Shift-drag), and snapping every
piece to its own pixels bent the line into a jagged staircase; rounding the half-pixel ties away
from zero also made the line depend on which end it started from. The walker now samples the
segment's own line at each column (or row) centre and resolves ties on the pixel grid, so the
pieces of a straight line paint exactly the pixels of the whole line, both ways.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Photoshop scales the Dual Brush diameter by the main size ratio, unrounded,
with a 5000 px cap and no lower floor: main 100 / dual 20 resized to 1 px
gives a 0.2 px dual tip, and back at 100 px it is 20 px again. PhotoCraft
already scaled the dual tip (#4856) but floored it at 0.5 px, so a trip to
1 px and back left a 50 px dual tip. Drop the floor (rendering bounds the
diameter itself), keep the cap, and leave a non-positive or NaN result alone.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
Smoother::push moved the brush part of the way to the pointer but gave
every point the pointer's newest pose (`..p`). On a fast stroke the
brush trails far behind, so when the pen lifted, the trailing brush took
the lift's near-zero pressure while still short of the end, and the
end catch-up drew a straight hairline from there to the pointer: a thick
stroke stepping down to a thin tail.
The brush's pressure, tilt, rotation (the short way round) and wheel now
move with its position, for exponential smoothing and the pulled string.
The end catch-up then ramps from the brush's pressure to the pen's last,
a taper instead of a step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Brush Settings: a response curve per dynamic control (#3294)
Every brush Dynamic (Size, Opacity, Flow, Roundness, Scatter, Count,
Depth, Foreground/Background...) now carries its own response curve for
its control (Fade, Pen Pressure, Pen Tilt, Stylus Wheel, Rotation),
applied after the global Preferences pen curve. It is part of the brush
JSON, so tools.setBrush sets it (e.g. shapeDynamics.size.curve), actions
record it and presets keep it; linear by default, so existing brushes
paint unchanged.
- paint: ResponseCurve, the global pen curve's monotone cubic moved down
from the engine as a fixed-size Copy type (no allocation per dab);
serialises as its point list. dynamics::dynamic_value applies it.
- engine: prefs::PressureCurve is now that type.
- ui-egui: each Control row gets a curve button (lit when the curve isn't
linear) that shows the same editor as Preferences > Tools.
- scorecard: TOOL-213-11 done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n: Response curve for hu, tr
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>
* Fix i18n after merge: Serbian Response curve, drop duplicate hu row; regenerate scorecard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md after merging main
* Regenerate docs/scorecard.md after merging main
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
A stroke clones its brush settings on every pointer event
(StrokeRenderer::tail_preview clones the dab generator and the context,
each holding a BrushSettings), and a sampled tip's GrayTile carried its
samples in a Vec<u16>. With a large sampled tip, common in imported ABR
brushes, every pointer event copied the tip: measured 1.1 ms per event
for a 1500² tip and 3.8 ms for 2500², at 120+ events a second.
GrayTile's samples are now an Arc<[u16]>: a clone copies a pointer.
Nothing mutates a tile's samples after building it, so only
construction changes (`.into()`), and equality, serde and every read
are unchanged. Measured the same way: under 0.01 ms per event at any
tip size. Textures and patterns stored as GrayTile share alike.
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>
* Brush presets: remember what the New Brush Preset dialog captured (#4490)
* Brushes: a New Brush Preset dialog for every way of making a preset (#4490)
* Brushes: the New Brush Preset strings in the Serbian catalog (#4490)
---------
Co-authored-by: Brandon Thomas <echelon@gmail.com>
* Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196)
The tool colours are stored as sRGB, so a colour picked as CMYK 100/0/0/0
(outside sRGB) came back as 100/28/0/17 when a CMYK document was filled.
tools.setColors now takes foregroundCmyk/backgroundCmyk, the picker sends
the entered inks, and Edit > Fill (and the fill keys) write them as they
are into a CMYK target. Fill also enters the document's CMYK profile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Brush and Pencil paint a CMYK foreground with the picked inks in a CMYK document (#4344)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.
Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.
Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.
Fixes#4283
Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
The 8-bit stroke dither (render.rs `dither8`) ran on every stroke, including
aliased ones. An aliased stroke paints each pixel by the same amount, so it
has no gradient to band; dithering only scattered it over neighbouring
levels. A 1 px Pencil line of 219/41/61 at 30% opacity over white came out
as four colours (244/191/197, 245/191/197, 244/190/196, ...), and a
foreground between 8-bit levels did the same at 100%. Photoshop's Pencil
paints one value, and pixel art relies on it: a tolerance-0 Magic Wand or
Paint Bucket and Indexed Color palettes trip on the grain.
Composite now skips the dither when the brush is aliased (Pencil, Eraser
Pencil and Block modes). Soft brushes keep it.
Test: an_aliased_stroke_at_partial_opacity_is_one_flat_colour (fails
before the fix at pixel 5). photocraft-paint 96/96, photocraft-engine
1529/1529, ui-egui pencil 13/13, clippy -D warnings and fmt clean.
Co-authored-by: cloudalister <ngnlif3@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:
- Height: paint reaches 12 x depth x coverage into the pattern and fills
what lies below it (dark valleys first; a brighter pattern takes paint
away, which is why the reporter had to flip Brightness's sign).
Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
Color Dodge/Burn scale the pattern toward their neutral with depth.
These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
1+c/100 before it. Texture::level is shared by the renderer and the
Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
values are now /100, matching the model and the Brush Settings field.
Built-in Subtract presets get Invert so they look as before.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
* ci: restore test and Clippy gates
* ci: keep browser wasm below size gate
* CI: restore plural catalog and MCP resource test gates
Adapted the catalog, parser, and shared-cache test fixes from PhotoCraft PR #3821 by @bommerts. The source-key test, bundled-catalog test, and MCP resource test pass locally.
* CI: expect saved native document name in CLI test
The native save path intentionally writes the output filename into the reopened bundle. Keep the pre-save Run assertion and assert r.pcraft after reopening.
* CI: compare orientation round trips without saved filenames
Native saves encode the output filename in document name. Check each expected name, then compare the remaining document fields and pixels.
Add the brush-outline half of #2516 on its own, so review and merge stay
small; the last-brush persistence half follows separately.
- paint: new `outline` module traces the tip shape as closed rings in
radius units -- a computed ellipse for round tips (rotated/flattened),
marching squares over the bitmap for sampled/stored tips, at the full
tip or the 50 % alpha contour. Traced rings stay finite and bounded for
hostile sizes; tested for the half-coverage contour, blank tips and
hostile input.
- ui-egui: new `brush_cursor` picks the traced outline when the tip has a
shape and falls back to the plain circle otherwise; `tool_cursor` gained
the `Outline` shape (bitmap rasterisation by segment splatting, painter
fallback on non-Windows).
- The Normal Brush Tip circle now goes through `half_coverage_radius`
(#2744) so outline and circle agree on the real 50 % contour.
Review fixes from #2516:
- Merged main; docs/scorecard.md is main's version and was verified with
`cargo xtask scorecard --check` (regeneration changes nothing).
- Dropped the scratch benchmark `crates/paint/examples/tmp_outline_bench.rs`
and the two `#[ignore = "temp bench"]` tests in tool_cursor/tests.rs.
Co-authored-by: JJ <jj@dot.com>
A dab never paints past radius + 0.5 px from its centre, at any hardness:
the half pixel is the anti-aliased edge of dab_coverage. A hard tip does
reach the ring; softer tips fade out inside it, which is the falloff
working as intended.
Brush phase 6c. A stroke composites in float, then each pixel is rounded to 8 bits, so a soft,
low-flow stroke's gentle gradient collapses into flat bands (the audit counted 35 levels against
119 at 16-bit). The pixels the stroke changes now get the same deterministic per-pixel offset of
under half a level on every channel before quantisation:
- fractional values round up or down in proportion, so bands become fine grain;
- a value already on an 8-bit level rounds back to itself, so exact colours never change, and
pixels the stroke doesn't touch are left alone;
- the offset depends only on the pixel's position, so the live preview, the commit and a replay
agree.
16-bit and float targets are unchanged.
The engine already had paint.colorReplacement, but the desktop UI had no
tool for it: no Tool entry, no toolbar slot, no options bar and no stroke
routing, so only agents and the CLI could use it.
Tool::ColorReplacement joins the Brush (B) flyout after the Mixer Brush,
so B / Shift+B cycle to it and ui.set / ui.inspect name it. Its options bar
has Mode, Sampling (Continuous / Once / Background Swatch), Limits,
Tolerance and Anti-alias, all mapped to the command's params, with the
foreground as the replacement colour. Strokes are drawn live through
LiveDab, which can now preview the stroke's finishing dab, so the preview
at the last move matches the commit. The command refuses a mask, channel
or Quick Mask target instead of silently recolouring the layer. New icon
derived from Lucide's brush; label translated in all 16 catalogs.
Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Small aliased tips (dabs at most a pixel apart) now walk each segment
pixel by pixel on the line between the pixels holding its ends, one dab
per pixel along the major axis at the pixel's dab centre. Distance
spacing put two dabs in a row wherever the line crossed a pixel corner,
which left L-shaped doubled pixels at every step of a Pencil line.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Wet Edges multiplied every dab by its own rim profile and max-accumulated the
dabs, so the rims of neighbouring dabs showed through the stroke as a chain of
rings. Dabs now accumulate normally and record how deep they reach each pixel
(1 - rn); the stroke keeps the deepest value per pixel and applies the
wet-edge transfer to its accumulated coverage when composited (live preview,
tail preview, commit and retouch coverage alike). The interior of the stroke is
evenly light and only the stroke's own rim is darker.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Pencil/Eraser: round Pencil cursor and Eraser Pencil mode (#2662)
The Pencil cursor always outlined the square grid_square footprint, but the
aliased dab is a disc from 4 px on. It now draws a circle on the same
pixel-grid square when the tip is round, and the square for 1-3 px tips
whose dab really fills it.
The Eraser options bar gains Photoshop's Mode: Brush / Pencil (replacing
the blend-mode dropdown the Eraser never used). Pencil mode commits
paint.pencil with erase: true (hard, aliased, full flow), live and on
release, and shows the Pencil's cursor. Block mode is left for later.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Eraser: translate the Pencil mode label in every catalog
The Eraser's Mode dropdown added tl!("Pencil"), which had no entry in the
cs, el, ko, zh-hans and zh-hant catalogs, so every_tl_literal_is_translated
failed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Eraser: Block mode (#2770)
Photoshop's Eraser Mode is Brush / Pencil / Block; #2694 added Pencil.
Block erases a hard, aliased square of 16 screen pixels, so 16 / zoom
document pixels, whatever the brush's size, hardness, opacity or flow.
- paint: BrushSettings.square (with aliased) rasterises the dab's whole
pixel-grid square (serde: omitted when false, so saved brushes are
unchanged).
- engine: paint.pencil "block": true builds that brush from the stroke's
"zoom" (brush_cmds::block_size: round(16 / zoom), 1..5000 px); a zoom
that is not a positive number is an Err. Params doc updated.
- ui: Mode dropdown gains Block; the Block-mode Eraser commits
paint.pencil with "block": true, shows the block's square cursor and
greys out Size/preset, Hardness, Opacity and Flow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Eraser: translate the Block mode label
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>
Since #2167 the soft tip falls off as (1 - t^2)^4, whose 50% point is at
t = sqrt(1 - 0.5^(1/4)) ~ 0.399, but the Normal Brush Tip cursor still used
the old smoothstep midpoint r * (0.5 + 0.5 * hardness). Soft brushes got a
ring up to 25% too large (size 21, hardness 0: 5.25 px instead of 4.19 px).
photocraft_paint::half_coverage_radius inverts tip_falloff, and the cursor
uses it, so the two can't drift apart again.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Brush phase 1 (owner-approved option A of the brush audit):
- Soft tips fall off as (1 - t²)⁴ from the hard core to the edge, a Gaussian-like curve that
tracks exp(-4.6 t²) but is exact on every platform and reaches 0 at the edge with a flat tangent.
The old smoothstep kept so much of the tip near full strength that overlapping dabs (Flow builds
up, as in Photoshop) turned a soft stroke's edge to half the tip's width. It now keeps about two
thirds of it, at the default 10 % spacing, with no ripple along the stroke.
- Computed tips under 3 px radius are rasterised by covered area (a 4×4 grid per pixel, each
sample anti-aliased over its quarter pixel) instead of at pixel centres: a 1 px dab's total
coverage no longer swings 1.00–1.17 with its sub-pixel position, so thin lines don't bead.
- The default spacing stays 10 %. Photoshop's documented 25 % beads soft strokes with either
curve; it waits for Photoshop-authored reference strokes.
* Brushes: keep the folder hierarchy of imported .abr files (#1851)
Photoshop CC brush files keep their Brushes panel folders in a `phry`
(preset hierarchy) section; the importer skipped it, so every preset of an
.abr landed in one flat group named after the file.
- photocraft-psd: `abr::parse` reads `phry` (a token stream of `Grup`
{Nm, zuid}, `preset` and `groupEnd` objects) into `AbrFile::folders`, one
folder path per preset. A damaged hierarchy never fails the parse: a stray
groupEnd, more or fewer preset tokens than presets, folders nested deeper
than MAX_FOLDER_DEPTH (32) or an unreadable section become warnings and the
affected presets land at the top level. `write_v6_folders` writes `phry`
back (`write_v6` is unchanged).
- photocraft-paint: `BrushPreset::folder`, the nested folder path inside its
group (serde default, so older JSON loads unchanged).
- photocraft-io: the file's folders nest inside the group named after the
file, as Photoshop's Import Brushes does.
- Preset store: each stored preset records its `folder`; group files written
before this change load with every preset directly in its group (no
rewrite). Paths from disk and from imported .pcpresets files are bounded.
- Commands: brush.presets.move, moveGroup, renameGroup and deleteGroup take
an optional `folder` path to move a preset into a folder, or to move a
folder among its siblings, rename or delete it. importAbr reports the
number of folders kept.
- Brushes panel and Brush Preset picker: nested folders are drawn indented
under their group (list and grid), open and close like groups, take
dropped presets, reorder among their siblings by drag and drop, and have
Rename/Delete in their context menu.
Checked against the 4-folder ABR Photoshop 25.1 exports of its default
brushes (8/6/6/8 presets): all 28 land in their folders, and a write/parse
round trip keeps them.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Brushes: bound phry folder allocation; reword the move folder param
Review on #2001:
- read_phry now runs after every section is read, so it builds folder
paths only for the presets in desc (a phry may precede desc), and the
paths it holds are capped at 16 MB; presets past the cap import at the
top level with a warning.
- brush.presets.move documents `folder` as folder names, so the
automation filesystem-path classifier no longer flags it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brush phase 3a (owner-approved brush overhaul):
- Every stroke point now carries a time (`timeMs`). egui's pointer events have none, so a frame's
samples are spread evenly between the previous point and the frame time. Speed spacing, airbrush
Build-up and Stroke Catch-Up worked from scripts but never from the canvas, because every point
had time 0. The live stroke and the commit get the same times.
- A stroke started on the press takes its moves from the press on. The moves before egui
recognised a drag (its 6 pt click distance) were dropped, which gave every stroke a straight
start and lost the pen's pressure ramp-in.
- Smoothing pulls by stroke time (16 ms steps, fractional below that), not once per input event,
so a 1000 Hz mouse and a 60 Hz pen feel the same. Without Stroke Catch-Up a paused pointer stops
the brush, as in Photoshop.
- A pointer held still keeps the stroke's clock running only while the stroke changes with time
(airbrush Build-up, or smoothing still catching up): it repeats the held point every 16 ms and
asks for repaints, then goes idle.
Opening the preset store decoded every imported tip at full resolution, and
each tip then lived twice: in the session's library and in the store's
snapshot of the last sync. A generated 600-brush library (16-bit tips,
800-2000 px, 2.27 GB decoded) cost about 4.5 GB after import and again after
every restart.
Library presets now hold their bitmaps as StoredTile references with a small
saved preview (at most 96 px): photocraft-paint gains TipShape::Stored and
Pattern::Stored (rendered with the full bitmap when loaded, else the preview),
and the store writes each tip's size and preview with its group. Opening the
store reads previews only; once a group is written its presets drop their full
bitmaps. A tip is decoded when its brush is used (picking a preset, a stroke
naming one, exporting) into a 256 MB least-recently-used cache. A loaded stored
tip serialises as `sampled`, so tool presets, actions and brush.get keep the
same JSON. Stores written before this change have no previews: their tips are
decoded once at the next start, eight at a time, and the groups rewritten. A
missing or corrupt tip file is now reported when the brush is used.
Measured with crates/engine/examples/abr_memory.rs on the 600-brush library
from crates/io/examples/gen_abr_library.rs (Windows, release):
after import 4565 -> 38 MB working set, after restart 4551 -> 20 MB,
after using 5 brushes 4560 -> 37 MB; store open 1.47 s -> 0.05 s.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Photoshop's Vertical, Horizontal, Dual Axis and Diagonal paint symmetry presets beside the
path-based symmetry: axes start at the document centre, Transform Symmetry moves and rotates
them (Shift snaps to 15°, Enter applies, Esc cancels), and all copies merge into one coverage
pass so overlaps don't build opacity. New paint.setSymmetry command and preset icons. Fixes#1648.
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>
* Brush tests: seams, hard edge, zero scatter and ABR tip colour (#213)
Adds the four TOOL-213-13 regressions: minimum coverage inside a stroke
(spacing 0.1/0.25/0.5), a hardness-1 rim measured against a soft falloff,
dabs staying on the path at zero scatter, and an imported ABR tip painting
the brush colour instead of black. Scorecard row flipped to done.
* Contributors: add Reynold Hu to people.toml
Real name credited, display name synced from the public GitHub profile at the
next contributors.py refresh (every field is opt-in).
* Regenerate docs/scorecard.md after rebase onto main
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>
With Spacing unchecked, dabs are spaced by pointer speed, and a segment
without timestamps got a single dab at its end point. The canvas sends no
timestamps, so a Shift-click straight line with such a brush (the Eraser
with 10% smoothing gives start, 90% and end points) left three separate
dabs instead of a line. Untimed segments now fall back to distance spacing.
Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tools.setBrush and stroke brush patches accepted any Scatter amount. The paint path
multiplied it by the radius in f32, so 1e38 overflowed the dab centre to infinity, and
dab_rect's unchecked i32 arithmetic then panicked (wrapped to an empty rect in release).
Brush validation now rejects primary and Dual Brush Scatter outside 0..=MAX_SCATTER (10,
the slider's 1000 %) with a BadParams naming the value, before the session brush changes
(also between setBrush's two merges, so infinity is named rather than reported as null).
bounded_for_render caps both amounts for direct paint API users (NaN scatters nothing),
and the dab rectangle, now one helper shared with the retouch tools, saturates.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Add blend modes to Gradient tool
* Establish context menu parity contract and first workflows
* Align Select menu actions and style selection context rows
* Add Pen canvas Make Selection context action
* Complete Pen path context menu and symmetry actions
* Select menu: no extra top-level Feather (keep Select > Modify > Feather only, for 1:1 menu parity)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md and docs/parity.md
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Regenerate docs/scorecard.md and docs/parity.md
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>
* paint: Tilt Scale, Brush Projection, speed spacing, section locks, mixer settings in the brush
Shape Dynamics gains Tilt Scale (pen tilt squashes the tip height when the size
control is Pen Tilt) and Brush Projection (tilt foreshortens the tip along the
tilt direction, barrel rotation turns it). Spacing gets Photoshop's checkbox:
off spaces dabs by stroke time, so speed sets the spacing. Section locks keep a
section when another preset is picked. Transfer gains Wetness/Mix jitter, and
the brush carries the Mixer Brush options (Wet, Load, Mix, Flow, Sample All
Layers).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine, io: mixer settings from the brush, preset organising commands, one journal entry per setBrush gesture, ABR mapping of the new fields
- paint.mixerBrush defaults to the brush's Mixer settings (Wet/Load/Mix/Flow/Sample All Layers).
- brush.presets.rename/move/moveGroup/renameGroup/deleteGroup (drag and drop and context menus in
the Brushes panel); the preset store index keeps the full panel order, built-ins included.
- tools.setBrush calls sharing a coalesce key journal as one entry (the patch from the brush before
the gesture), so a slider drag is one command.
- .abr: Tilt Scale, Brush Projection, the Spacing checkbox (Intr), Wetness/Mix jitter and mixer
tool options map instead of warning.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: Tilt Scale, Brush Projection, Spacing checkbox, section locks, mixer controls; Brushes grid/list, drag and drop, rename/delete; options bar through tools.setBrush
- Brush Settings: Tilt Scale (enabled with Size on Pen Tilt) and Brush Projection in Shape
Dynamics, the Spacing checkbox, Wetness/Mix Jitter plus the Mixer Brush options in Transfer,
and a lock beside every section.
- Brushes tab (new brushes_tab.rs): list/grid toggle, drag presets within and between groups,
drag group headers to reorder, context menus to rename or delete presets and groups; all via
brush.presets.* commands.
- Options bar, the canvas brush picker and the [ ] keys edit the brush through tools.setBrush;
edits of one gesture share a coalesce key, so a drag is one journal entry.
- ui.set accepts brushesView (list|grid).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Right-click opens the Brush Preset picker (or erases), eraser-on-Background tests, README comma
- #75: the right mouse button never paints with the tool. With a painting tool it opens the
Brush Preset picker at the pointer (Photoshop). New preference Preferences › Tools ›
Right-click with painting tools (tools.rightClickWithPaintingTools: brushPicker | erase,
serde default brushPicker): with erase, a right drag with the Brush erases with the current
brush (one paint.stroke, one undo step, pen pressure/tilt as usual). ui.pointer accepts
"button": "secondary". New module ui-egui/src/paint_mouse.rs; picker body shared with the
options-bar chip.
- #76: the engine already paints the background colour when erasing a transparency-locked
layer (the Background); added tests at 8/16/32-bit in RGB/CMYK/Gray, with a selection, on a
normal layer (erases to transparency), on a transparency-locked layer, and through the UI.
- paint.stroke/pencil refuse coordinates beyond ±1e6 (an f64::MAX point ran out of memory).
- #73: test that the canvas hands the live stroke over to the committed document with no
frame lacking the stroke, refreshing only the stroke's rect.
- #69: README comma.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Brush smoothing follows the options bar; the live stroke draws the catch-up tail (#73)
- The options-bar Smoothing % (pro and classic bars, Brush and Eraser) edits the session
brush's smoothing, which the live stroke and the committed paint.stroke both use; the
canvas no longer forces 0.3. The session brush starts at Photoshop's 10 %.
- With smoothing the brush lags the pointer and catches up when the stroke ends. LiveStroke
now draws that catch-up tail on every step (StrokeRenderer::tail_preview renders only the
tiles the tail touches; the previous step's tail is restored first), so the preview at the
last move equals the commit and nothing pops in on release. At 0 % there is no lag.
- Path › Stroke Path ignores smoothing (it strokes the exact geometry).
- Tests: paint tail_preview = finished stroke; canvas: 0 % preview = commit, 50 % preview =
commit with the end reaching the pointer, smoothing changes the stroke, commit equals
paint.stroke with that smoothing, no stale tails after sharp turns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Smoothing is a per-tool option that brush presets keep
- Picking a brush preset (tools.setBrush / paint.* "preset", .abr import with select) keeps the
current smoothing amount and mode instead of the preset's (BrushSettings::picked_over),
as Photoshop's options bar does. An explicit smoothing alongside the preset still applies.
- The Brush and the Eraser each remember their smoothing (UiState smoothing_tool /
tool_smoothing, synced on tool switch before the options bar, the canvas and each stroke);
a tool seen for the first time starts at 10 %.
- Tests: engine presets keep 10 % / a user value with pulled-string mode; UI per-tool
smoothing across Brush/Eraser/other tools and on the first stroke after a switch.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* psd: .abr brush (v1/v2/v6+) and .grd gradient (v5) parsers and writers
Clean-room from public format descriptions: legacy computed/sampled brushes,
8BIM-sectioned samp/patt/desc files, 8/16-bit raw and PackBits tips. Bounded
(MAX_EDGE, MAX_TOTAL_BYTES), damaged brushes are skipped with warnings, hostile
input returns Err. Adds an abr fuzz target.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* paint: 36 grouped built-in brush presets and new procedural tips
General / Dry Media / Wet Media / Special Effects groups (pressure size/opacity
variants, airbrush, pencils, charcoal, pastel, crayon, rake, watercolour, ink,
oil, foliage, grass, stipple, sparkle, halftone…). Leaf, grass, charcoal,
sponge, star and rake tips are generated in code. BrushPreset gains a group.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* io: map .abr brushes onto BrushSettings
Tip shape, shape dynamics, scattering, texture (embedded patt patterns),
dual brush, colour dynamics, transfer, pose, noise, wet edges, build-up and
captured tool options; unmapped settings are reported as warnings. Includes
bench_abr (100-brush import, 5000-dab stroke).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* engine: brush.presets.importAbr and gradient.presets.importGrd
Path or base64 data, group per file (re-import replaces), unique names next to
the built-ins; graceful-failure tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* ui: real stylus pressure/tilt, grouped Brushes panel, Import Brushes
Pen force from egui Touch events (Windows WM_POINTER via winit), Pointer Events
pressure/tilt/twist on the web, tilt via ui.pointer; mouse stays 1.0. .abr/.grd
open from File > Open, drag-and-drop, Brushes panel and Preset Manager. Preset
previews keyed on a cheap signature instead of the full brush JSON.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser and Background Eraser engine commands
paint.magicEraser erases the wand region (tolerance, anti-alias,
contiguous, sample all layers, opacity) to transparency, tile by tile in
parallel; paint.backgroundEraser erases colours similar to the one under
the brush hotspot (continuous / once / background swatch sampling,
discontiguous / contiguous / find-edges limits, tolerance, protect
foreground colour). Both compare and erase in the layer's own channels at
any depth, respect the selection, turn the Background into Layer 0 like
Photoshop, and are one undo step. The Magic Eraser paints the background
colour on layers with locked transparency.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser and Background Eraser tools in the Eraser group
Shift+E / E cycles Eraser, Background Eraser and Magic Eraser; the
toolbar flyout lists all three. Options bars: Magic Eraser tolerance,
anti-alias, contiguous, sample all layers and opacity; Background Eraser
sampling toggles, limits, tolerance and protect foreground colour. The
Background Eraser cursor always shows its sampling hotspot. Icons are
derived from Lucide's eraser (ISC), recorded in ATTRIBUTION.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Image Rotation, Canvas Size, Crop, Trim and Image Size move vector geometry
Canvas-wide operations remapped only pixels: type, shape and smart-object
transforms, vector masks, saved/work paths, guides, slices, notes and
Count/Ruler marks stayed put, so they jumped back on the next re-render.
New engine module canvas_geom applies the canvas map to all non-pixel
geometry. 90°/180°/flip turns remap every pixel surface exactly (incl.
type/shape/smart/fill caches and smart filter masks) and move geometry
through the matching affine; guides swap orientation under 90° turns;
rect/ellipse/line live shapes stay live; gradient fill angles follow.
Canvas Size/Crop/Trim translate geometry (shapes re-render for the new
canvas), Image Size scales it and re-renders vectors, Arbitrary rotation
also moves unlinked vector masks and document marks. Layer effect angles
stay (Global Light), as in Photoshop.
Tests compare the CPU composite of a rotated document (type, shapes,
vector mask, smart object) against the rotated flattened original, also
after re-rendering everything from geometry, at 8/16/32-bit RGB, CMYK
and Gray.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Magic Eraser: edit encoded tile bytes in parallel (24 MP: 1260 -> 375 ms)
Tiles are copied out as encoded bytes and only the touched samples are
decoded, so the parallel workers do all the work and the serial write-back
is a memcpy. The ignored bench prints a phase breakdown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>