Commit Graph
772 Commits
Author SHA1 Message Date
Valentin KleinandClaude Opus 5.5 a7f7f1fd1e Edit › Menus: Menus, Keyboard Shortcuts, Preferences, Save and Exit can't be hidden (#5096) (#5183)
Hiding Edit › Menus (or Keyboard Shortcuts) left no way back into the
dialog, and Save, Save As, Exit and Preferences could disappear too.
edit.menus now refuses to hide them, the dialog greys their Visible box,
and the menus keep them even when a preferences file lists them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:07:28 -04:00
MarekSpodBiedry a904bd7a39 Frame tool: take type, shape and smart object layers into the frame (#4435) (#4441) 2026-10-14 13:06:32 -04:00
Gert Pellin - GPE fe8fa3b9bc Color Range: sample colours outside the active selection (#5244) (#5296)
The eyedroppers (the points/subtractPoints params, and clicks on the
canvas while the dialog is open) refused points outside the active
selection. Photoshop samples anywhere and only limits the result to the
selection, which Color Range already does; the refusal is gone.
2026-10-14 13:06:05 -04:00
Nigel Ihza Firdausy b8ae1cd92f fix(brush): preserve Dual Brush tip spacing and scale its size with the primary brush (#4856)
* fix(abr): prefer dual brush tip spacing over wrapper spacing

* test(abr): cover conflicting dual brush spacing descriptors

* fix(paint): scale secondary brush tip when changing primary diameter

* fix(engine): preserve dual tip proportions through brush commands

* test(brush): cover secondary tip scaling across settings, strokes and presets

* style: apply rustfmt to Dual Brush ABR code

* style: apply rustfmt to Dual Brush ABR code
2026-10-14 12:47:23 -04:00
Wil 0eb008ce5d Actions: record layer selections by name and display the target (#2441)
* Record action layer selections by name and show their targets

* Use document pixel format in grouped-layer regression

* Document the named layer action UI with a synthetic capture
2026-10-14 12:46:31 -04:00
Gert Pellin - GPE 7b08b56531 feat(engine): layer.list, a read-only listing of the layer tree (#5059) (#5148)
The layers were only in document.inspect, together with history,
channels and much more, so agents looked for a layer.* command that
didn't exist.

layer.list returns the layers top-down with id, name, kind, visibility,
bounds, selected and active; groups list their children, or with
"flat": true every layer is one row with its depth and parent group.
It takes the same optional "document" index as document.inspect, is not
journaled and bad params are errors.
2026-10-14 12:46:18 -04:00
45f8921ae8 Free Transform: solid pixel stretch (edge-repeat sampling, selected-pixel bounds) (#3867)
Stretching a 1 px row or column faded out along the stretch: bicubic and
bilinear sampling read transparent pixels past the source edge, and with a
selection the edge was the whole layer's bounds (lifted pixels kept their
colour at zero alpha). Samples past the source rect now repeat the edge and
the outline is antialiased by coverage, and the lifted pixels' bounds are the
selected pixels'.

Fixes #3773

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:46:11 -04:00
8d2806f66c Expose shared Smart Object instances in inspection and replacement results (#4832)
* Smart Objects: inspect and Replace Contents show shared instances (#4075)

Placed layers that share a source have imported as one set of contents
since #2222, and Replace Contents changes every instance, but nothing
reported it: `linkGroup` is Link Layers, and `smartSource` can't tell
copies of one contents from independent copies of the same file.

document.inspect now gives each smart object `smartContents`: the
contents `id` its copies share and how many `instances` there are.
Replace Contents, Relink to File and Convert to Embedded/Linked list the
`instances` they changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test rendered caches after shared Smart Object replacement

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:46:04 -04:00
shmuel biller 7b8e5034d0 Fix/scale marker rollback layer state 4975 (#5391)
* fix(engine): restore document targeting on failed compound edits

* test(engine): prove failed scale-marker rollback preserves layer targets

* fix(engine): resync tool mask target after restoring channel view

* test(engine): cover channel and tool-mask rollback after failed compound
2026-10-14 12:45:36 -04:00
Gert Pellin - GPE 56c5b355a4 feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5140)
* feat(commands): find the Elliptical Marquee by 'ellipse' or 'marquee' (#5060)

The Elliptical and Single Row/Column Marquee are options of select.rect
("ellipse":true, "line"), but the command-list filters only looked at
the id and the label ("Rectangular Selection"), so 'ellipse' and
'marquee' found nothing.

Commands can now carry search keywords. command.list / engine.commands
rows include them as "keywords", and the three filters (CLI commands
--filter, the engine.commands RPC and the MCP command_list) share one
matcher over id, label and keywords. select.rect gets the Photoshop
marquee names as keywords.

* Command list replies: document the optional keywords key

The reply-shape test from #3684 pins the exact keys of each row; select.rect
now also carries keywords, so the test and docs/control-protocol.md allow
that one optional string.
2026-10-14 12:45:24 -04:00
Abdul karim mia 1ab33d5113 Map .atn duplicate-into-new-document (Mk) to layer.duplicate (#5371)
* Map .atn duplicate-into-new-document (Mk) to layer.duplicate

Photoshop records Layer > Duplicate Layer with Destination: New as a
Mk event (document class + layer Usng, Nm = new doc name, LyrN = copy
name), verified against a ScriptListener/XTensions export. Import maps
it to layer.duplicate {document: new}; export writes it back, so the
steps round-trip instead of becoming kept Photoshop steps that fail at
playback. A Dplc with a T document reference (another open doc) now
imports as a kept step rather than misplaying as a same-doc copy.

* Fix Rust formatting in ATN command tests

* Fix rustfmt formatting in ATN command tests
2026-10-14 12:45:11 -04:00
MarekSpodBiedry d12dc6b718 fix(layers): merging layers keeps pixels that lie outside the canvas (#5067) (#5124) 2026-10-14 12:45:05 -04:00
Vikram BhaskaranandBrandon Thomas c4bf9eb045 Bound selection sampling buffers and report allocation failures. (#4594)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:44:54 -04:00
Vikram BhaskaranandBrandon Thomas e34cdcbcca Keep channel pastes transactional when pixel allocation fails. (#4588)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 12:44:47 -04:00
Valentin KleinandClaude Opus 5.5 706638b284 Tests: bad-layer-id checks use an id that can't exist (#3343) (#3351)
* 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>
2026-10-14 01:59:43 -04:00
7ad75b5db2 Layer masks: Reveal Selection drops the selection, as Photoshop does (#5251)
A mask made from the selection takes the selection with it in Photoshop: after
Layer > Layer Mask > Reveal Selection (the Layers panel's mask button with a
selection) the marching ants are gone. PhotoCraft kept the selection, so the
usual next step, Invert (Cmd/Ctrl+I) on the new mask, inverted only the part
inside the old selection and left the whole mask black, instead of flipping
the mask. Hide Selection (the Alt-click variant) already dropped it.

Reveal Selection now clears the selection in the same history step as the
mask: one undo brings the selection back and removes the mask.

Tests: adjust_cmds::tests::reveal_selection_drops_the_selection_so_invert_flips_the_whole_mask
(8/16/32-bit). Tests that deselected right after Reveal Selection no longer
need to (Deselect is disabled without a selection); layer_mask_depth and the
automation agent tasks now expect the selection to be gone.

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:59:27 -04:00
Nicolas FalesyandClaude Opus 5.5 7a0e60c104 test(engine): add Color Range fuzziness 0 exact-match test (TOOL-214-3) (#3581)
* test(engine): add Color Range fuzziness 0 exact-match test (#214)

Adds an explicit test for Sampled Colors at Fuzziness 0 (point sampling
and hex colors) asserting that exact matches are fully selected and
adjacent shades are rejected. Updates scorecard TOOL-214-3 to done.

* docs: regenerate scorecard (TOOL-214-3 done)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017WeVmjnnPPKdxVTpr2Vjtw

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 01:59:13 -04:00
Gert Pellin - GPE 7e28900df3 layer.setProps: refuse opacity and fill outside 0..1 (#5242) (#5291)
Opacity and Fill are fractions in layer.setProps but percentages in the
Layer Style commands, and a value above 1 was clamped silently: 55 gave
an opaque layer. Out-of-range or non-numeric values now fail with an
error that names the range, and the layer is left alone.
2026-10-14 01:59:09 -04:00
Gert Pellin - GPE 15f7618504 Object Selection: say so when the sampled layer is empty (#5246) (#5298)
select.object samples the active layer by default. On an empty layer
(scripts often add one above the photo) it found nothing and returned
without an error; the failure only surfaced at the next command that
needed a selection. It now fails with an error that names the cause and
the sampleAllLayers option, also when the layer's pixels lie outside the
rectangle or point.
2026-10-14 01:59:04 -04:00
Gert Pellin - GPE 4bbcede572 Fibers: fill an empty layer, as Photoshop does (#5241) (#5289)
Fibers renders from the foreground and background colours without
reading the layer, but its output area was the layer's content grown by
a zero halo: on an empty layer that is empty, so nothing was drawn (and
a following transform found nothing to transform). On a partly filled
layer only the content's bounding box was filled. It now writes the
reference bounds (the selection's, else the canvas) like the global
filters, without reading the whole layer as they do.
2026-10-14 01:59:00 -04:00
Gert Pellin - GPE 2da2889546 Background: an opacity, fill, blend mode or Select and Mask layer mask makes it Layer 0 (#4837)
The Background has none of them. layer.setProps applied them to it as it
was, and Select and Mask's Layer Mask output gave it a mask. Like
Photoshop (and like PhotoCraft already does for moving, transforming,
erasing, styles and Layer > Layer Mask), it now becomes a normal layer,
"Layer 0", first. Values that leave its look alone (opacity and fill
100 %, Normal) keep it the Background.

Fixes #4836
2026-10-14 01:58:55 -04:00
Vikram Bhaskaran 7c916d9ecd Validate selection modification limits before allocating the canvas mask. (#4591) 2026-10-14 01:58:51 -04:00
Vikram Bhaskaran 79e2107f71 Report channel copy allocation failures without losing the clipboard. (#4587) 2026-10-14 01:58:46 -04:00
33b360d5ee Reject clipboard origins that cannot form a new document (#4586)
* Reject clipboard origins that cannot be moved into a new document.

* Fill Clip.resolution in the unrepresentable-origin clipboard test.

main added resolution to Clip; the synthetic overflow fixture omitted it and failed engine lib tests.

* Format the clipboard origin test (cargo fmt)

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>
2026-10-14 01:58:42 -04:00
Ivan · VanTan e12b7f7352 fix(prefs): preserve selected themes during legacy migration (#5179) 2026-10-14 01:58:31 -04:00
skylartaylor 235d4e22fa Merge current main while preserving native printing and system printer menus 2026-10-13 21:18:34 +02:00
David MannandClaude Opus 5.5 b41e34c20f Rulers: drag the zero point from the ruler corner, double-click resets it (#2955) (#3505)
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>
2026-10-13 13:24:17 -04:00
ragtuxandClaude Fable 5.1 995bc89c40 Auto-Blend Stack Images: a depth map by depth from focus, for Lens Blur (#2073)
* 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>
2026-10-13 13:23:35 -04:00
kayrockscreenprintingandClaude Opus 5.5 50631b54f7 Profile prompts on open: one file at a time, before it opens, and the answer goes to that file (#3471)
* 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>
2026-10-13 13:10:04 -04:00
jonathanwxh-cellandjonathnwxh 01b354a340 Add filename templates to Batch export (#4466)
* Add filename templates to Batch export

* Keep Batch naming evidence out of the shared gap-summary header

* Avoid catalog append conflicts for Batch naming labels

* Keep Batch naming labels clear of catalog append conflicts

* Keep Batch naming details in the automation parity row

* Separate Batch documentation from evolving Actions details

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
2026-10-13 13:03:02 -04:00
25a82978b2 fix(segment): let Remove Background's cancel stop Select Subject's min cuts (#4878)
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>
2026-10-13 12:56:21 -04:00
9a4d925b34 Brush: remember the last selected brush across restarts (split from #2516) (#3472)
* 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>
2026-10-13 12:48:41 -04:00
jonathanwxh-cellandjonathnwxh d0fc974aae Wrap Clone Stamp strokes and sampling in Pattern Preview (#4997)
* Wrap Clone Stamp sampling and strokes in Pattern Preview

* Preserve per-dab flow and source alpha across pattern seams

---------

Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
2026-10-13 12:48:33 -04:00
David MannandClaude Opus 5.5 a666ab5a85 History: jump to a state in one step (history.selectState) (#2966) (#3533)
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>
2026-10-13 12:48:26 -04:00
Abdul karim mia ca5755f5b0 Layer: Duplicate Layer to a new document, as in Photoshop (#4805)
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.
2026-10-13 12:48:18 -04:00
ce6fccc3f5 fix(type): missing-font checks follow layout's PostScript resolution; split hyphenless style suffixes (#608)
* 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>
2026-10-13 12:48:09 -04:00
DeWarePebandClaude Opus 5.5 d740ea638d Puppet Warp: a linked layer mask moves with the pixels (#4910) (#4989)
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>
2026-10-13 12:48:02 -04:00
Nicolas FalesyandClaude Opus 5.5 4b76f10072 perf: encode and quantize canvas pixels in parallel (display_color, Buffer::to_rgba8) (#4768)
* 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>
2026-10-13 12:47:55 -04:00
Gert Pellin - GPE b007b1d355 Replace Color: Localized Color Clusters (points, localized, range) (#4477)
* 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
2026-10-13 12:47:47 -04:00
FinnNkandClaude Opus 5.5 b3df06b8c4 perf(engine): lasso and polygon selections rasterise only the rows they span (#4532) (#4773)
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>
2026-10-13 12:47:40 -04:00
Sean Hellwig 1460187522 Layers: Cmd/Ctrl-click New Layer adds the layer below the current one (#4475)
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.
2026-10-13 12:47:24 -04:00
.CooCoo 0a5cc0d1a0 Swatches: importing an .ase file numbers repeated group names in linear time (#5087)
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.
2026-10-13 12:47:17 -04:00
F4LCON ef8dc9ea30 Layer locks: locking the pixels clears the transparency lock, as in Photoshop (#4892) (#4898) 2026-10-13 12:47:10 -04:00
Gert Pellin - GPE 1bac788706 info: a smart object embedded in a PSD is reported as embedded (#4843)
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
2026-10-13 12:47:01 -04:00
Gert Pellin - GPE 663863e62b Smart Objects > Export Contents: another extension exports the contents in that format (#4847)
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
2026-10-13 12:46:40 -04:00
FinnNkandClaude Opus 5.5 52fcca883e perf(paint): sampled tips share their samples, so cloning brush settings is cheap (#4534) (#4798)
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>
2026-10-13 12:46:25 -04:00
Tarek Mohamed 2307acef92 feat(select): preserve existing mask metadata and allow explicit target in refineEdge (#4499)
- 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.
2026-10-13 12:46:17 -04:00
Vikram Bhaskaran b52115fc0a Preserve document and history when Fade cannot allocate its buffers. (#4593) 2026-10-13 12:46:10 -04:00
Gert Pellin - GPE f389d4ee64 info: adjustment layers are reported with the params their command takes (#4871)
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
2026-10-13 12:46:03 -04:00
Persi 207d05a0e2 Channels: keep the cached composite across view-only changes (#4915)
* 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.
2026-10-13 12:45:56 -04:00