Commit Graph
3466 Commits
Author SHA1 Message Date
25c0ec78d5 Keep CMYK ink values when flattening and merging (#3855)
Flatten Image, Merge Visible and Merge Down now composite a CMYK stack of
plain Normal raster layers in its ink values, so a lone Background flattens
bit-exact and 16-bit depth is kept. Stacks that still need the RGB
compositor separate back through the document's own CMYK profile instead of
the built-in one, and Merge Down's scratch document keeps the profile.

Fixes #3778

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:33:50 -04:00
David MannandClaude Opus 5.5 6231325aea Brushes: drag a brush folder into another folder (#2014) (#3851)
Photoshop's Brushes panel nests folders by dragging one onto another; ours
could only reorder a folder among its siblings.

- brush.presets.moveGroup takes `into: {group?, folder?}`: the group or
  nested folder (with everything in it, presets in order) moves inside that
  folder of that group (`folder` [] = directly in the group), or with no
  `group` out to the top level as a group of its own. `before` names a
  sibling folder (or top-level group) there to land before. Moving a folder
  into itself or one of its own folders, a name clash at the destination,
  a result deeper than MAX_FOLDER_DEPTH, the ungrouped presets, `index`
  with `into`, and bad shapes are errors that leave the library unchanged.
  The preset store follows the move (a group moved into another one loses
  its group file; a folder moved out gets one).
- Brushes panel and Brush Preset picker: a group or folder header dropped
  on another header goes inside it; on the header's upper third it lands
  before that folder, reordering siblings or moving a nested folder next to
  it (out to the top level before a group). Every drop is one moveGroup
  command (`folder_drop` maps the drop).

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:33:28 -04:00
bacc886a6d Preserve signed HDR colors during surface transforms (#3857)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 08:32:45 -04:00
6037ade5ed Fix straight-path anchor insertion at the hit position (#3856)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 08:32:15 -04:00
David MannandClaude Opus 5.5 684f211a72 Merge fixups: translate the History Brush source strings
i18n::tests::every_tl_literal_is_translated needs every tl! string in the complete catalogs:
the options bar hint and the History panel source column tooltip get rows in all 17.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 14:10:23 +02:00
David Mann 27f001c3fa Merge remote-tracking branch 'origin/main' into fix/history-brush-source 2026-10-12 14:10:23 +02:00
Scott C Hughes 2aa1f8d2c6 feat(engine): add history.undo and history.redo aliases (#3675) (#3786)
A `history.*` namespace exists (history.deleteState, history.newDocument) but not history.undo, so a client or agent guessing `history.undo` gets a hard `unknown command`. Register history.undo and history.redo as aliases of edit.undo and edit.redo, with no menu entry and no shortcut; the Edit menu items keep Cmd+Z / Cmd+Shift+Z.

Tests: cargo test -p photocraft-engine --lib history_cmds; cargo test -p photocraft-engine --test panic_hunt -- --ignored; cargo xtask parity.
2026-10-12 07:49:26 -04:00
MrCloudy2andClaude Opus 5.5 c5da1a0d5f Hue/Saturation: Photoshop's saturation curve, lightness order and Colorize lightness (#3781)
* fix(adjust): Hue/Saturation saturation, lightness order and Colorize as in Photoshop

Measured in Photoshop 27.11:
- Raising Saturation divides it by (1 - s): +50 doubles it. PhotoCraft
  multiplied by (1 + s), up to 26 levels off.
- Master Lightness applies first; hue and saturation then work on the
  result. Fixes the corpus file adjustments/rgb8/hue-saturation (Hue
  +40, Saturation +25, Lightness -10): 17 levels / 11.8 % bad -> 2 / 0 %.
- Colorize builds the colour at the lightened lightness, so black with
  Lightness +30 is a 30 % lightness colour, not grey (was 78 off).
- A colour range's positive Lightness lightens towards the pixel's
  brightest channel, not towards white (Blues +50 was 71 off).

All 18 Hue/Saturation probe cases are within 4 levels. Corpus: the
Colorize files go to 1-3 levels (psd-tools huesaturation_colorize_rgb
120 -> 3). CPU and GPU paths.

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

* fix(adjust): a range's negative Lightness darkens towards the darkest channel

Measured in Photoshop 27.11 (Blues -50 / -100, Reds -50): (102, 128, 204)
at Blues -50 becomes (102, 115, 154), towards the pixel's darkest channel,
not towards black. Mirrors the positive rule (towards the brightest).

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:16 -04:00
MrCloudy2andClaude Opus 5.5 836fd52089 fix(adjust): Black & White tints like the Color blend mode, as Photoshop does (#3780)
Photoshop's Black & White tint keeps the grey's luminosity and takes the
tint's hue and saturation (SetLum + ClipColor, luma 0.30/0.59/0.11), the
same as the Color blend mode. PhotoCraft mixed half the grey with twice
the grey times the tint, up to 141 levels off. Measured in Photoshop
27.11 on 9 cases (4 tint colours over a grey ramp and hue sweeps): all
within 3 levels now. Corpus: photoshop adjustments/rgb8 and rgb16
black-white-tint go from 69 levels max / 77 % bad pixels to 1-3 / <=0.5 %.
CPU and GPU paths.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:49:05 -04:00
UnemarsandClaude Opus 5.5 e35671ffa9 Shadows/Highlights: Photoshop's, measured (#3765)
Image › Adjustments › Shadows/Highlights follows Photoshop 25.4, measured as a
black box: per-channel screen curves (1 - (1 - v)^q blended over q = 1, 2, 4,
8), the Tone mask as a Beta distribution function of an 8-bit neighbourhood
brightness, an edge-aware (guided bilateral) neighbourhood, the luma of the
result with the colour differences scaled by Color, and the Midtone S-curve.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:48:55 -04:00
DennisandClaude Opus 5.5 e4e3f00f48 Camera raw colour fit: read a Sony Adobe RGB preview as Adobe RGB, not sRGB (#3760)
The per-file colour fit (`fit_preview_look`) converted the embedded JPEG to sRGB only when the JPEG
carried an ICC profile, and otherwise read it as sRGB. A Sony body set to Adobe RGB writes no profile
into its previews; it states the colour space only in the raw's EXIF (ColorSpace Uncalibrated with
InteropIndex "R03", the DCF option file for Adobe RGB). The fit then targeted a desaturated,
hue-shifted reference, and the developed raw came out that way.

`photocraft_raw::embedded_preview_color_space` reads that EXIF (sRGB, Adobe RGB or unstated; never for a
DNG, whose previews are the converter's), and `preview_profile` uses it when the preview has no ICC
profile of its own. A preview's own profile still wins. LightCraft reads the same tags for the same
reason (storytold/lightcraft#778).

Measured on 19 private Sony ILCE-1M2 ARWs, all shot in Adobe RGB, against the camera's JPEG read as
Adobe RGB (median CIE76 ΔE at 240 px): full-frame 400 mm shots against blue sky 5.6-7.0 → 1.5-2.4,
their mean a* offset +4.3…+4.9 → +0.7…+1.1; every file improved, mean of the medians 4.9 → 2.2.

Tests: `exif_colour_space_of_the_previews` (sRGB, Adobe RGB; R98, a missing interop IFD, other values,
a DNG and junk are unstated) and `a_preview_without_a_profile_takes_the_raws_exif_colour_space` (fails
without the EXIF fallback; the preview's own ICC profile wins). The hostile-input harness calls the new
function.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:48:45 -04:00
David MannandClaude Opus 5.5 81bff2aaa4 Brushes: Photoshop's texture Height/Subtract modes, Brightness and Contrast (#3340) (#3749)
Imported brushes whose Texture uses Height or Linear Height at a low depth
(common in Photoshop brush packs) painted nothing: the modes were modelled
backwards (paint only on the brightest texels, more of it with more depth).
Measured against Photoshop with single dabs over a 0..255 ramp pattern:

- Height: paint reaches 12 x depth x coverage into the pattern and fills
  what lies below it (dark valleys first; a brighter pattern takes paint
  away, which is why the reporter had to flip Brightness's sign).
  Linear Height grades the shallow end by the pattern instead.
- Subtract takes depth x pattern away (white removes the most paint);
  Color Dodge/Burn scale the pattern toward their neutral with depth.
  These now live in texture_combine; the Dual Brush keeps mask_combine.
- Brightness shifts the pattern by value/255 (not value/150); positive
  Contrast steepens by 1/(1-c/100) after the shift, negative flattens by
  1+c/100 before it. Texture::level is shared by the renderer and the
  Brush Settings swatch.
- ABR import mapped textureContrast with /50 for both signs; positive
  values are now /100, matching the model and the Brush Settings field.

Built-in Subtract presets get Invert so they look as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:48:35 -04:00
David MannandClaude Opus 5.5 b071929ecb Type: editing text keeps its ligatures and OpenType alternates (#3617) (#3747)
Photoshop keeps Stylistic Sets, Contextual Alternates, Swash, Fractions, Ordinals, Titling and
Stylistic Alternates and Ornaments only in a PSD's Txt2, not in EngineData. Import now reads them
into the runs' font features, so the layout drawn while the type is edited keeps Photoshop's
glyphs instead of falling back to the default ones.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 07:48:24 -04:00
MrCloudy2andClaude Opus 5.5 56c5fd5797 fix(effects): Satin at distance 0 draws as 1 px, as Photoshop does (#3744)
Photoshop 27.11 draws Satin Distance 0 as a 1 px offset (an edge band of
2/size), not as two copies that cancel out. PhotoCraft drew nothing. Measured
on a #808080 square with a Linear contour at sizes 5, 10 and 20; the other 12
cases already matched within 1 level. Applied to the CPU and GPU paths and
to the effect reach.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:48:14 -04:00
224cebbd8a fix(automation): warn when text falls back from a missing font (#3391) (#3630)
* fix(automation): warn when text falls back from a missing font (#3391)

* 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-12 07:48:03 -04:00
JGFandClaude Opus 5.5 edc1b1e774 View: remember Extras, Show and Snap To across restarts (#3762) (#3787)
* View: remember Extras, Show and Snap To across restarts (#3762)

View › Extras, the View › Show items (Pixel Grid, Grid, Guides, …) and
the View › Snap To targets reset to their defaults on every launch. They
are now saved in Preferences (`viewExtras`) as they change and restored at
startup, as Photoshop keeps them between sessions.

Only items that differ from their defaults are stored, so a default
changed in a later release still reaches users who never touched it. A
hand-edited file can't break them: unknown keys and non-boolean values
keep their defaults.

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

* View: rustfmt the view-extras restart test (#3762)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:47:33 -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
David MannandClaude Opus 5.5 5241773022 Merge fixups: Vietnamese History panel menu labels
main added a complete Vietnamese catalog; the panel menu's new labels need rows there too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 13:27:24 +02:00
David Mann 4a0617ce83 Merge remote-tracking branch 'origin/main' into fix/history-panel-menu 2026-10-12 13:27:24 +02:00
AlastorIRL 5cd134f353 fix(ui): wire video menu pickers/dialogs and fix Timeline Stop playback 2026-10-12 08:16:25 -03:00
3dd5fc1cdc automation: say why a command is disabled in engine.commands (#3740)
engine.commands, command.list and the MCP command_list tool now give each
row a disabledReason (null when enabled, else the failed precondition) and
needsDocument (true when the command is off because no document is open),
so an agent can tell "open a document first" from "not runnable". enabled
keeps its meaning. Both listings share one row builder in the engine.

Fixes #3664

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:57 -04:00
Valentin KleinandClaude Opus 5.5 6751a46003 Codecs: integer fast paths for Image::convert (#3717) (#3729)
U8/U16 depth changes and pure channel-copy layout changes (alpha add/drop, gray to RGB) skip the two intermediate f32 buffers. Output is bit-identical to the f32 route.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:45 -04:00
89312de597 Answer a repeat auth with "already authenticated" (#3723)
A second `auth` frame on an authenticated TCP connection fell through to
the method dispatcher and came back as an unknown method, in both the
headless serve server and the desktop control server. A shared
`security::repeat_auth_reply` now answers it with `already authenticated`
(keeping the request id) and the connection keeps serving.

Fixes #3668

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:13:32 -04:00
Nicolas Falesy 0c2ca488ae fix(text): scale faux bold dilation with glyph scaling (#3705) (#3719)
When drawing faux-bold glyphs, the offset dilation was composed as
`Xform(ox * r, oy * r).mul(&glyph)`. Because `Xform::mul(o)` applies `o`
first (`self ∘ o`), the translation offset was added after the glyph's
scaling transform, leaving faux bold dilation unscaled by horizontal
and vertical font scaling.

Applying the glyph transform to the offset transform (`glyph.mul(&offset)`)
ensures dilation is performed in font-local coordinates and scaled
proportionally with anisotropic glyph scaling, matching Photoshop.
2026-10-12 07:13:06 -04:00
e54adc250c feat(codecs): decode legacy PCX images and palettes (#3692) (#3701)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:55 -04:00
Streetblock 38236f90c2 perf(compose): allow empty vector masks in preview proxies (#3716) 2026-10-12 07:12:47 -04:00
Streetblock e156a9ed85 perf(compose): select Normal RGB kernel once per buffer (#3715) 2026-10-12 07:12:34 -04:00
Valentin KleinandClaude Opus 5.5 476f983876 Codecs: bound the TIFF IFD walk by a file-size entry budget (#3683) (#3711)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:13 -04:00
Valentin KleinandClaude Opus 5.5 7893a6e04d PSD: avoid cloning layer data when padding and presize the output buffer (#3687) (#3707)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:12:00 -04:00
Valentin KleinandClaude Opus 5.5 21f1012ce2 Codecs: refuse implausible PNG/WebP headers before allocating (#3691) (#3706)
A tiny file declaring a huge image reserved the full output buffer before any
data was checked. Add Limits::check_plausible (compression-ratio guard) and a
fallible alloc_zeroed, use them in PNG and WebP, and lower the 32-bit default
max_alloc to 1 GiB.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:11:41 -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 6547ca0b84 fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)
- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:02:10 -04:00
4705fc5980 feat(io): open Krita .kra composites as editable raster documents (#3692) (#3700)
* feat(io): open Krita .kra composites as editable raster documents (#3692)

* 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-12 07:01:20 -04:00
0dbc288667 fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias (#3672) (#3678)
* fix(algo): mesh warps filter their source when shrinking, so thin lines don't alias

warp_triangles (Edit > Transform > Warp, smart objects placed through a warp, Puppet, Lens and
Wide Angle corrections) took one Catmull-Rom sample per output pixel with no minification
filter. A large smart object placed small through a warp (a 4200x4800 design at ~1/3-1/4 size
in a shirt mockup) came out with crinkled, doubled or missing grid lines where Photoshop's render
is smooth. The affine path (transform::warp_surface) already pre-reduces below 50% for exactly
this reason ("bicubic alone aliases below ~50%"); the mesh path had no equivalent.

Each triangle now carries its inverse-map Jacobian, computed analytically from its vertices.
Where it shrinks the source, an output pixel averages nx x ny bilinear subsamples over its
source footprint, with about one subsample per source pixel per axis (anisotropic, capped at 8).
Averaging happens in premultiplied space, before alpha is divided out. Triangles that keep or
enlarge the source still take the exact single sample, so identity and unscaled warps are
bit-for-bit unchanged. The per-tile source read grows by the subsamples' actual spread instead
of a fixed pad. Per-pixel filtering is used rather than a global pre-reduce because a mesh
warp's scale varies across the surface: one reduction factor over-blurs the parts that stay
large or under-filters the parts that shrink most.

Measured with an earlier cut of this approach (square bicubic subsampling) against Photoshop
exports of real mockups (RMSE of the placed design): 1.78% -> 0.81%
(White shirt), 0.49% -> 0.29% (Pepper). A mockup without a warp is unchanged.

Test: a_shrinking_mesh_warp_is_prefiltered (8/16/32-bit). A 1 px grid every 4 px placed at
1/5 through an Arc must stay within 0.25 of its 9/16 area average. Before: 0.5625 off (solid
black pixels). After: 0.20 off, which is the limit of a ~5 px box over a 4 px period.

Perf (30 MP RGBA8 source, place_source with Arc 10, release, M-series, best of 7):
  1/3 size: 55 ms -> 87 ms
  1/1 size: 240 ms -> 239 ms (no subsampling: identical path)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* Use as_chunks for the opaque-pixel scan (clippy 1.99)

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 06:49:25 -04:00
e4298f554b Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653) (#3696)
* Free Transform: layer styles render with the transformed layer (#3653), work in progress

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

* Free Transform: a layer's style shows while the box is open, drawn from the transformed layer (#3653)

The live preview drew the moving pixels as a bare texture on a mesh, so Stroke,
Drop Shadow and every other effect vanished while the box was open (and for
type, shapes, groups and smart objects the style was baked into the texture,
cut off at the layer's bounds and stretched with the box).

A styled layer is now composited for the preview: the moving texels are
resampled through the box and the style is laid over them by the CPU
compositor's composite_with_effects, the code that draws the layer once the
transform is applied. A stroke keeps its width when the box is enlarged and a
shadow keeps its direction when it is turned, as after the commit.

The composite is made at screen resolution (never finer than the document),
with the style's sizes scaled to match, within a pixel budget from a measured
cost per effect: about 30 ms per rebuild while dragging and 120 ms at rest.
It is rebuilt only when the box, the zoom or the layer changes.

Still plain: the selected part of a styled layer, and Warp mode.

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

* Merge fix: Warp on type reads the texels as the session did (bare when the box draws the style)

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

* Format the type warp texel read

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

* Free Transform: Content-Aware Scale's preview has no styled composite

Main added a TransformPreview initializer for Content-Aware Scale (#3103) after this
branch started; give it the new field so the merge builds.

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 06:37:15 -04:00
304720b1a0 Gradient Map: edit color stops on the ramp (#3982)
* Gradient Map: edit color stops on the ramp (#2958)

* Gradient Map: keep stop removal scoped to the editor

* Gradient Map: fit selected stop controls in narrow Properties panels

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:30:14 -04:00
Scott MacdonaldandClaude Opus 5.5 590bec7998 feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:55 -04:00
Scott MacdonaldandClaude Opus 5.5 5ed758763d PSD: smart object Trnf frames the custom warp's control points, not the source rect (#3677)
A placed layer with a custom warp (warpCustom, a 4 x 4 customEnvelopeWarp
mesh) can have control points outside its source rect 0..w x 0..h. Photoshop
maps the bounding box of those 16 control points onto the Trnf quad. PhotoCraft
mapped the source rect onto it, so a mesh that spills past the source counted
the overhang twice and the re-rendered smart object came out stretched.

Measured against Photoshop's own export of a real shirt mockup (4200 x 4800
source, mesh spilling to x = -9, y = -88): before, a ~1.6% vertical stretch,
the tile offset growing from 0 to -16 px top to bottom; after, alignment is
exact everywhere and the RMSE against Photoshop is 0.49%. The bounding box of
the evaluated warp outline (rather than the control points) fixed that file
but put another at 5.4% RMSE, so it is the control-point box.

- blocks::placed_frame: the shared rule. The control-point box of a
  single-patch custom warp, else the source rect; a degenerate or non-finite
  box falls back to the source rect.
- blocks::parse_smart (and so smart_map::parse_sold): the affine maps that
  frame onto Trnf.
- smart_map::quad_points: export writes Trnf (and PlLd's quad) as the image of
  the same frame, so a spilled mesh round-trips and quad_moved keeps the
  imported Trnf as stored. Distort / Perspective placements keep the source
  rect, matching parse_smart_perspective.

Refs #579.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:38 -04:00
Scott MacdonaldandClaude Opus 5.5 2cb396d154 fix(engine): Displace smart filter ignores its mapPath displacement map (#3676)
A Displace applied to a smart object is recorded as a smart filter and
rendered by apply_smart_filters -> filters::apply_filter_to_surface, both
when it is added and on every later re-render (replace contents, toggling
a filter, transforms). That path built FilterParams with params_for alone,
which leaves Displace's map as None; only run_filter resolved `mapPath`
(through filters_ext::resolve, which needs the session). With no map,
algo's displace returns the source unchanged, so the smart filter was a
silent no-op.

apply_filter_to_surface now resolves `mapPath` through the same file read
and import the direct command uses (filters_ext::map_from_path, shared by
resolve and the new session-free resolve_map_path). A map that can no
longer be read on re-render leaves the pixels alone, as for an unknown
filter; the command still refuses an unreadable map when the filter is
added. `mapDocument` needs the session and stays unresolved on re-render.

Regression test: smart_cmds::tests::
displace_smart_filter_reads_its_map_path_on_re_render (8/16/32-bit).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:33 -04:00
Valentin KleinandClaude Opus 5.5 64a02107ba PSD: cap descriptor list/object preallocation from untrusted counts (#3636) (#3699)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:18 -04:00
Valentin KleinandClaude Opus 5.5 16341720c0 PSD: bound the zero padding of undersized pattern channels (#3652) (#3686)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:14 -04:00
Valentin KleinandClaude Opus 5.5 7c77143b97 PSD: don't preallocate knots from untrusted path length counts (#3635) (#3654)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:09 -04:00
Valentin KleinandClaude Opus 5.5 04396dd85f PSD: composite_rgba8 checks the output budget and converts planes fallibly (#3639) (#3651)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:27:57 -04:00
David Mann 9c2d6ac892 Merge remote-tracking branch 'origin/main' into docs/scorecard-stale-rows-2
# Conflicts:
#	docs/gaps.md
#	docs/scorecard.md
#	docs/ui-parity.md
2026-10-12 12:27:32 +02:00
Brandon ThomasandClaude Opus 5.5 27f56eb762 feat(compose): Lab documents evaluate Invert, Posterize, Threshold, Brightness/Contrast and Gradient Map on L, a, b (#4038)
In Lab documents Photoshop evaluates these adjustments on the Lab channels,
not on the screen RGB colour:

- Invert and Posterize act on L, a and b alike (normalised L/100,
  (a+128)/255, (b+128)/255; Invert is n -> 1 - n).
- Brightness/Contrast (modern and legacy) applies its tone curve to L only;
  a and b are unchanged.
- Threshold splits on L alone and outputs neutral black or white.
- Gradient Map is driven by L (Reverse uses 1 - L).

Adjustment layers (CPU compositor) and destructive Image > Adjustments both
go through the new `adjust::apply_in`; the GPU compositor defers these
adjustments to the CPU in Lab documents. Levels and Curves keep their Lab
records; other adjustments stay on the screen colour.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:20:44 -04:00
zenjabbaandzenjabba ce16e100b0 Expose WebP and optional Batch export quality (#3956)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 06:20:16 -04:00
McClabe be877c27d0 fix(ui): keep filter jobs polling on idle Linux (#3961) 2026-10-12 06:20:00 -04:00
Nigel Ihza Firdausy 6ca7e60cb0 fix(ui): make checkbox labels nonselectable with pointer cursors (#4002) 2026-10-12 06:19:44 -04:00
Nigel Ihza Firdausy 73520f8681 fix(ui): Open layer color choices from the visibility icon (#4001)
* fix(ui): offer label colors from the layer eye icon

* fix(ui): offer label colors from the layer eye icon

* test(ui): cover layer eye color context menu

* style(ui): keep extracted color menu indentation consistent
2026-10-12 06:19:28 -04:00
MarekSpodBiedry db509dc4d9 fix(ui): show a paint-bucket cursor for the Paint Bucket tool (#3968) (#3989) 2026-10-12 06:19:13 -04:00