* fix(compose): a wide stroke effect keeps a round outer edge (#3646)
A stroke's outside distance came from the 5x5 chamfer, whose level sets are polygons: at a
245 px outside stroke the boundary wanders ~10 px into visible flat facets. Use the exact
Euclidean distance (edt_nearest, already used for glows and chiselled bevels) for the outer
edge; the inside edge and spread/choke keep the chamfer, whose sub-pixel edge seeding is
fitted to Photoshop at small offsets. A point source dilated by 60 px now stays within 0.5 px
of a circle (was 2.5 px).
Tests: a_wide_outside_stroke_is_round_not_a_polygon; stroke_edges_use_the_exact_euclidean_distance
(a 3 px outside stroke's (1,3) corner coverage is 0.838, the exact distance; it was 0.764).
Full photocraft-compose suite: 206 pass; fmt and clippy clean.
* fix(gpu): a wide stroke keeps its exact outer edge on the GPU (#3646)
1080008a fixed the CPU compositor's stroke outer edge to use the exact
Euclidean distance instead of the 5x5 chamfer, whose level sets are
polygons and facet a wide stroke. The GPU compositor builds the outer
stroke field from FieldKind::StrokeOutside, which still maps to the
chamfer, and the canvas is drawn on the GPU, so the fix did not reach the
app.
Add FieldKind::StrokeOutsideExact (dist_outside_by Euclidean) used only by
the stroke. The spread of drop shadows and softer outer glows keeps the
chamfer on purpose (fitted to Photoshop, #224), so StrokeOutside keeps its
meaning. StrokeOutsideVector is exact too, matching the CPU vector outline
path.
Tests: crates/gpu/src/fx.rs a_raster_stroke_uses_the_exact_outer_field
(stroke uses the exact field, shadow spread keeps the chamfer), and the
banded/whole field test covers the new kind.
* fix(stroke): a stroke keeps the chamfer narrow and the exact edge wide (#3646)
The exact Euclidean outer edge (#3646) fixed a wide stroke's faceting but
regressed Photoshop's own composites against the PSD flatten oracle: the
fitted 5 × 5 chamfer matches Photoshop at small offsets, and only at a large
outside width do its octagon facets show. Pick the metric by the stroke's
outside width instead: below `STROKE_CHAMFER_MAX` (20 px) keep the chamfer and
its sub-pixel edge seeding, at or above it use the exact Euclidean transform.
The measured chamfer deviation from a point source is 0.027 · r px — 0.48 px at
r = 18, the widest outside stroke any Photoshop oracle matches with the chamfer,
and 1.6 px at r = 60, where the boundary visibly facets. Half a pixel is reached
at r ≈ 19, so the switch sits at 20. Inside stroke edges keep the chamfer.
The CPU and GPU compositors pick the same field by the same width
(`stroke_outside_exact`); `FieldKind` gains `StrokeOutsideVectorChamfer` for a
narrow shape-layer outline, while the shadow / glow spread keeps its chamfer.
io corpus: 140 → 148 (floor 146) and psd-tools 231 → 237 (floor 236), both back
to main's counts with no file newly differing; a 60 px outside stroke stays
round (`a_wide_outside_stroke_is_round_not_a_polygon`).
A renderer's data window is its crop of the frame; the composition lives
on the whole display window (our MY27 renders: data 1782x1267 at
(1109,1100) inside a 4000x3000 stage). decode_deep now validates the
display window against the limits and reports it as stage + origin on
DeepImage; flatten pads the composed region to the stage at its offset
(transparent elsewhere) and tags the image with a DisplayWindowStage
warning. Files without a usable display window open as before.
Includes a cropped-deep generator and a stage/offset oracle test.
* fix(gpu): contain errors in unwatched compositor work
* test(gpu): gate recovery fixtures on format capabilities
---------
Co-authored-by: haranishi <223786623+haranishi@users.noreply.github.com>
A flat TIFF dropped EXIF on export ("EXIF not supported") and ignored it
on import. TIFF is EXIF's own container: IFD0 points to the Exif (34665)
and GPS (34853) directories and holds the descriptive tags.
- Export: the EXIF block's Exif and GPS directories are written as
directories outside the page chain, IFD0 points to them, and IFD0's
descriptive tags (ImageDescription, Make, Model, Software, DateTime,
Artist, Copyright) are added unless the text metadata already wrote
them. Values are copied as bytes, reordered to the file's byte order,
so unknown tags survive.
- Import: a classic TIFF with an Exif or GPS directory gets an EXIF
block built from them and IFD0's descriptive tags.
- TIFF's format caps now include EXIF. Layered TIFFs keep EXIF as
resource 1058 as before (read back unchanged); the layered importer
already preferred the TIFF's own EXIF when there is one.
exiftool and Photoshop 2025 read the Exif and GPS tags of such a TIFF.
Fixes#4875
Drag out of the rulers' upper-left corner to move the zero point: cross hairs mark where it
lands, Shift snaps it to the ruler ticks and View > Snap To targets apply otherwise; dropping it
back on the corner cancels. Double-clicking the corner puts it back at the image's top-left.
The rulers then read 0 at the zero point (negative left of and above it) and the Info panel's
X/Y measure from it.
The zero point is a document field (`ruler_origin`, document pixels), saved in .pcraft with
`#[serde(default)]` so older files open at the top-left. It is set by two undoable engine
commands, `view.setRulerOrigin {x, y}` and `view.resetRulerOrigin`, so agents can drive it.
It is not written to PSD: the public PSD spec documents no resource for it.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The compressed RW2 layouts of earlier Lumix bodies (e.g. the DMC-GX7)
decode at full sensor resolution instead of falling back to the camera's
embedded JPEG preview. The layout was established by observation of
sample files (fixed 16 bytes per 14 pixels, predictor groups with
per-parity predictors, page-permuted downward bit order); the black
level of these files reads 15 counts low.
Adds round-trip tests through a mirrored synthetic encoder, cursor and
predictor-group unit vectors, and updates the support matrix.
Co-authored-by: Gotya-OFFCIAL <339410022+Gotya-OFFCIAL@users.noreply.github.com>
Saving a 36 MP document spent most of its time packing the merged image's
RLE rows one after another on one thread (packbits over ~144 MB), and
importing it decoded each layer's channels sequentially. The merged-image
row packer now runs in parallel chunks that concatenate in order (rows are
independent; errors surface like the sequential path's), the layer gather
runs in row bands on all cores before the per-channel RLE, the layer's
channels decode side by side, and the full-surface clone per layer is
gone.
Measured on a 6000×4000 8-bit document with a photo, a paint layer and a
Levels adjustment: save 2.86 s -> 2.55-2.64 s, open's import stage
1.12 s -> 1.07 s (psd_to_document 745 -> 564 ms). The remaining save time
is dominated by the merged composite render itself (~0.7 s).
* Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur
`edit.autoBlendLayers` with `method: stack` and `depthMap: true` adds a hidden gray Depth layer, the layer each pixel is sharpest in (black = the bottom layer, white = the top), from `photocraft_algo::dff`: the ring difference filter as focus measure, guided-filter aggregation with the merged picture as guide, a streaming peak search with Gaussian interpolation and a confidence, edge-aware weighted least squares solved by multigrid-preconditioned conjugate gradients, and guided upsampling. Once on a layer mask it drives Lens Blur's depth map.
Closes#2067.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Depth from focus: a guide or plane of the wrong length is the empty map
Review follow-up on #2073: `depth_from_focus` is public and indexed
`guide[i]` without checking that `guide` and every `luma` plane are
`w × h` long. It now returns the empty map (depth 0, confidence 0) for a
mismatch, before anything is read, and the test covers a short guide, a
short plane and a size that matches neither.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* i18n(de): translate the labels that were still English
A walk through the German UI with a lookup recorder (every menu item, every
tool's options bar, every dialog it opens) found 344 texts that fell through
to English. They were invisible to the existing i18n test, which only scans
tl!("literal") calls: the labels of the schema-driven filter dialogs are built
from the engine's parameter names ("centerX" -> "Center X"), and status and
system-info templates are looked up at run time.
- de.tsv: 344 new entries. Filter, Render (Flame, Picture Frame, Tree, Lighting
Effects), Blur Gallery, Lens Correction, Color Lookup, Shadows/Highlights,
Apply Image / Calculations, Layer Style blending options, Find and Replace,
Document Info, the keyboard shortcut editor's commands, system info and the
tool names that were missing (Color Sampler, Vertical Type, Patch).
Written from the plain meaning of each label, in the vocabulary the catalog
already uses (see its header: no proprietary localisation resource consulted).
- "Light" was translated as "Light": the appearance mode in Preferences and the
toolbar tooltip read "Light" next to "Dunkel". It is "Hell" now (Camera Raw's
Light section and the font weight have their own contexts).
- Export As: the dialog title was built with format!() and could not be
translated; it goes through the catalog now.
- Names such as "iPhone 14 Pro Max" (the artboard dialog's preset choices) were
split like camelCase keys and read "I Phone 14 Pro Max"; a name with a
space is shown as it is. Test: display_names_are_not_split_like_camel_case_keys.
Left English on purpose: product and file-format names, units, document and
layer names the engine creates ("Untitled", "Background": other code compares
the latter), brush and swatch preset names (data), and words that are the same
in German (Filter, Info, Zoom, Median...).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* i18n(de): drop the process note and give the new sections English headings
The first heading described how the entries were found (and when), which is not
catalog content, and the other headings were German while the rest of the file
uses short English section comments. Only comment lines change.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
* i18n(de): US spelling in a section heading
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
* Blur Gallery: Field Blur pins on the image (#3373)
Field Blur opened a parameter dialog with one centre, though the engine
already blends any number of pins. It now works as Photoshop's Blur
Gallery workspace: the image under the dialog takes the pointer. A
click adds a pin and selects it, a drag moves a pin, and a drag around
the selected pin's ring turns its blur up or down (one turn is the
0-500 px range; the arc shows the amount). Delete or Backspace removes
the selected pin. The dialog shows the selected pin's Blur and Preview,
and opens at the right edge, beside the image, so the pins stay in view.
The pins live in the dialog's fields (`pins`, `__selected`), so the
existing filter preview follows every change, OK applies
filter.blurGallery.fieldBlur with them in one step, and automation
drives it with ui.dialog.set. Pins are fractions of the selection's
bounds when there is one, else the canvas, as the engine reads them.
Proxy previews now scale Blur Gallery pins' pixel blur like other pixel
params.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* i18n(vi): translate the Field Blur pin strings
main added the Vietnamese catalog after this branch was cut, so
every_tl_literal_is_translated failed on the merge for "No pin
selected" and the pin hint. Terms follow the catalog's own: ghim
(pin), Bấm (click), Delete kept as the key name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* Blur Gallery: Alt+Cancel Reset starts again from one pin
main's Alt+Cancel → Reset (#3871) restores a filter dialog's schema
defaults, and the schema has none for Field Blur's pins, so Reset
left the workspace with no pins. blur_pins::reset now puts back the
opening pin (in the middle, selected), and reset_fields runs it for
the Blur Gallery. init is reset plus capturing the bounds, which
Reset keeps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(hu, tr): translate the Field Blur pin strings
main added complete Hungarian and Turkish catalogs after this branch
was cut. Terms follow each catalog: tű / Mezőelmosás (informal
Hungarian), pim / Alan Bulanıklığı (formal Turkish).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018CiFWEfKibLnwb6oSy6ZBE
* i18n(sr): translate the Field Blur pin strings
Main gained a complete Serbian catalogue (#4436); every_tl_literal_is_translated needs these strings there too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Profile prompts on open: one file at a time, before it opens, like Photoshop
With Ask When Opening on, files opened together put up every Embedded
Profile Mismatch prompt at once, each answer went to the active document
rather than its own file, and no prompt named its file. Checked in
Photoshop 27.11: one prompt at a time, naming the file, before that file
opens; OK opens it and asks about the next; Cancel opens neither it nor the
files still to come.
A file that asks is now held back until answered: the engine's background
open parks the decoded document (ColorState::open_question,
Session::take_held_open) and the shell queues it (open_prompt). The answer
is applied to that document (ColorState::resolve_open) before it opens.
Files that finish reading meanwhile wait their turn. A developed raw has no
embedded profile, so it doesn't ask.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Profile prompts on open: the prompt's text is localized in every catalog
The two messages are templates now, one line each so the catalog check covers
them: the sentence naming the file and the mode, "Embedded: {embedded}" and
"Working: {working}". The mode reads as the catalogs name it (French "RVB"),
and the file's name goes in last, so braces in it are never taken for a
placeholder. Rows added to all 17 catalogs; main's two "colour profile"
templates (Czech and Italian only) are removed, nothing uses them.
The Camera Raw control test from main expected a mismatch prompt when a raw
opens. With this change a developed raw is not asked about (it has no embedded
profile), so the test now checks that no prompt stands before Camera Raw.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Profile prompts: rows for the hu, sr and tr catalogs; tests hold for any read order
The prompt's four templates join the Hungarian, Serbian and Turkish catalogs
that landed on main.
Files read in the background ask in the order their reads finish, which is the
order given unless a later read overtakes an earlier one. Three tests assumed
the order given and one failed once under a loaded test run; they now follow
whichever file the prompt names (and still require the order given for files
read on the spot).
New test: a file held for its prompt still reports type in a font that isn't
installed, once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Select Subject ran every graph cut (the GrabCut iterations and the
full-resolution band re-cut in `refine_band`, tile by tile on rayon)
through the non-cancellable `grid_cut`, so the Remove Background job only
saw Cancel/Esc before and after the whole "Finding subject" step.
Add `select_subject_with`, `grabcut_with` and `finish_region_with`, which
take an `Interrupt` and return `Err(Cancelled)`: each band tile checks it
before reading pixels and its cut runs through `grid_cut_cancellable`, so
in-flight tiles stop at the max-flow's next poll and unstarted ones never
start. Remove Background runs the step under `JobCtx::stage`. The old
functions keep their signatures and results (they pass `Interrupt::NONE`),
so `select.subject`, Object Selection and the layer menu are unchanged.
Co-authored-by: lloyd <lloyd@stackworx.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(psd): prioritize 16-bit and 32-bit global layer blocks over compatibility section (#4928)
In 16-bit and 32-bit PSD files saved by Photoshop, the Layer Info section
either contains 0 layers or 8-bit compatibility layers, while the actual
16-bit/32-bit layers are stored in the global Lr16/Lr32 tagged blocks.
Previously, candidate_seen was initialized to layer_info.is_some(), which
caused PsdFile::from_bytes to skip the Lr16/Lr32 block whenever the section
was non-empty. This left the document with empty layer lists or 8-bit records
that failed channel decoding at depth 16/32, causing all layers to be empty
and invisible.
Prioritize the depth-specific tagged block (Lr16 for 16-bit, Lr32 for 32-bit)
over the compatibility section.
* test(psd): improve 16-bit multi-layer compatibility regression tests
* PSD: Backgrounds survive a round trip through PhotoCraft
Photoshop opens a PSD's bottom layer as its Background when that layer's
record has no transparency (-1) channel. PhotoCraft always wrote one, so
every file with a Background came back from PhotoCraft with an ordinary
"Layer 0"-style bottom layer instead. Measured by opening PhotoCraft's
re-exports of the 427 corpus files in Photoshop 27.11: 317 lost their
Background this way.
- Export: the bottom top-level layer is written without the -1 channel
when it is a locked (transparency and position), unclipped, unmasked
raster that is opaque over the whole canvas at full opacity and Normal,
as a Background always is; flags as Photoshop writes them (bit 4 marked
meaningful, and "pixel data irrelevant" on adjustment layers).
- Import: like Photoshop, a bottom raster layer whose record has no -1
channel becomes the Background (locked, named "Background") whatever
the file names it: psd-tools' "Фон" and ag-psd's "bg" or "Layer 1" open
as Backgrounds in Photoshop.
Of the 227 corpus files whose bottom layer is a Background, PhotoCraft's
export now writes all 227 the way the original does (it was 0), and no
other bottom layer turns into one. Corpus oracles and round trips are
unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258 on this
commit; every round trip renders the same).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* PSD: a flat Bitmap / Indexed file's image opens as the locked Background
Probe 7 (PhotoCraft's re-exports opened in Photoshop 27.11): with the
Background fix, 314 of 318 Backgrounds survived; the other four were
psd-tools' flat 1-bit Bitmap and Indexed files. Photoshop opens a flat
file's image as its Background, but PhotoCraft's import of these modes
left the "Background" layer unlocked, so the export wrote it as an
ordinary layer. An opaque flat image now opens locked (as flat PNG/JPEG
and layered-but-flattened PSDs already did) and saves as a Background.
Corpus unchanged (io 148/170, psd-tools 237/309, Photoshop 137/258;
every round trip renders the same).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
When the wgpu compositor can't draw a refresh (layers over its memory
budget, or a feature it doesn't cover), the CPU compositor redrew the
whole document inside the draw call: minutes of Not Responding on a
multi-gigabyte PSB. With background jobs, a refresh of 4 MP or more is
now composited by a worker in 1024 px blocks, the visible ones first;
each frame uploads the finished blocks and the canvas keeps its previous
contents until they land. An edit while it runs draws its own damage and
restarts the worker on the edited document with the blocks still to
draw. The status bar shows Redrawing N%. Web and tests keep the
synchronous path.
* Brush: remember the last selected brush across restarts (split from #2516)
The persistence half of #2516 on its own, so it can merge and be maintained
apart from the brush-outline half. The tool's brush (settings, tip
references, and the name of the preset it was picked from) is saved once the
pointer is up and restored at launch: options bar, painting cursor and
Brushes panel highlight all start where the session left off.
Review fix from #2516 -- no per-frame work:
- `ToolState::brush_rev` is bumped by every brush/preset setter
(`tools.setBrush`, preset save/delete/rename/setCurrent/deleteGroup,
`brush.defineFromSelection`, `brush.presets.importAbr`, the store's tip
re-linking, and the shell's tool-switch/reset paths); `remember_brush`
serialises only when it moved since `brush_saved_rev`, so an unchanged
brush costs a counter compare per frame, not a clone and a JSON dump of
the whole brush (sampled tip previews included).
- Restoring counts as saved: the launch value is never rewritten.
Bitmaps stay out of the preferences document: remembered tips and patterns
are references (a loaded stored tile drops its full bitmap), and a sampled
bitmap too big to embed is skipped, keeping the previous value so a preview
never enters the prefs file. A malformed remembered brush falls back to the
default.
docs/scorecard.md regenerated with `cargo xtask scorecard` (34 of 154).
Tests: engine 1267 lib (5 new: revision gating, rejected changes, picked
preset, restore round trip, no embedded bitmaps), ui-egui 1982 lib; clippy,
layers, wasm green.
* rustfmt the StoredTile line and regenerate the scorecard after the main merge
* engine tests: destructive Levels keep the signed float overshoot at 32 bits (#2592)
* Scorecard: regenerate after merging upstream/main
* Export As: compare batch paths without the Windows separator
* font fix ?
* fix(ui): only Filter menu dialogs get the in-dialog preview window
#3877 drew the preview window in every schema dialog with a Preview
checkbox, including Image > Rotation > Arbitrary. Photoshop's Rotate
Canvas and adjustment dialogs have no preview window, and the taller
dialog covered the canvas, which broke
arbitrary_rotation_preview::size_changing_rotation_preview_is_visible_and_preserves_the_panned_center
on main. The window and its reserved height now apply to filter.*
commands only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* cargo fmt --check max_width = 160 fix
---------
Co-authored-by: JJ <jj@dot.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Clicking a History panel state ran Edit > Undo / Redo once per state. Each
step bumped the document revision, so the canvas could not tell what the
jump changed and recomposited the whole document (re-uploading every
changed layer on the GPU path).
history.selectState {state} returns the document to a state (numbered as
the panel lists them, redo states included) in one command: the history
moves by pointer swaps, the document becomes current once, with one
revision and the damage rect between start and target, and the targeted
layers restored as Undo restores them. The panel runs it once per click;
an open Free Transform refuses it (panel guard kept, and the transform
intercept greys and refuses it for every caller). A floating piece goes
back as with Undo instead of being dropped as a new step.
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Duplicate Layer dialog gains Photoshop's Document: New destination
with a document-Name field (As stays the copy's name, multi-select keeps
names, single group gets the Duplicate Group title). layer.duplicate
takes document index-or-new plus optional documentName, mirroring
channel.duplicate: the new document matches the source canvas, mode,
depth, resolution and profile, holds just the selected layers at their
positions, and becomes active. Smart-object contents are remapped within
the batch so copies are independent of the source. The dialog is fixed to
its content width so no dead strip remains.
* fix(text): split hyphenless PostScript style suffixes
A PostScript name without a hyphen kept its style in the guessed family:
`GmarketSansBold` became the family "Gmarket Sans Bold" at weight 400.
The guess now drops a trailing capitalised style word (Gmarket Sans, 700).
A lowercase ending ("Daylight") is not a style, and a style word after the
vendor tag MT stays in the legacy family ("Arial Rounded MT Bold").
Hyphenated names keep the last-hyphen parsing from #3878.
PSD import stores this guess when the exact face is absent. A legacy family
that ends in a style word without MT before it ("CooperBlack") is stored as
the shorter family (Cooper, 900) until the face is installed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* fix(type): missing-font checks follow layout's PostScript resolution
When a run's stored family is not installed, layout draws it with its exact
PostScript face, or with the family guessed from that name if installed.
Missing-font detection compared only the stored family label, so such a run
was reported missing, and Replace Missing Fonts rewrote it whenever an
unresolved run shared the label. missing_fonts, missing_fonts_in_layer and
the replacement now use layout's decision.
#1406 already corrects the label when a PSD or Affinity file is opened with
the face installed, and #1844 finds exact faces outside the family prefix.
The label still stays on a run reopened from .pcraft after its font was
installed, on fonts that arrive after opening, and on a run created with
both "font" and "postscriptName". The tests take the .pcraft and command
paths.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: kijoong <gijoong.gim@gmail.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Edit > Puppet Warp warped only a layer's pixels and left a linked mask
where it was, so the mask no longer lined up with the content. Free
Transform, Warp and Perspective Warp (#3083) already move a linked mask.
`puppet_warp_with_mask` solves the mesh once and moves the mask on the
same mesh: the mask under the mesh is lifted, deformed like the pixels
and laid over the rest; the area it leaves takes the mask's default and
the mask away from the mesh keeps its values. With a selection the mesh
covers only the lifted pixels, so only that part of the mask moves. An
unlinked mask stays put.
Tests: the moved stripe at 8/16/32-bit, undo, an unlinked mask, a
selection, and a hide-all mask in the algo crate. The engine tests fail
before the fix.
Claude-Session: https://claude.ai/code/session_01NPyyabA5efedMqVWMu3fiq
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* perf(display): encode linear documents for the screen in parallel
CanvasDisplay::texture_buffer (every CPU-path canvas upload of a linear
document, which every 32-bit document is since #3792) and to_rgba8's sRGB
encode ran one pixel at a time on one thread. Both now split the pixels into
16K-pixel pieces across rayon off wasm; results are bit-identical.
24 MP linear document, median of 5 (22-thread laptop, dev profile):
- texture_buffer: 348.5 ms -> 29.6 ms
- to_rgba8: 107.8 ms -> 10.6 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
* perf(compose): quantize Buffer::to_rgba8 in parallel
The plain 8-bit quantize behind the CPU canvas, previews, thumbnails and
8-bit exports ran on one thread. Split it into 16K-pixel pieces across
rayon off wasm; results are bit-identical.
24 MP buffer, median of 5 (22-thread laptop, dev profile): 122.3 ms -> 11.9 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
* perf(canvas): build canvas images in parallel, fused with the quantize
display_image (CPU canvas updates, filter previews, the navigator, the crop
pasteboard) quantized to RGBA8 and then ran egui's single-threaded
ColorImage::from_rgba_unmultiplied over every pixel. The identity path now
quantizes straight to Color32 in one parallel pass; the colour-managed path
converts in parallel. Results are identical to egui's conversion.
24 MP buffer, median of 5 (22-thread laptop, dev profile): 50.9 ms -> 12.7 ms
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AbyXia7KGdFgbNjBeggY8J
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Replace Color: Localized Color Clusters (points, localized, range)
image.adjustments.replaceColor takes the Localized Color Clusters of
Select › Color Range: "points" (where the colour was picked),
"localized" and "range" (% of the canvas's longer side). With clusters
on, the colour only matches near the points, through the same
color_range_samples as Color Range, so the same colour elsewhere in the
photo (a near-white background next to a cream garment) is left alone.
Without "localized" nothing changes. localized without points, and
malformed points, are refused.
The dialog part follows once the canvas eyedropper of #4247 is in.
Refs #4457
* Replace Color dialog: Localized Color Clusters and Range
The dialog gets Photoshop's Localized Color Clusters checkbox and its
Range slider (greyed while the box is off), and the canvas eyedropper
(#4247) now also records where the colour was picked (points), so the
localized match centres on that spot. The command's params list
localized, range and points as real keys in Photoshop's order.
Refs #4457
polygon_selection rasterised every lasso over the whole canvas (a
w×h f32 mask) and combined it over the whole canvas, whatever the
polygon's size.
Without feather it now rasterises the full-width band of rows the
polygon spans and combines over that band; Add and Subtract keep the
old selection outside it, Replace and Intersect leave it empty there,
exactly as the whole-canvas combine does. Full-width rows keep
`polygon`'s coverage bit-identical (its x arithmetic is relative to the
area's left edge, its y samples whole rows plus the same fractions);
a feathered lasso keeps the whole-canvas path, whose blur reaches past
the polygon. A test compares 24 random polygons, partly off the canvas,
in every mode, with and without anti-aliasing, against the old path.
Measured, a 2000-point lasso of a 400 px circle on 5472×3648: Replace
15–16 ms → 5 ms; Add to a selection unchanged (about 30 ms).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: document the engine.commands reply shape (#3684)
engine.commands, the engine command command.list and the MCP tool
command_list reply with a bare JSON array of command objects, which the
docs never said. Document the shape (keys, types, an example element,
how filter/enabled_only narrow it) in control-protocol.md and mcp.md,
state it in the MCP tool description, and pin it with an rpc test so
the docs cannot drift. The shape itself is unchanged, so existing
agents and scripts keep working.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Command list docs/test: include main's disabledReason and needsDocument keys
Main added `disabledReason` and `needsDocument` to every command list row
(#3664), so the documented shape and the pinning test now list all eight keys.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Like Photoshop, Cmd/Ctrl-clicking the Layers panel's New Layer button adds the
layer below the selected layer instead of above it. The Background stays at the
bottom: below it, the layer lands above it.
swatches.import named each imported group with unique_group, which
compares a candidate name with the library's groups one by one and
tries "Name", "Name 1", "Name 2" and so on until one is free. Groups
without a name all take the import's group name, so a file of n
unnamed groups cost about n^3/6 string comparisons. In a test build,
importing a file of 9,994 unnamed groups took about 4.5 minutes.
The import now builds the set of the library's group names once and
keeps the next number to try for each repeated name, as it already
does for swatch names. Group numbers still start at 1, and groups get
the same names as before.
unnamed_groups_are_numbered_in_linear_time imports such a file on a
thread of its own and fails when the import takes more than 20
seconds. repeated_group_names_take_the_first_free_number checks the
names when a group in the file has a name that the import already gave
to an earlier group.
PSD import keeps every placed layer as SmartSource::Linked { path:
<Idnt uuid> } so duplicates share the file and export stays
byte-stable, and info reported that as a link whose path is the uuid.
When the uuid has an embedded file (a liFD item in the preserved
lnk2/lnk3/lnkD blocks), the document's info now says
{"kind": "embedded", "fileName", "uuid"}; the new
linked::embedded_file_name finds it without copying the file.
Fixes#4842
A pattern fill or layer style copies its pattern into the document, but
the tile visitor behind History's byte budget skipped Document::patterns.
The pattern's pixels were left out of the budget, of trimming and of
Edit > Purge > Histories, including after an undo left them only to the
redo states.
Related to #4972.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Export Contents wrote the smart object's file as it was whatever the
path, so out.png held .pcraft or .psb data. With the contents' own
extension (or none) it still copies the file; with another one it opens
the contents and exports them in that format, as File > Export does
("converted": true, with the export's warnings). jpg/jpeg and tif/tiff
count as the same; an unknown extension is an error.
Fixes#4846
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>
- Preserve existing layer mask metadata (density, feather, linked, enabled) during select.refineEdge when outputting to layerMask.
- Honor explicit mask targeting (target: "mask") in refine_edge when both selection and mask exist.
- Add tests for mask metadata preservation.
document.inspect dumped an adjustment's internal model (enum name as
key, snake_case fields, 0..1 values), which no adjustment command takes.
It is now {"kind": "levels", "params": ...}: the kind and parameters
from adjust_params::to_params, which from_params reads back.
Fixes#4865
* Liquify: simplify the brush-resize gesture condition
Same logic; clears clippy's nonminimal_bool on 1.96.
* Channels: keep the cached composite across view-only changes
Channel eye toggles bump the revision the cache was keyed on, so it was always dropped. The test only passed when the freed buffer came back at the same address, and failed under load.
A point outside 0..255 was moved onto the edge without a word, while the
error for bad points says they must be in 0..255; Photoshop refuses
them too. The error also named the command twice-quoted (`adjustment
`curves``); it is now `curves adjustment`.
Fixes#4867
* Preferences › Interface: Photoshop's Appearance / Presentation / Options layout (#2532)
Replace the ~300 px theme preview cards with Color Theme swatches (a light
and a dark row, since we follow the system appearance) and group the page
like Photoshop: Appearance (mode, themes, canvas colour and border),
Presentation (UI language, font size, scaling) and Options (checkboxes).
At 1000×750 the language now shows without scrolling, in Photoshop's
place rather than moved to the top.
The swatch rows come from LIGHT_THEME_OPTIONS / DARK_THEME_OPTIONS, so
Breeze Light and Breeze Dark show up. Each swatch keeps the favourite star
(#2445) as a badge on its corner, shown on hover and kept for a favourite,
with the same "Add to favorites: <theme>" / "Remove from favorites: <theme>"
labels. Preferences search filters the grouped page: rows that don't match
are hidden and a group left empty is skipped.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Preferences: rustfmt the grouped Interface page
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* Tests: 32-bit destructive Levels keep the HDR range (main is red since #2592)
#2592 made float Levels keep values outside 0..1 and updated compose's
levels_dont_clip_in_32_bit, but the engine-side copy still expected 0 and 255
at 32 bits, so test (ubuntu/windows) and corpus tests fail on main.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 1ebaf7612e)
---------
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Ctrl/Cmd-pressing an anchor, a shown handle or a segment of the Pen (or
Curvature Pen) path in progress now edits it like Direct Selection does,
and drawing carries on from its last anchor once Ctrl/Cmd is released,
as in Photoshop. A Ctrl/Cmd-press anywhere else still ends the path.
The path in progress is a new Direct Selection candidate, PathRef::Pen,
built from ui.pen; its edits apply to ui.pen directly (no command, no
history step) since the path isn't in the document until it's finished.
The Add/Delete Anchor Point tools handle it the same way.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* Give Image Statistics a source-file dialog and stack mode selector
* Keep stack sources in vertical rows and translate the mode label
* Place the new label alongside stack-mode translations
---------
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
select.colorRange takes {"select": "skinTones"}: a soft skin-tone model
in the YCbCr chroma plane (an ellipse along the skin run of Chai & Ngan,
1999) with a luminance gate, so near-black and near-white pixels drop
out. Fuzziness widens or narrows it as it does for Sampled Colors; the
selection modes, Invert and Sample All Layers work as for the other
presets. The dialog lists Skin Tones with Fuzziness enabled.
Detect Faces needs a face detector, which there is none of yet:
detectFaces: true is refused with a message instead of being ignored.
Fixes#4455