Commit Graph
50 Commits
Author SHA1 Message Date
David Mann 2b0deddec2 Merge remote-tracking branch 'origin/main' into integration-batch-7
# Conflicts:
#	crates/ui-egui/src/panels.rs
2026-10-24 00:51:37 +02:00
Lucas RodriguesandBrandon Thomas 15db2d8560 Paste: keep the pasted image on the canvas (#6759) (#6798)
* Paste: keep the pasted image on the canvas (#6759)

Paste centred the clipboard image on the view centre with no clamp, so an
image that fit the canvas could land partly off it, and one larger than
the canvas could leave blank edges. Now, per axis, an image that fits stays
wholly inside the canvas, as close to the view centre as it can, and one
larger than the canvas starts at the canvas's top-left edge, as in
Photoshop. Paste Into / Outside still centre on the selection
("clampToCanvas": false). Centres are computed edge by edge in f64, so
extreme coordinates no longer overflow.

* Paste: doc comments for Paste Into and the paste placement tests

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-23 18:35:21 -04:00
edea110601 Integration: refuse Paste Into a Lock-All group before pasting (#6352)
With the mask commands now honouring Lock All, Paste Into / Paste Outside with a
child of a Lock-All group active committed edit.paste and then failed on the
refused layer.layerMask.revealAll, leaving an unmasked pasted layer behind an
Err. paste_into now checks the destination group's locks first and refuses
cleanly. Test: mask_lock_all::paste_into_a_lock_all_group_refuses_before_pasting
(layer count and history unchanged). Also moves mask_lock_all.rs into main's
one-binary tests/it layout.

Co-authored-by: osaidahmed <170216456+osaidahmed@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-22 20:52:59 +02:00
aiongg 4484a07a5c Merge PR #6440: fix(engine): convert the background and match mask depth where they were missed 2026-10-22 02:22:00 +00:00
aiongg 5189019b5b Merge PR #7696: fix(actions): preserve effective Stroke width in ATN export 2026-10-21 21:56:10 +00:00
Matthias Schlich 0a7325af6e Merge origin/main into feat/psd-render-smart-filters
# Conflicts:
#	docs/file-format-parity.md
2026-10-20 22:20:37 +02:00
David MannandClaude Opus 5.5 b67502e20f Reveal All: one History step named "Reveal All" (#7145) (#7147)
image.revealAll ran image.crop, whose step is labelled "Crop". It now
runs it inside analysis_cmds::compound with the label "Reveal All".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:19:35 -04:00
PerchAnton 0acb707fe1 fix(actions): preserve effective Stroke width in ATN export 2026-10-19 12:28:43 +03:00
aiongg 4127604122 Merge PR #6502: Engine: reject numeric overflow before document edits 2026-10-18 13:15:50 +00:00
Matthias Schlich 690a73b595 Merge origin/main into feat/psd-render-smart-filters 2026-10-18 11:14:58 +02:00
Korg1ch f04fc80267 fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928) (#6967)
* fix(engine): flip and rotate presets turn about the selection's bounds, as Free Transform does (#6928)

* test(engine): cover flips and turns about the selection (#6928)
2026-10-18 03:07:18 -04:00
Matthias Schlich 875ec69fce fix(filters): Clouds size their pattern by the canvas, not the selection
Since Clouds became a filter, its cloud size came from the filter's reference bounds, which are
the selection's when there is one: clouds in a selection were finer than the canvas's. Before,
they were sized by the document. Clouds now take the canvas as their bounds; the selection only
masks them, as before (review on #6160).
2026-10-17 12:28:10 +02:00
Matthias Schlich c6fff8a3a8 Merge origin/main (up to a1e19a0b) into feat/psd-render-smart-filters
# Conflicts:
#	crates/engine/src/smart_cmds.rs
2026-10-17 12:10:28 +02:00
PerchAnton 3f216a0cd1 fix(engine): reject numeric overflow before document edits 2026-10-16 10:28:57 +03:00
Omar Saidahmed 2b5e857d92 convert the background and match mask depth where they were missed 2026-10-15 22:46:05 -04:00
Tanish Manku 04db697b5d Paste Layer Style: a group's Pass Through pastes onto other layers as Normal; style clipboard comment (#6310) 2026-10-15 14:17:22 -07:00
Tanish Manku c11ef015b7 Paste Layer Style carries Blend If and channels; align to an active selection with several layers (#5881)
* Paste Layer Style carries Blend If and channels; align to an active selection with several layers

From the layers audit (L2):
- Copy / Paste Layer Style copied the effects, blend mode, Fill and advanced blending but
  dropped Blend If and the channel restrictions; Photoshop's layer style carries all of its
  Blending Options. Across colour modes only Blend If's Gray range carries over and the
  channels reset (RGB channel ranges mean nothing in CMYK). The copied style is now a named
  struct (CopiedStyle) instead of a tuple.
- With an active selection, aligning (Layer › Align, "auto") aligned two or more layers to
  their own bounds; Photoshop aligns them to the selection (Layer › Align Layers to
  Selection), as it already did for one layer.

* Paste Layer Style keeps Blend If normalised across modes; Align docs say it follows an active selection for any count; undo, group and 16-bit tests
2026-10-15 13:10:28 -07:00
Tanish Manku 77a258b9a0 Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles (#5868)
* Clip groups: Release Clipping Mask takes the layers above; merges keep clipping; Delete Hidden settles

From the layers audit (L2, silently wrong results):
- Release Clipping Mask on a clipped layer also releases the clipped layers above it, as
  Adobe documents ("removes the selected layer and any layers above it from the clipping
  mask"). Before, they stayed clipped to the released layer.
- Merge Down of two clipped layers, and Merge Layers of clipped layers of one chain, give a
  layer still clipped to their base (it was always unclipped). Merge Down's result also keeps
  the lower layer's colour label and link; Merge Layers' keeps the top layer's label.
- Delete Hidden Layers releases the layers clipped to a deleted hidden base (clip_group rule
  3, as single Delete already does), instead of leaving them clipped to the layer below.

* Merge Layers keeps the result clipped only when the clipped layers share one clip chain

* fmt
2026-10-15 13:10:22 -07:00
Matthias Schlich 50a011ae9e feat(filters): Clouds and Difference Clouds as smart filters; render filters work within a smart object
Clouds and Difference Clouds ran outside the filter pipeline (map_pixels with the session's
colours), so on a smart object they failed instead of adding a smart filter. They are now a
regular render filter (FilterParams::Clouds, same noise as before) that records its colours,
so a smart filter re-renders the same clouds after the colours change.

Render filters on a smart object (Clouds, Difference Clouds, Fibers, Lens Flare) now use the
placed content's bounds, as Photoshop does: a flare's centre is a fraction of the object, not
of the canvas.
2026-10-15 16:05:34 +02: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
3a307a757e Play real Photoshop action sets: TrueGrit .atn mappings and fixes (#4356, #219) (#4698)
Third-party action sets that load since 3204231 now play. Four TrueGrit
Texture Supply sets (Debaser, Distress Press, Halftone Zine Machine,
Transparency) go from 223 to 3 unmapped steps of 533 and play end to end.

- .atn import, 108 -> 112 events: Select and Mask (refineSelectionEdge),
  Filter Gallery Spatter (GEfc), Ripple, Crystallize; targeting the layer
  mask or composite channel, loading the targeted channel, Step
  Backward/Forward, removeFromSelection (it silently imported as a plain
  select), Pattern Fill layers (by name), nudges in points, Layer From
  Background, Grayscale/Bitmap mode objects, Image Size by resolution.
- Engine: layer.translate units pt; layer.select mode remove;
  select.loadSelection channel targeted; image.imageSize keepPrintSize;
  layer.new.layerFromBackground ifPresent/select; pattern.import data.
  New from Clipboard keeps the copied document's resolution (was 72 ppi).
- Filters: Spatter's Smoothness smooths the displacement (torn but
  attached edges, as Photoshop's); Crystallize fills a cell from its point
  instead of the average, so masks keep hard edges.
- UI: .pat opens/drops into the Patterns panel (no UI import existed).
- Docs: gaps G11, file-format and target-app parity.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-13 01:10:58 -04:00
Valentin KleinandClaude Opus 5.5 dc8a3f0b35 Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351) (#4352)
* 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>

* Paint Bucket and Edit > Stroke: a CMYK foreground writes the picked inks (#4351)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:54:16 -04:00
81f9745033 fix(smart-object): keep pixels when rasterizing smart object layer (#3873)
Resolve an issue where rasterizing a smart object layer resulted in an empty raster surface if the cache was not yet populated. Now refreshes the smart layer snapshot if needed, extracts the rendered surface, converts to the document pixel format, and cleans up PSD placed-layer blocks (SoLd, SoLE, PlLd). Includes a regression test suite covering solid fills, new layers, shape layers, and transformed smart objects.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:03:40 -04:00
53d814f643 feat: Photoshop's layer and selection keys (Ctrl+, toggle, Ctrl+\, /, Ctrl+Alt+2, Ctrl+Alt+Backspace, Tab rename, Alt-click thumbnail) (#3710)
* feat(engine): Ctrl+, toggles a layer's visibility and / its transparency lock

Photoshop's Ctrl+, (Cmd+, on macOS) hides the selected layers and, once
they are all hidden, shows them again: its Layer menu item then reads
Show Layers. layer.hideLayers now does the same, so the key toggles: it
shows them only when every targeted layer is hidden.

The / key toggles Lock Transparent Pixels, as the Layers panel's first
lock button does (layer.toggleLockTransparency). With several layers
selected, any unlocked one means lock them all; the Background, which
can't take this lock, is left out. Each press is one undoable step.

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

* feat(engine): Ctrl+\ targets the layer mask and Ctrl+Alt+2 loads luminosity

Photoshop's Ctrl+\ (Cmd+\) targets the active layer's mask, as clicking
its thumbnail does, beside Ctrl+2 for the composite
(channel.target.layerMask; greyed when the layer has no mask). Its
Ctrl+Alt+2 (Cmd+Option+2) loads the image's luminosity as a selection,
the Ctrl-click on the composite channel (select.loadLuminosity).

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

* feat(engine): Ctrl+Alt+Backspace fills from history

Photoshop's Ctrl+Alt+Backspace (Cmd+Option+Delete) fills the selection, or
the layer, from the History Brush's source state. edit.fillHistory is
edit.fill with contents "history": the oldest state still held, unless
"state" names another. It joins the other fill keys, so it honours the
Channels panel target and appears with them in Keyboard Shortcuts.

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

* feat(ui): Tab renames the next layer; Alt-click a thumbnail fits the layer

While a layer name is being edited, Tab commits it and starts renaming the
layer below, and Shift+Tab the layer above, as in Photoshop; the
Background, which isn't renamed in place, ends it. Alt-clicking a layer's
thumbnail selects the layer and fits its content on screen (View › Fit
Layer(s) on Screen).

Key tests cover Ctrl+,, /, Ctrl+\, Ctrl+Alt+2 and Ctrl+Alt+Backspace
through the shortcut dispatcher. docs/ui-parity.md lists the new keys.

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

* fix(ui): an explicit channel target drives the Layers panel's mask target

After Ctrl+\ targeted the layer mask, Ctrl+2 moved the Channels target back
to the composite, but the Layers panel kept the mask targeted (its own
flag, which only ever followed the engine towards the mask), so painting
still went to the mask. The same happened after clicking the mask
thumbnail. Any channel.target command (Ctrl+2, Ctrl+\, Ctrl+3…) now sets
the panel's mask and vector-mask targets from the engine's target.

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

* Use Document::is_background after #4036 removed extra_cmds::is_background

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>
2026-10-12 07:32:23 -04:00
Brandon ThomasandClaude Opus 5.5 b564f2ca9c fix(layers): identify the Background by structure, never by its English name (#4036)
Photoshop's Background is the bottom layer of a file without transparency: a pixel layer that is
transparency- and position-locked, whatever the file's language calls it ("Hintergrund", "背景",
"Arrière-plan"). We matched the name "Background" in compose (deep / shallow knockout), the
engine (cut/clear, merge visible, transforms, warps, distort, erasers, styles, masks, crops,
straighten, smart-object unpack) and the UI (Properties, layer row, tab title), so a localized
file's deep knockout revealed transparency instead of the Background.

- `Document::background` / `background_mut` / `is_background` and `Layer::has_background_traits`
  in the doc model replace every name check; `extra_cmds::background_to_layer` is the one
  "Background → Layer 0" conversion.
- PSD import marks the bottom record position-locked when it is transparency-protected and has
  no transparency channel, with or without an `lspf` block. Affinity import and HDR Toning now
  lock the Background's position like every other place that creates one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:33 -04:00
Brandon ThomasandClaude Opus 5.5 0d6e658e73 PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

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

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:11:33 -04:00
Valentin KleinandClaude Opus 5.5 7107c80042 perf: pasting a layer recomposites only where it draws (#2901) (#2917)
* perf: Duplicate Layer recomposites only where the copy draws (#2883)

Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

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

* perf: pasting a layer recomposites only where it draws (#2901)

Paste (and Paste in Place, Paste Into, Paste Outside) inserted the
pasted layer through Session::edit, which reports no damage, so every
paste recomposited the whole document (P10/P11). Capture the document
before the paste and report the new layer's change bounds with
note_insert, as Duplicate Layer does (#2884). Anything that isn't
self-contained (a new clipping base, video, excluded channels) still
recomposites everything.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:24:51 -04:00
Valentin KleinandClaude Opus 5.5 017d1aea1c Tools: Perspective Crop (#1043) (#2864)
New Perspective Crop tool in the Crop flyout (C): drag a box or click four
corners, drag corners independently onto a skewed object, perspective grid
(Show Grid), Enter commits, Esc cancels. W/H/Resolution come from the Crop
tool's fields (Front Image, Clear), or the quad's mean side lengths.

Engine: image.perspectiveCrop {corners, width?, height?, resolution?,
interpolation?} maps the quad onto an upright canvas through the
quad-to-rectangle homography (Heckbert's square-to-quad map, inverted):
every raster layer, mask, alpha channel and the Quick Mask, one undo step.
The Background stays opaque over the background colour; type, shape, fill
and smart object layers are rasterized first; vector geometry follows the
nearest affine map. Non-convex, self-crossing, counter-clockwise or
degenerate quads are refused with an error.

algo: warp_surface_clipped bounds the per-tile warp by the new canvas even
when a layer reaches past the map's horizon; convex_quad, quad_size,
quad_to_rect.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:28:11 -04:00
Valentin KleinandClaude Opus 5.5 7609834df8 Channels: Edit › Transform works on a targeted alpha channel (#2835) (#2845)
edit.transform without a "target" (or "layer") param now follows the
Channels panel: a targeted alpha channel or the Quick Mask transforms
instead of the layer. Edit › Transform › Rotate 180°/90° and Flip
Horizontal/Vertical are routed to the panel target like the paint and
filter commands, so they turn a targeted alpha channel, the Quick Mask or
an unlinked layer mask within its frame (one undo step). The Background
no longer greys the transforms when a channel is targeted.

Flips scorecard row BUG-207-4.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:38:36 -04:00
3c70d3b34f Layer > Rasterize > Layer Style bakes effects; Rasterize Layer converts vector masks (#2245) (#2669)
* Add Layer > Rasterize > Layer Style; Rasterize Layer converts vector masks (#2245)

Nothing could bake layer effects into a layer's pixels, and Rasterize >
Layer refused a raster layer that has a vector mask.

New command layer.rasterize.layerStyle (Layer > Rasterize > Layer Style,
and in the Layers context menu). It renders the layer by itself through
the compositor at 100% opacity in Normal mode, so the effects, fill
opacity and masks end up in the pixels, including those past the canvas.
The layer's own opacity, blend mode, knockout and Blend If stay layer
properties. Type, shape, fill and Smart Object content is rasterized
first, all in one history step. With a Normal layer and Normal-mode
effects the composite is unchanged at 8, 16 and 32 bit. Effects in
another mode are evaluated against the isolated layer's transparent
backdrop, because one layer of pixels cannot hold several blend modes.

Rasterize > Layer and All Layers now also turn a vector mask into a
pixel mask ("all vector data") and leave effects live, as Photoshop
does. The menu enablement follows both changes.

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

* Regenerate docs/scorecard.md after rebase (#2245)

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

---------

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>
2026-10-10 19:20:33 -04:00
Fabian GhenofandClaude Opus 5.5 96198fe49d Copy and paste a single channel from the Channels panel (#2307) (#2695)
With a colour channel (Red, Green, Blue, ...) targeted, Copy copied the whole
layer and Paste made a new layer, so a channel could not be copied into
another one. Now, as in Photoshop:

- Copy with a colour or alpha channel targeted copies that channel's values
  as a grayscale clip, inside the selection or across the whole canvas. With
  an alpha channel targeted, Copy needs no pixel layer.
- Paste, Paste in Place and Paste Into with a colour channel targeted write
  the clip's luminosity into that channel of the active layer, in one undo
  step. The other channels and the layer's transparency are left alone, and
  a pixel lock refuses the paste.

The logic lives in a new channel_clip module; Copy, Paste and Paste Into only
call it. Cut keeps its old behaviour.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:59:34 -04:00
Nigel Ihza FirdausyandBrandon Thomas 8d13aff784 fix(layers): preserve Background behavior across merge, duplication and canvas resize (#2419)
* fix(layers): keep Background semantics across merge, duplication and canvas size

Preserve destination locks in Merge Down, share the normal Background-copy rule with multi-layer duplication, and convert a Background to a paintable layer when extending its canvas transparently.

Add depth-aware regression coverage and ordinary-layer controls for #2380, #2381 and #2384.

* refactor(layers): copy lower-layer locks without cloning Copy data

* Format background_layer_consistency test

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 13:26:11 -04:00
28ed1c1147 Handle allocation failures in large pixel operations (#2530)
* Make large pixel operations allocation-fallible

* Warp: drop the fixed 400 MP cap; fallible reservations already cover large transforms

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

* Adapt #2530 to main: fallible steep-Distort warp, Result from mask paste in float moves; regenerate scorecard

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-10 13:26:07 -04:00
Sir StarchandBrandon Thomas 7b3ded0f8b Layers menu and layer commands follow the selection, not the active layer (#2244) (#2254)
* Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243)

Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

* Layers menu and layer commands follow the selection, not the active layer (#2244)

Availability of Rasterize, Create/Release Clipping Mask, Add Layer Mask and Paste/Clear Layer
Style was decided by the active layer, so one selection was greyed or not depending on which of
its layers happened to be active (with the Background active, Create Clipping Mask and Add Layer
Mask were greyed although they would have run on the other layers), and Rasterize Layer was
enabled for layers it then refused ("nothing to rasterize"). Several of these commands also only
ever acted on the active layer, whichever layer the menu was opened on.

- Add Layer Mask (Reveal/Hide All, Reveal/Hide Selection), Paste Layer Style and Clear Layer Style
  run on every selected layer that can take them (not the Background, layers that already have a
  mask, layers without a style), in one history step. An explicit `layer`, or a single selected
  layer, behaves as before.
- `enable_rules::selection_targets`: availability of those commands, Create/Release Clipping Mask
  and Rasterize is judged by the selection.
- Rasterize Layer is enabled only for type, shape, fill and Smart Object layers, which is what the
  command converts.
- Items with no single target stay greyed for several layers: Blending Options, Copy Layer Style,
  Quick Export, Export As and the per-layer mask items. A multi-layer menu has one stable mask item.

Tests: engine integration (mask, paste and clear on a selection, errors, unchanged single-layer
paths) and a property test over 20 selections x clipboard state x 7 commands: an enabled item runs,
and for several layers a greyed one would have changed nothing. It fails on the old rules for
["R1", "BG"] with the Background active, the case from the bug report.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 04:18:42 -04:00
Sir StarchandBrandon Thomas 1b6150e3fc Layer › Rasterize acts on every selected layer; the Layers menu offers Rasterize Layers (#2243) (#2253)
Rasterize › Layer / Type / Shape / Fill Content / Smart Object took only the active
layer, so right-clicking inside a multi-layer selection converted one layer. Without a
`layer` param they now rasterize every selected layer they apply to, in one history step.
An explicit `layer` (the "Rasterize?" paint prompt, scripts) still targets exactly one.

Layers panel: a multi-layer selection gets one "Rasterize Layers" item (greyed only when
nothing selected can be rasterized, judged by the whole selection, not the clicked layer).
Edit Contents and Convert to Layers act on one layer, so they are not offered there.
Adobe's documentation: select "the layer or layers you'd like to rasterize".

Tests: engine integration (mixed type/shape/fill/smart selection, one undo step, per-kind
commands, explicit layer, nothing to rasterize, bad targets) and ui-egui (menu entries,
enablement, right-click → Rasterize Layers end to end). The new behaviour tests fail on
the old code.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-10 03:55:43 -04:00
5630e112b4 Layer style colours keep the document's mode (Gray, CMYK) (#2103)
In a Grayscale or CMYK document, effect colours (overlays, shadows, glows,
strokes, satin, bevel, gradient stops) were stored as RGB and drawn
unconverted: a red Color Overlay in a Gray document was red. Effect::in_mode
converts them where effects are written: the effect commands, the Layer
Style dialog (replace), Paste Layer Style and style presets.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-09 19:20:10 -04:00
kayrockscreenprintingandClaude Opus 5.5 0493d06aaa Edit › Stroke: whole opaque pixels, Center like Photoshop, and strokes on the canvas edge (#2025)
* Edit > Stroke: whole opaque pixels; Center puts the odd pixel inside; canvas edge strokes

Center expanded and contracted the selection by w/2, so odd widths left
half-transparent pixels on both sides (a 1 px stroke was a 2 px band at
half opacity), and a selection edge on the canvas border got no stroke
(Select All, Stroke Inside drew nothing). As in Photoshop, widths round up,
Center puts ceil(w/2) inside and floor(w/2) outside, and the mask is padded
so the canvas border is a selection edge.

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

* Edit > Stroke: a hard-edged selection strokes hard, matching Photoshop's curves

Photoshop's 6 px Outside stroke of a 14 px circle selection made without
anti-aliasing is reproduced exactly when a pixel is stroked whenever its
centre is less than width + 1 from a selected pixel's (our expand gave the
6..7 px fringe partial alpha). For a hard selection the band is hardened;
soft selections keep their soft edges.

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

* Edit > Stroke: the inside of a hard stroke matches Photoshop on curves

Photoshop's 3 px and 5 px Inside strokes of a 14 px hard circle, and its 6 px
one filling the circle, fit one rule: inside, a pixel is stroked when its
centre is less than width + 1/2 from an unselected pixel's (the outside rule
is width + 1). Straight edges are unchanged.

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 14:01:07 -07:00
Nigel Ihza FirdausyandClaude Opus 5.5 0f2900da34 Edit > Stroke: a full options dialog with Fill's blending controls (#1477)
Edit > Stroke… from the menus opens a Photoshop-style dialog (Width, Color, Location, Blending:
Mode, Opacity, Preserve Transparency) remembering its last values; called with params it runs
directly. edit.stroke gains mode and preserveTransparency, blending through Fill's compositing
(blend_color_mask), and validates them. The Blending section is shared with Fill
(dialog_blend_ui.rs). Fixes #1463.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:21:21 -04:00
kayrockscreenprintingandClaude Opus 5.5 cf8044bcb1 Equalize, Average and Clouds edit the targeted channel, not the layer (#1777)
These four ran through map_pixels, which always rewrote the active layer, so with an alpha
channel, the Quick Mask or a layer mask targeted they changed the layer and left the channel
alone. They now rewrite the targeted grayscale surface, as the core filters do. Fixes #935.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:07:43 -04:00
buivanvu94 1ddda05394 fix(engine): use grammatical article for adjustment layer error messages (#1549) 2026-10-09 13:24:03 +07:00
UnemarsandClaude Opus 5.5 6054a8a019 Crop tool: show the pixels past the canvas while a frame is edited, as Photoshop does (#1508)
A crop with Delete Cropped Pixels off keeps what it cuts away (and a moved layer can reach past
the edges), but the Crop tool drew only the canvas, so a frame couldn't be pulled back out over
those pixels by eye. Photoshop 25.4 (observed) grows the view to every layer's bounds and the
frame from the first press on the frame: hidden pixels under the shield, transparency as the
checkerboard, no canvas border or shadow, until the crop is committed or cancelled.

- crop_ui: `CropState::editing`, set by the press (before egui's drag threshold) and cleared when
  a new default frame is made (commit, Esc) or the tool is left; `shows_beyond_canvas`.
- canvas: `draw_beyond_canvas` composites only the ring between the canvas and every layer's
  pixels (`extra_cmds::reveal_all_bounds`, shared with Image > Reveal All), once per document
  state, at most MAX_TEXTURE texels per side (from a downsampled proxy past that), and draws it
  with the checkerboard out to the frame under the canvas, reaching a point under its edge so the
  edge's anti-aliasing has no seam. The drop shadow and the Line border are off meanwhile.
- compose: `render_reduced_rect`, the reduced render of any area of the document plane.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:12:05 +07:00
b0680fd421 Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (FILE-215-16, part of #215) (#903)
* Advanced Blending: knockout, blend as group, transparency shapes, masks hide effects (part of #215)

Blending Options > Advanced Blending now works end to end:

- doc: AdvancedBlending on every layer (knockout none/shallow/deep, blend
  interior effects as group, blend clipped layers as group, transparency
  shapes layer, layer/vector mask hides effects), saved in .pcraft.
- PSD and layered TIFF: knko, infx, clbl, tsly, lmgm, vmgm read into the
  model and written back (in place, or added when not at the defaults);
  the TIFF byte-order transcoder handles lmgm/vmgm as byte flags.
- CPU compositor (compose/src/advanced.rs): shallow and deep knockouts
  (isolated groups and clipping groups stop a deep knockout), clipped
  layers blended individually, effects over the whole layer when
  Transparency Shapes is off, effects built from an unmasked copy when a
  mask hides them, interior effects under fill opacity.
- GPU: documents using Advanced Blending fall back to the CPU compositor.
- engine: layer.layerStyle.blendingOptions takes channels, knockout and the
  five switches; inspect reports them; Copy/Paste Layer Style carries them.
- UI: the Blending Options page laid out like Photoshop's (General,
  Advanced Blending, Blend If with split sliders), translated.

psd-tools corpus oracle 229 -> 236 (seven knockout files now pass; floor
raised); other corpora and all round trips unchanged. Scorecard
FILE-215-16 -> partial.

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

* Polish catalog rows for Advanced Blending; regenerate docs/scorecard.md

pl.tsv landed on main and the coverage test needs every catalog to translate new UI strings.

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

* Undo damage: compare Layer.advanced in layer_pixels_equal; regenerate scorecard

#810's exhaustive Layer destructure must classify the new advanced-blending
field; it changes the composite, so it is compared.

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

* Regenerate docs/scorecard.md

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 20:08:03 -04:00
BFlatandClaude Opus 5.5 fa186c59b9 Paste into a targeted layer mask or channel instead of a new layer (#1035) (#1078)
With the layer mask targeted (its thumbnail clicked, or Alt-clicked to view
it), Edit > Paste, Paste in Place, Paste Into and Paste Outside now paste
the clipboard's luminosity into the mask, placed as a paste places it, over
the mask's values (the pasted pixels' transparency, and the selection for
Paste Into / Outside, let the mask show through). One history step, no new
layer, at 8, 16 and 32 bits. A targeted alpha channel or Quick Mask takes
the paste the same way, as in Photoshop.

The paste commands now follow the target like the fills and filters do
(channel_cmds::routed), and accept a "target" param.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:41:26 -04:00
reeseandBrandon Thomas 2873afdda9 fix(layers): a locked group locks the layers inside it (#847)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:18:27 -04:00
BFlatandClaude Opus 5.5 15af216bd1 Follow-ups to #665, #666, #734: Photoshop's mask-only Remove Background, one-step eye sweep, no-clone Show Only (#824)
* layer.showOnly: no document clone, keep layers added during a solo, more tests

- Compute the rows from a borrow instead of cloning the whole document, and set
  visibility by path (one walk) instead of an id lookup per layer.
- Restoring keeps the current visibility of layers the snapshot doesn't know
  (added while one layer was shown alone) instead of leaving them unspecified.
- Tests: enclosing groups stay visible and a group's contents keep their own
  visibility, an undo ends the solo, one history step, no snapshot shows every
  layer, bad layer ids and no document return errors.

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

* Layers eye sweep: one history step, no skipped rows on a fast drag

- Every layer.setProps of one eye-column drag shares a coalesce key, so the
  whole sweep is one "Layer Visibility" undo step (it was one per row).
- A row counts as swept when the pointer passed it since the last frame, so
  a fast drag that jumps a row in one frame no longer skips it.
- The sweep lives in a small EyeSweep helper instead of inline in layer_row.
- layer.setProps names a visibility-only change "Layer Visibility" also when
  it carries a coalesce key.
- The harness test also checks one undo step and a single-move drag.

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

* Layers panel: hidden effects and smart filters get an empty eye box too

Matches the layer rows (and Photoshop): an effect or smart filter that is off
leaves its eye box empty instead of drawing a struck-out eye, so the whole
eye column of the Layers panel reads the same way.

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

* Remove Background: Photoshop's mask-only Quick Action, reusing Select Subject's sampler

Photoshop's Remove Background is a Properties (and contextual task bar) Quick
Action that analyses the selected layer and adds a layer mask from Select
Subject; it has no Layer menu item and no destructive variant.

- No menu path (drops the Layer › Remove Background placement); it stays
  reachable from Quick Actions, the command palette, the CLI, the control
  channel and MCP.
- Always a layer mask: drop the `output: "transparency"` bake and the `fill`
  layer (both are existing commands: Layer Mask › Apply, New Fill Layer) and
  the `radius` knob.
- Analyse the layer alone by default (`sampleAllLayers` false), as Photoshop
  does for the selected layer; true samples the composite.
- Reuse Select Subject's sampler: smartselect_cmds gains `with_doc_sampler`
  (what `with_sampler` now calls) instead of a second CompositeSampler and
  find/refine helpers.
- Promote the Background only once a subject is found.
- Tests: mask at 8/16/32-bit with untouched pixels, one history step,
  undo/redo, Background promotion, no subject, layer-alone default vs
  sampleAllLayers, disabled states and bad params, replacing a mask.

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

* Korean labels for Relight's Ambient and Warmth (i18n coverage test red on main after #735)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:25:22 +07:00
d5923e49db layer.showOnly: ⌥-click a layer's eye to show only that layer (#665)
New engine command `layer.showOnly {"layer"}`: hides every other layer
(keeping the layer's enclosing groups visible); run again on the same
layer, it restores every layer's previous visibility, layers that were
already hidden included. ⌥-clicking another eye while one layer is shown
alone moves the solo and keeps the original snapshot. The snapshot is
per-document view state (`DocState::show_only`), not history.

The Layers panel sends ⌥-clicks on an eye to it; plain clicks still toggle.

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 15:59:29 -04:00
Multipad-cyberandMultipad-cyber 8713e26f5a Background layer: double-click, masks and cut behave like Photoshop (#266) (#266)
Co-authored-by: Multipad-cyber <246280291+Multipad-cyber@users.noreply.github.com>
2026-10-06 07:43:37 -04:00
Brandon ThomasandClaude Opus 5.5 4218d07f40 Never crash: the engine is panic-free (Rule 9) (#54)
Every unwrap/expect/unreachable in non-test engine code now returns an
EngineError (or a predicate's Err string) or uses a real fallback:
- enabled predicates read the active layer through active_layer_of()
  instead of unwrap() after has_layer();
- new raster layers get their pixels through pixels_mut()? instead of
  surface_mut().expect("raster");
- layer paths use split_last().ok_or(..)? (Merge Down, Arrange, Ungroup,
  Release Clipping), Auto-Align/Auto-Blend/Photomerge carry placements
  instead of re-unwrapping them, and "next free name" searches are bounded;
- web export builds its options map directly, quantize_rows has a wasm
  tail, parse_script rejects non-array/object JSON, the shape-layer edit
  returns an error instead of unreachable!, and the gray composite
  profile falls back to sRGB.
The engine denies unwrap/expect/panic/unimplemented/todo/unreachable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:26:26 -04:00
Brandon ThomasandClaude Opus 5.5 f7eb6ea9e7 Format: adopt the craft-family rustfmt.toml and format the workspace
CI's `cargo fmt --all -- --check` has failed on every run because the
repo had no rustfmt config and was never formatted. Use the same settings
as EffectCraft/FilmCraft (max_width 160, use_small_heuristics Max), which
also keeps the churn small. Formatting only; no code changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:31:17 -04:00
Brandon Thomas 536a7346fb more algorithms, progress 2026-10-01 08:25:06 -04:00