Commit Graph
958 Commits
Author SHA1 Message Date
Valentin KleinandClaude Opus 5.5 3ba0e9419c Color Picker: CMYK foreground fills a CMYK document with the picked inks (#4196) (#4339)
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>
2026-10-12 19:39:55 -04:00
Marco Arnulfo a1fbfd9a6c Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (#4333)
* Filters on a type layer: ask to rasterize or convert to a Smart Object instead of greying out (fixes #4303)

* Filters on a type layer: Liquify, Filter Gallery, Camera Raw, Adaptive Wide Angle, Vanishing Point and Last Filter ask too (fixes #4303)
2026-10-12 19:30:58 -04:00
David MannandClaude Opus 5.5 6c0dcf428b Color Table: keep the transparent index inside the palette (#4186) (#4187)
Replacing the colours could shrink the table while the transparent index
still pointed past its end, so the palette PNG export refused the table.
A stale index is now dropped (pixels end up as with "transparent": null),
and an explicit invalid index (out of range, negative, non-integer) is a
BadParams error before any edit instead of silently keeping the old one.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 19:24:17 -04:00
61c52c08db Brush Settings: toggle switch rows without losing smoothing settings (#4463)
Noise, Wet Edges, Build-up, Smoothing and Protect Texture toggle from
anywhere on their row while keeping the selected controls open. Keep
lock clicks independent and expose each switch as one accessible checkbox.

Give Smoothing a default-on enable flag so disabling it preserves the
amount and modes. Old brush JSON stays compatible; toolbar amount edits
and explicit stroke overrides enable it, and ABR imports retain disabled
smoothing values.

Cover row, checkbox, lock and keyboard gestures, old section selections,
command replay, presets, legacy JSON and 8/16/32-bit rendering.

Fixes #4283

Co-authored-by: augrclk <217665348+augrclk@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 19:23:17 -04:00
ae72084a9c Resolve every pattern name before a batch delete removes any of them. (#4132)
* Resolve every pattern name before a batch delete removes any of them.

A missing name used to delete the earlier presets and then return an error. The pattern library is not on the document undo stack, so that partial delete could not be undone.

Fixes #4103

* Pattern batch delete: reject non-string entries and resolve against the library only

A document-only pattern resolved through pattern_cmds::resolve, so the batch
deleted earlier entries before pattern.delete refused it. Non-string array
members were silently dropped (ported from #4127).

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 19:19:23 -04:00
Fabio Lissi 90c42f5555 Map Place, Open, Image Size in percent, the Paint Bucket and Insert Conditional (#219) (#4470)
- image.imageSize takes percent (both sides; an explicit side wins).
- actions.conditional {if, then, else}: Photoshop's Insert Conditional as a step, 33 conditions
  on the document (shape, mode, depth, selection, layers, alpha channels, unsaved) and the
  active layer (kind, locks, visibility, masks, effects); a failing branch stops the caller.
- .atn events: placeEvent → file.placeEmbedded/placeLinked (path, scale), open → file.openAs,
  imageSize in percent, fill from a point → paint.bucket (pixels), conditional. 105 → 108.
- Panel labels conditional steps as 'If cond → then / else'.
2026-10-12 19:12:35 -04:00
Fabio Lissi 13f5c104f6 Map the remaining frequent Photoshop action events (#219) (#4465)
- edit.transform gains the relative form (scaleX/scaleY %, rotate, dx/dy about the frame
  centre), as a Photoshop action records a Free Transform; image.applyImage takes a source
  layer by name.
- .atn events: transform (layer → translate or relative transform; selection →
  transformSelection), applyImageEvent, export (Save for Web, with the txtu path record
  decoded and written back; kept verbatim without a destination), newPlacedLayer /
  placedLayerEditContents / updatePlacedLayer, close with the Save answer → save + close,
  delete of the target channel → layer mask delete, Quick Mask on/off, polygon selections →
  select.lasso, Gradient Map (direct and as an adjustment layer). 92 → 105 events.
- Export tries every event listing a command id (Quick Mask on → set, off → clearEvent).
- Docs: event counts in the parity docs.
2026-10-12 19:02:15 -04:00
bbfaefd5f0 File Handling: wire Maximize PSD and PSB File Compatibility (#204) (#3955)
The preference was stored but read nowhere, so Edit › Preferences hid it.
Never now writes Photoshop's white placeholder instead of the merged image
and records has_real_merged_data = false in resource 1057, without
rendering the composite: smaller files and faster saves. Always and Ask
When Saving keep the real merged image, as Image Previews already does
with Ask. A file without layer records always keeps its real merged image,
and layered TIFF still renders the composite it is made of.

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 19:00:03 -04:00
Fabio Lissi 9862dfbb09 Actions panel: set folders, dialog toggle, Insert Stop (#219) (#4456)
* Actions panel: set folders, dialog toggle column, Insert Stop (#219)

- Engine actions_edit_cmds: actions.insert (any command at a position; Insert Stop… uses it
  with actions.stopMessage), actions.dialog (the per-step dialog flag), actions.assignSet.
  Flag sets follow inserted steps; nothing edits during playback.
- Panel: loose actions first, then each set as a collapsible folder row with its count
  (ui.actions.collapsedSets); dropping an action among another set's rows moves it there;
  Save Set… on a folder's context menu; a dialog toggle column next to the check marks;
  Insert Stop… in the panel menu with a small message/Allow Continue dialog.
- Shell playback pauses on a dialog-toggled filter or adjustment step: the command's dialog
  opens seeded with the recorded values; OK runs the dialog's values and resumes from the next
  step, Cancel stops. Agents play headlessly. The play result reports paused: {step, id, dialog}.
- Docs: control protocol, scorecard AUTO-219-2 done, gaps G11.

* Translate the Actions panel's set, dialog-toggle and Insert Stop strings

* Resume a paused action only after the dialog's background job lands

A filter confirmed from a dialog runs as a background job in the desktop app; the resumed
steps met a locked document and failed. Wait for the job first (test covers background jobs).
2026-10-12 18:51:53 -04:00
049a92c288 feat(engine): recolor vector shape layer fill with Alt+Backspace and Cmd+Backspace (#3925)
* feat(shape): recolor vector shape fill via Alt/Cmd+Backspace fill keys

Photoshop fill keys (Alt+Backspace for foreground and Cmd+Backspace for background, as well as their Shift variants) now recolor the active vector shape layer's solid fill when targeting a shape layer, matching Photoshop 1:1 instead of erroring out with 'active layer is a Shape layer, not a pixel layer'. Channel and mask targets continue to be routed to edit.fill. Includes undo support, locked layer checks, and regression tests.

* Fill keys: keep Fill from History's own enablement after merging main; rustfmt

edit.fillHistory joined the shared fill-key IDS on main and shares their
enabled check, which now also accepts shape layers. Give Fill from History
edit.fill's check so it isn't enabled where it can only fail, and limit the
locked-shape test to the four colour keys.

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 18:40:05 -04:00
Fabio Lissi 3204231d7d Load and save Photoshop Actions (.atn) (#4356, #219) (#4434)
* Load and save Photoshop Actions (.atn) (#219)

- photocraft-psd: `atn` parser and writer for version-16 action sets, exact round trip,
  truncation/garbage safe, fuzz target and `atn_dump` example. Descriptor fix: `indx` and
  `Idnt` reference items carry a class before the number (real action files failed to parse).
- photocraft-engine: `atn_cmds` maps 92 Photoshop events to commands and back (filters,
  adjustments per channel, image/canvas/crop/trim/rotate/flip/mode, make/set/select/delete/
  hide/show/move/duplicate, fill/stroke, selection, clipboard, purge, profiles, play, stop).
  Other steps load as `actions.photoshopStep` (descriptor kept, written back by save, fails at
  playback by name); tool selections load switched off. Commands `actions.load`,
  `actions.save`, `actions.stopMessage`, `actions.photoshopStep`. `Action` gains `dialogs`
  and `set`. `layer` params accept a layer name, so loaded steps replay in any document.
- UI: Actions panel menu › Load Actions… / Save Actions…; Photoshop steps labelled.
- CLI: `batch --actions set.atn`. Automation policy covers the new path params.
- Docs: file-format parity (.atn R/W), gaps G11, target-app parity, scorecard AUTO-219-2
  partial, development guide.

* Translate the Actions panel's Load/Save strings in every catalog

The eight new panel strings (Load/Save Actions…, the loaded/saved counts, the kept-step label)
were missing from all 19 catalogs; the languages that were complete are back at 100%.

* Show the Actions entries in the default dock's panel menu

The legacy dock renders its own group menu and only knew the Layers and Swatches entries, so
Load Actions… and Save Actions… were missing there.
2026-10-12 18:39:57 -04:00
zenjabbaandzenjabba dd992c7e57 fix(color): validate RGB working profiles atomically (#3924)
Co-authored-by: zenjabba <679864+zenjabba@users.noreply.github.com>
2026-10-12 18:34:59 -04:00
7f8b5816ca Use whole group bounds for transforms with active selections (#3970)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 18:30:20 -04:00
UnemarsandClaude Opus 5.5 8cd6f16b88 Merge origin/main d43db3e1 into content-aware-workspace
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 21:11:34 +02:00
skylar taylor-barrick 5fe4d09478 Merge current main while preserving craft-print transport contracts 2026-10-12 16:17:51 +02:00
fd21e73411 Format: check pattern sizes on load (#3478) (#3484)
* Format: check pattern sizes on load (#3478)

A .pcraft pattern's width/height came straight from the manifest, so a
1 KB file could declare a 65535x65535 pattern without tiles; painting it
allocated width*height*16 bytes (abort) and the engine's u32 size math
overflowed.

- format: reject patterns 0 px on a side (Corrupt) or above the PSD
  reader's 30 000 px edge (LimitExceeded).
- doc: Pattern::pixel_count / is_paintable with MAX_PAINT_PIXELS (2^26,
  a 1 GiB f32 RGBA working copy).
- compose/gpu: patterns too large to paint paint nothing instead of
  allocating (Tile::new_in, pattern_rgba, PreparedPatterns, GPU plan).
- engine: brush.texturePattern refuses them; average_color uses u64 math
  and a bounded corner.

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

* gpu: skip unpaintable patterns in the Bevel texture too

The Bevel & Emboss texture element uploaded the pattern through
pattern_rgba, which now returns no pixels for a pattern too large to
paint; filter it like the other GPU pattern lookups.

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 10:06:16 -04:00
HEOJUNFOandClaude Opus 5.5 bb17dd555c Color: convert input-only embedded profiles to the working space on open (#3876) (#3900)
An iPhone photo embeds Apple's Wide Color Sharing Profile, an input-class
profile with only AToB tables. Preserve kept it as the document's space, so
every composite into the document failed with "RGB profile without
matrix/TRC or BToA tags". Such a profile can't be a destination, so open
converts from it to the working space whatever the policy (Off still
discards it) and the report says inputOnly.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:57:11 -04:00
7958dc63fb Layers: Merge Group for a selected group (#3832) (#3888)
* Layers: Merge Group for a selected group (#3832)

With a lone group selected, Layer > Merge Layers (Cmd+E) reads Merge Group and the
Layers panel's right-click menu offers Merge Group instead of Merge Down, as in
Photoshop. The new layer.mergeGroup command flattens the group's visible contents
into one pixel layer in its place, named after the group, keeping its opacity, blend
mode (Pass Through becomes Normal), clipping and locks. Before, Cmd+E on a group ran
Merge Down and merged the whole group into the layer below it.

* i18n: Vietnamese row for Merge Group

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 09:42:58 -04:00
4010630c98 Preserve surviving channel targets and visibility on deletion (#3887)
Co-authored-by: jonathnwxh <258340642+jonathanwxh-cell@users.noreply.github.com>
Co-authored-by: Codex <noreply@openai.com>
2026-10-12 09:32:37 -04:00
76fa06dbd5 Merged and stamped layers keep their content bounds in CMYK and Lab (#3886)
Merging or stamping converts the straight-RGBA composite into the
document's colour model. Transparent black converts to K = 100% in CMYK
and neutral a/b in Lab, so those pixels no longer equal the surface's
default pixel, prune() keeps them, and the layer's content bounds span
everything that was rendered: the whole canvas for Stamp Visible, Stamp
Down and Copy Merged, and for Merge Layers, Merge Visible and Merge Down
whenever an input may draw anywhere (a fill layer, even a masked one).

Fully transparent pixels are now written as the default pixel in both
conversion paths: photocraft_compose::flatten_to_surface (stamps, Copy
Merged) and engine pixels::composite_layers (merges). Only formats with
alpha are affected; over a Background everything stays opaque.

Tests: engine merge_transparent_bounds (Merge Layers, Merge Visible over
a transparent document, with a masked fill layer and onto a Background,
Stamp Visible, Stamp Down, undo) in RGB/Gray at 8/16/32 bit and CMYK/Lab
at 8/16 bit, and a compose unit test for flatten_to_surface. The masked
fill layer test fails on main (merged layer = full canvas in CMYK).

Co-authored-by: dgliwa <2328690+dgliwa@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 09:28:37 -04:00
FinnNkandClaude Opus 5.5 8d49ecf50d feat: Photoshop's tool keys (arrows turn the brush, Shift-click new type, Space+F canvas colour) (#3725)
* feat: Left and Right arrows turn the brush tip

With a painting tool, Photoshop's Left and Right arrow keys turn the brush
tip 1 degree (15 with Shift); Right turns it clockwise. tools.rotateBrush
turns the session brush's angle by "degrees", wrapped into -180..180, and
move_mods::arrow_keys gives it the plain and Shift arrows only for the
painting tools, when no Move-tool, transform or selection nudge applies
and no type is being edited. Alt-arrows still kern type; Ctrl-arrows still
nudge the layer.

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

* feat(ui): Shift-click with the Type tool starts new text over existing type

In Photoshop a click on existing type with the Type tool edits it, and a
Shift-click there starts a new type layer instead, so text can be placed
over other text. pointer_down now leaves a Shift-click unconsumed when no
type is being edited, so it starts new text; inside the text being edited,
Shift-click still extends the selection.

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

* feat(ui): Space+F cycles the canvas colour

Photoshop's Space+F steps the pasteboard (canvas) colour through Default,
Black, Dark Gray, Medium Gray, Light Gray and Custom, and Shift+Space+F
steps back, round and round. While the Hand key is held, F does this
instead of Cycle Screen Mode; the choice is saved like picking it in the
pasteboard menu (interface.canvasColor). docs/ui-parity.md lists the new
tool keys.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:17:43 -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
cbcdeb2549 Layer styles: Photoshop's measured contour presets; Satin's contour lookup (#3803)
* fix(effects): Photoshop's measured contour presets; Satin reads contours from the far end

The built-in contour presets were hand-drawn guesses and none matched
Photoshop. They are now Photoshop 27.11's own curves, measured by running
a Satin with each preset against a Linear one (38 levels per curve,
thinned to within 1 %, straight segments).

The measurement also showed how Satin applies a contour: |c(1 - v) - c(1)|
(Cone and Cone - Inverted Satins are identical; a Gaussian Satin is 0.36
at 0.5 while the Gaussian on a glow is 0.64). New effects::satin_lut, used
by the CPU and GPU satin paths.

Probe results vs Photoshop (max level error): Satin with every preset
<= 3 (Cove - Shallow 9), was up to 128; Outer Glow Gaussian 38 -> 2,
Ring - Double 121 -> 15, Cone 48-52 -> 4-7; bevel Contour Cone at 100 %
range 27 -> 12. Corpus oracles unchanged (their stored curves are
symmetric). Gloss Contour is still off: that is the bevel's gloss model,
not the presets.

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

* Layer style UI test: the Contour Editor on the measured Cone

The measured Cone preset has 18 points, all inner ones corners, so the
editor test moves point 1 between its neighbours and checks the corner
flags by position instead of the old three-point shape.

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 08:52:57 -04:00
bbe77354e0 Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, measured (#3815)
* Camera Raw: Whites, Blacks, Shadows and Highlights closer to Photoshop's

Measured as a black box on Photoshop 25.4's Camera Raw Filter.

Whites and Blacks are global curves of the grey level: the measured rows
per slider value, Whites first, after Shadows and Highlights. The
clipping directions adapt to the image's black and white points
(Whites up to 2.7 times stronger on a dark image, Blacks at least 1.2).
On ten public-domain photos the mean error against Photoshop drops from
4.2-36.8 to 0.9-8.9 levels of 255.

Shadows and Highlights are a local Laplacian filter (Paris et al. 2011,
fast form of Aubry et al. 2014) on the log luminance, the method Adobe
credits for process version 2012. Shadows raises pixels darker than
their surroundings by an amount set by their own and their
surroundings' tone, so big edges keep no halo, and keeps the 99th
percentile; Highlights pulls pixels brighter than their surroundings
toward them and keeps the 1st. The strengths are fitted to the photos
(mean error 2.8-9.4 levels, was 3.7-14.3); Photoshop's exact operator
was not recovered.

The tone stages decode light with the document's curve: filter.cameraRaw
stores encodingGamma (1.8 for ProPhoto, where raws open).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Camera Raw Shadows/Highlights: no contour lines on smooth gradients

Twelve reference values over a raw's whole range left more than a stop
between them, and the remapping changes within half a stop: coefficients
interpolated across that gap drew contour lines in out-of-focus areas
(Shadows +100 on a Nikon D4 raw). References are now an eighth of a stop
apart over the image's tones (the deepest 0.1 % share the lowest), on a
copy at most 1600 px across whose change comes back to full size by a
guided upsampling, so a 16-megapixel image takes about 2 s per slider and
the preview matches the result.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Camera Raw on raw opens: Shadows/Highlights in linear light, D4 look and black point as Photoshop

Measured black-box on 29 Nikon D4 NEFs opened through Camera Raw in Photoshop 25.4 (sliders at
+-100 and +50, exposure +-1) against our own linear development of the same files.

- Shadows and Highlights of the open-time dialog now act while developing, in linear light before
  the tone curve, as Camera Raw does for raw files (exposure -1 gives the same gains). The log2
  gain is slider/100 * C(B - anchor): B is a guided-filter base of the log2 luminance on a copy
  1232 px on its long side, the anchor is image-adaptive (Shadows: log2 power mean p=1.5 of the
  linear luminance; Highlights: mean log2 luminance). New module photocraft_raw::tone; the
  preview undoes the camera curve to show the same (photocraft_raw::retone).
- The D4 profile copies Camera Raw's default rendering: look matrix folded into the
  ForwardMatrix, per-channel curve, and an adaptive black point (0.33 x the 0.1th percentile of
  the luminance, Sensor::black_point).

Against Photoshop on the 29 frames (1/4 size, log2 Y): default rendering median difference
0.29 -> 0.04 stop; correlation of the slider's effect Shadows +100 0.96, -100 0.91, Highlights
-100 0.87, +100 0.85 (the previous filter model: 0.68 and 0.55 on DSG_3464).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs: the open-time Camera Raw re-develop covers Shadows and Highlights

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Camera Raw raw-stage Shadows: keep shadow contrast as Photoshop does

The user saw Photoshop's shadows sink deeper with more contrast. Measured on the 29 D4 NEFs:
with Shadows +100 our shadow detail had 0.79 of Photoshop's local contrast and shadows sat 0.38
stop lighter. Two causes:

- The black point now comes before the Shadows / Highlights gain, as in Camera Raw (lifting
  first let the lift wash out the black point's deep-shadow contrast).
- The guided upsampling's epsilon goes from 0.01 to 1 stop^2: the change follows strong edges
  but stays smooth inside texture (with 0.01 it followed every detail and flattened it). Near
  strong edges the effect still matches Photoshop as well as elsewhere.

Shadows +100: shadow detail 0.79 -> 0.91, shadow bias +0.38 -> +0.16 stop; effect correlation
Shadows -100 0.906 -> 0.946, Highlights -100 0.870 -> 0.886, others unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Camera Raw on raw opens: Clarity, Texture and Dehaze in linear light, as Photoshop

Measured black-box from Photoshop 25.4 opening 29 Nikon D4 NEFs through Camera Raw (descriptor
keys Cl12, CrTx, Dhze; each at ±100 and +50, and +100 at exposure −1, 348 opens). All three act
before the tone curve: the change at exposure −1 equals the one at 0 (correlation 0.98–0.99).

- Clarity: log2 luminance split into bands between guided-filter bases (radii 2, 8, 32, 128 on
  the 1232 px work copy), each band scaled by a measured gain that follows its base's tone, plus
  a tone term; carried to full size with the Shadows / Highlights change.
- Texture: Gaussian bands σ 0.5…16 on a half-size copy, each through its measured response
  (small detail raised more than strong edges, flat areas unchanged), guided-upsampled.
- Dehaze: He, Sun and Tang's dark channel prior on linear ProPhoto (dark channel in Display P3,
  airlight from the brightest 0.1 % of it, guided-smoothed transmission), w 0.8 at +100;
  −100 a uniform veil. The black point stays the default rendering's.

Against Photoshop, PhotoCraft's develop vs Photoshop's open on all 29 frames (median effect
correlation, log2 luminance at 1/4 size, Texture at 1/2): Clarity +100 0.81, −100 0.85;
Texture +100 0.85, −100 0.88; Dehaze +100 0.61 (0.78 in gamma 2.2: the crushed shadows
differ), −100 0.96. Shadows / Highlights unchanged (0.964 / 0.886).

The open-time Camera Raw dialog re-develops for them and its preview shows them (retone).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-12 08:52:45 -04:00
6c7328b709 Workspace: wire Auto-Collapse Iconic Panels (#204) (#3880)
* ui: a panel expanded from its collapsed strip auto-collapses on an outside click

* ui: test the Auto-Collapse Iconic Panels peek open and outside close

* engine: show Auto-Collapse Iconic Panels in Preferences, it is wired

* docs: scorecard, settings that do nothing 30 -> 29 of 154

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:52:32 -04:00
Diego Chagas de22bca760 Remove tool: Create a new layer, and Remove after each stroke off (#1750) (#3808)
paint.remove takes several strokes at once (`strokes`) and fills them as one job and one undo
step, so the fill sees every hole together; and `newLayer` writes the fill to a new layer above
the target, only where the hole is, leaving the target as it was (with Sample All Layers it
fills from the composite). The options bar shows Photoshop's three options; with Remove after
each stroke off, strokes collect as a tint on the canvas until Enter or the ✓ applies them and
Esc or ⊘ drops them. Strokes are dropped when the tool or document changes under them.
2026-10-12 08:51:56 -04:00
Jakub 286ff73533 perf: edits, undo and redo recomposite only where added or removed layers draw (#3847)
Session::edit reported damage only for edits that changed no pixels (same_pixels) and left
every other edit to recomposite the whole document unless the command reported its own
damage. Duplicate and paste do (#2883, #2901), but Fill, Invert, filters, Delete Layer and
the undo/redo of a duplicate, a paste or a delete all recomposited everything.

- Session::edit now reports step_damage(before, after), the same bound undo and redo use.
- step_damage bounds layers only one side has in both directions, so a removed layer counts
  where it drew like an added one; the bounded_additions flag and insert_damage go, and
  added_bounds becomes one_sided_bounds. A layer that is (or was) the clipping base of
  clipped layers above it, an adjustment, a video layer or one excluding channels still
  recomposites everything (#3090).
- The subject-mask cache is dropped only when the step can have changed pixels.

perf_scenarios on the layered 20 MP document, edits on a 1 MP layer (p50, RTX 3090, Vulkan):
delete layer 9.1 -> 1.4 ms, undo a duplicate 9.3 -> 1.6 ms, fill a 500 px square
17.4 -> 5.5 ms, Invert 16.7 -> 9.2 ms. Duplicate and paste are unchanged (already bounded).

Tests: duplicate_paste_and_delete_report_where_the_layer_draws checks that each step, its
undo and its redo report the same bounded damage and that no pixel outside it changes.
new_layers_that_change_clipping_recomposite_everything (renamed) now expects bounded damage
for a painted or empty new layer and None for a new clipping base or an adjustment; deleting
and restoring a layer reports its bounds.
2026-10-12 08:47:05 -04:00
Scott C Hughes 3cc0d48410 fix(engine): Flatten All Layer Effects keeps layer and vector masks (#3804) (#3835)
Flatten All Layer Effects baked the layer (including its masks) into the pixels and then cleared mask and vector_mask, destroying unrelated mask pixels and their enabled/disabled state permanently after save. Bake the effect visuals with the masks left off the render and keep the original mask and vector_mask objects, so each mask still applies once to the baked raster and the command stays a visual no-op.

Tests: cargo test -p photocraft-engine --lib file_cmds; cargo test -p photocraft-engine --lib; cargo clippy -p photocraft-engine --all-targets (with the two pre-existing lints allowed); cargo test -p photocraft-engine --test panic_hunt -- --ignored
2026-10-12 08:46:04 -04:00
MrCloudy2andClaude Opus 5.5 04d2874e38 fix(image): 32 Bits/Channel holds linear light, as in Photoshop (#3792)
PhotoCraft treats 32-bit documents as linear light (Transfer::for_document,
Exposure, Photo Filter, PSD import), but Image > Mode > 32 Bits/Channel only
rescaled the samples, so a converted 8-bit grey 128 stayed 0.502. Photoshop
stores 0.2159 (measured in Photoshop 27.11: 32-bit float TIFF of an sRGB
chart, every sample = the sRGB-to-linear value). The conversion now decodes
colour samples through the document's tone curve after widening to 32 bits,
and encodes them before narrowing (so no linear value is quantised to 8 or
16 bits). Alpha, masks and alpha channels keep their values. RGB and
Grayscale only (the 32-bit modes).

With it, Photoshop's 32-bit results match exactly for the conversion itself,
Levels, Black & White (default), Brightness/Contrast and Colorize (were 40-80
levels off in sRGB terms).

Adds Surface::map_color (colour samples only, including the default pixel)
and makes Transfer::decode/encode public.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:45:48 -04:00
Reynold Hu dcc9f86aa5 Workspace: wire Enable Narrow Options Bar (#204) (#3870)
* engine: expose workspace.enableNarrowOptionsBar through a Workspace helper

* ui: the options bar honors the narrow options bar preference

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:43:48 -04:00
Reynold Hu b0554b8178 Type: wire missingGlyphProtection (script fallback toggle) (#3863)
* text: missing glyph protection can turn the script fallback stack off (#1909)

* engine: apply type.missingGlyphProtection to the live text engine

* docs: scorecard, settings that do nothing 30 -> 29 of 154
2026-10-12 08:34:22 -04:00
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
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
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
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
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
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
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 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
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
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
Brandon ThomasandClaude Opus 5.5 62346f7fb6 feat(save): File › Save keeps a JPEG's quality and a WebP's lossy/lossless encoding (#2399) (#4032)
Opening a JPEG now estimates the quality it was saved at from its quantisation
tables (the Annex K tables of ITU-T T.81 scaled by the IJG quality curve; exact
for such tables, the closest fit otherwise). Opening a WebP records whether it
is lossless (VP8L) or lossy (VP8), and a lossy one's quality from its base
quantiser index. The settings live on the document (`Document::file_encoding`,
saved in .pcraft) and a save that asks for no quality writes the file back the
same way, as Photoshop's Save does: File › Save, Save As and Save a Copy start
there instead of the fixed codec defaults (quality 90, WebP always lossless).
An explicit quality still wins; new documents and other formats keep the
defaults. Hostile or truncated DQT/VP8 headers give no estimate, never a panic.

Refs #2399

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:45:50 -04:00
Reynold Hu e79d6571ba Actions: replay tests for crop, filters, selections and styled text on a second document (AUTO-219-4) (#3566)
* test(engine): a recorded edit run replays on a second document

* docs(scorecard): mark AUTO-219-4 done; the replay tests cover all four families
2026-10-12 05:36:39 -04:00
a1a7b17ee0 fix(engine): a PSD type layer names the installed font, not an invented one (#3556) (#3564)
* fix(engine): a PSD type layer names the installed font, not an invented one (#3556)

A type layer created with a font by family name stored no PostScript name, so PSD export
invented one ("<Family>-Regular", spaces removed). Photoshop looks type up by PostScript name,
so it showed the font as missing and substituted Myriad Pro as soon as the text was edited.

Resolve the family's installed face (matching weight/italic) from the font database when the
family, style, weight or italic is set, and store its real PostScript name; an explicit
postscriptName still wins. A family PhotoCraft does not have keeps no name, so the export falls
back as before.

Test: a_family_font_gets_the_installed_postscript_name; the existing named-face test now expects
the resolved name.

* Type tool test: a family change names the installed face (#3556)

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 05:32:03 -04:00
Valentin KleinandClaude Opus 5.5 9f49cae093 Move: with an unlinked layer mask targeted, the Move tool moves the mask alone (#3604) (#3611)
layer.translate takes target=mask: a layer whose mask is unlinked then shifts that mask only
(the shell adds the target when the Layers panel targets the mask), live preview included.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:04:13 -04:00
xsvm 6d21a69968 Brush: symmetric live strokes update only what changed (#3593) (#3605)
With painting symmetry on, every pointer event cloned and finished the stroke and each mirrored
pass, merged them again, composited the whole stroke and restored each pass's full bounds, so an
event cost grew with the area painted so far. StrokeRenderer::composite_union_live merges and
composites only the coverage tiles that changed (new dabs, the smoothing tails now and before),
leaving exactly the pixels the full merge gave; a mirror joining redraws every tile once.
2026-10-12 04:53:36 -04:00
agent-346022andphotocraft-agent UNCONFIGURED 8c6e4a3abc fix(engine): image.canvasSize rejects unlisted anchors instead of landing between two (#2946) (#3572)
anchor_factors resolved the x and y axes independently and with different matching
rules (x looked for a capitalised "Left"/"Right" or the exact word, y for a lowercase
"top"/"bottom" prefix). A misspelling matched one rule and missed the other, so
"topleft" anchored top-centre and "TopLeft" left-centre -- anchors the command does not
offer -- with no error.

Match the anchor exactly against the nine documented values via one ANCHORS table;
anything else is a BadParams error naming `anchor` and listing them, as #507 settled
for other command params. The nine valid values keep the exact factors they had, and an
omitted anchor still means center.

Test covers the nine offsets, the omitted default, and the misspellings from the issue
("topleft", "TopLeft", "TOPLEFT", "bottomleft", "bottomright", "middle", "", "NOPE").

Fixes #2946.

Co-authored-by: photocraft-agent UNCONFIGURED <unconfigured@invalid.local>
2026-10-12 04:13:20 -04:00