Commit Graph
239 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 ffe59b4247 Preferences: show Zoom Clicked Point to Center now that the Zoom tool reads it (#1480)
#1241 wired tools.zoomClickedPointToCenter, so prefs_usage's
every_preference_is_read_or_hidden fails while it stays hidden.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 19:09:47 -04:00
DWAA1660andClaude Opus 5.5 6f7192d294 Select › Modify refuses a negative radius instead of clamping it (#993) (#1274)
modify() rejected only non-finite radii and radii above the dialog's
maximum, then clamped with r.max(0.0). A negative radius therefore reported
success while leaving the selection alone (Expand, Contract) or clearing it
(Border), against the function's own rule that out-of-range values are
"refused, never clamped silently". Negative radii now get the same
BadParams error, "radius must be a number in 0..max", and the selection is
untouched.

Test: modify_commands also tries -5, -0.5 and f64::MIN for all five
Modify commands.


Claude-Session: https://claude.ai/code/session_01K3vVft4VXvvy8SpbbUceTW

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:50 -04:00
kayrockscreenprintingandClaude Opus 5.5 64db46f5ab Puppet Warp: an expansion that empties the mesh is refused, not a panic (#951) (#1410)
With at least one pin, a density or expansion that remeshed to no vertices
panicked in PuppetSolver:🆕 `locate` falls back to vertex 0 when nothing
is closer, and the union-find over an empty mesh indexed it ("index out of
bounds: the len is 0 but the index is 0"). The UI reached it through the
control channel and the options bar's Expansion field (its -50 minimum
empties a 20 px tall bar), and edit.puppetWarp reached it through
puppet_warp, where the dispatch guard caught the panic.

- algo: PuppetSolver::new binds no pins when the mesh has no vertices, and
  puppet_warp returns the source when there are no triangles. With only the
  first change the command erased the layer, since rendering keeps only
  pixels inside the mesh.
- ui-egui: a remesh that leaves no triangles is refused, as `begin` refuses
  a layer without opaque pixels. The previous density, expansion and mesh
  come back. The control channel returns the error; the options bar's
  fields snap back.

Tests in algo, engine and ui-egui follow the issue's fixture and failed with
that panic before the fix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:28:36 -04:00
ef8610adeb Fix radial blur sampling density (#515) (#658)
* Fix radial blur sampling density (#515)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d

* Bound radial blur quality and parallelize sampling

Add Draft, Good, and Best radial sampling tiers, defaulting to Good, and process sampled blur rows in parallel. Preserve compatibility with serialized filters that omit quality.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505

* i18n(ko): Radial Blur quality choices Good and Best

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>
Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-08 18:28:01 -04:00
mnmlywandClaude Opus 5.5 3ac39e1ede Content-Aware fill returns the size error for a huge selection extent instead of panicking (#963)
The automatic sampling margin was three quarters of the selection's larger side, computed
as `ext * 3` in u32 (edit.fill) or i32 (edit.contentAwareFill) before the window cap, so a
loaded document with a far-flung selection overflowed and panicked. edit.contentAwareFill
also had no window cap at all and could try to allocate a canvas-sized window.

The margin is now one shared function computed in u64 and saturated to i32, and both
commands check the window against the same 400 M-pixel cap before reading or allocating.
Content-Aware Move uses the shared margin too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 05:09:47 +07:00
0423f74b50 Paint Bucket and Gradient keep alpha on a transparency-locked layer (#1104) (#1193)
composite_area already honours lock transparency, but paint_gradient and
bucket_fill(_src) always passed false, and paint.bucket / paint.gradient
dropped the flag target_surface returns. Thread it through, as Edit > Fill
and the brushes already do.

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:25 -04:00
2425431c78 layer.delete keeps a document's last layer (#1107) (#1189)
The single-layer path removed the layer with no guard, leaving a document
with no layers and no active layer. It now refuses with the same error as
the multi-select path; the edit is discarded, so nothing changes and no
history step is recorded.

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:05:20 -04:00
278e294131 channel.merge: reject a repeated source index instead of closing an unrelated document (#1179)
Fixes #934. `channel.merge` closes its sources by current index, sorted and in
reverse, so `documents: [0, 0, 1]` passed validation and then ran `close(1)`,
`close(0)`, `close(0)`; the last call removed whatever document had shifted
into index 0, i.e. one that was never named, with its unsaved edits and
history. Repeated indices are now rejected as bad params before anything is
built or closed, and the first-source lookup no longer indexes the document
list directly.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 17:05:12 -04:00
75b6931e71 Open camera raws in Camera Raw first (Open / Cancel) (#1309)
* Open camera raws in Camera Raw first (Open / Cancel)

Photoshop opens a raw file in Adobe Camera Raw before it becomes a
document; PhotoCraft developed it with the defaults straight away and
said so in a warning. An interactive open (File › Open, Open Recent,
drag and drop, the command line) of a raw developed from its sensor data
now shows the Camera Raw dialog first, titled "Camera Raw (name)", with
Open and Cancel.

- Open re-develops the raw when Temperature, Tint or Exposure changed:
  as-shot white-balance multipliers times the filter's gains, and the
  develop exposure (photocraft_io::raw::import_raw_tuned). The other
  settings are one filter.cameraRaw step; the document starts
  unmodified. Without an as-shot white balance or without the file's
  bytes, Temperature / Tint stay RGB adjustments as in the filter.
- Cancel closes the document again.
- The "developed with default settings" note is no longer shown when
  the dialog is.
- Preference rawDefaults.openInCameraRaw (default on), the first
  visible Camera Raw Defaults setting. Automation opens (app.open, MCP,
  CLI) never show the dialog.
- "NIKON CORPORATION" + "NIKON D3200" now reads "NIKON D3200" in the
  import note.

Tests: open → set → Open re-develops (brighter, still 16-bit, one
document, unmodified); Cancel closes; unchanged settings add no step;
preference off skips the dialog; a closed document is an error.
Translations for the two new strings in every catalog.

* Camera Raw open: re-develop in the background, keep tab and view

Open re-read and re-developed the whole raw on the UI thread. In the desktop
app it now runs as a job (cameraRaw.redevelop, document locked) like the
open itself; the reply is {job, pending} and the shell finishes when it
lands. The final Camera Raw step goes through app.run (a job too) and the
document is marked unmodified once it lands. Tests and the web still run
inline.

The re-developed document replaces the old one at the same tab position
and path, and its view (zoom, scroll) carries over. New test covers the
background path with documents on both sides.

wb_gains now uses camera_raw::white_balance_gains, shared with the Camera
Raw filter and documented as an observed approximation. 'Developing {name}'
translated in every catalog.

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-08 17:04:51 -04:00
a32ba211c9 Clear, Cut and Image › Adjustments refuse a pixel-locked layer (#1103) (#1194)
* Clear, Cut and Image > Adjustments refuse a pixel-locked layer (#1103)

clear_area, destructive_adjust and auto_adjust took the layer's surface
directly. They now go through commands::paint_surface, which refuses a
pixel- or fully-locked layer with the same message painting uses.

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

* edit.cut: refuse a pixel-locked layer before filling the clipboard

copy() ran before clear_selected noticed the lock, so a refused Cut still
replaced the clipboard. Check the lock first (check_pixels_unlocked, shared
with paint_surface) and assert the clipboard is unchanged in the test.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:33 -04:00
88568297ab Show Clone Stamp source preview inside the brush cursor (#1042)
* Show Clone Stamp source preview inside the brush cursor

* Drop the Clone Stamp preview screenshots and doc page

Binary screenshots and a standalone doc page aren't wanted in-tree (review):
remove docs/clone-stamp-preview.md, the two PNGs, their licence file and the
ATTRIBUTION.md row.

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

* Clone preview: pass the command name to clone_sample and propagate its far-source error

clone_sample gained a cmd parameter and returns Result since #1165.

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-08 17:04:27 -04:00
f41eb4f70d SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215) (#1305)
* SVG opens as shape layers and places as a vector smart object (FILE-215-9, part of #215)

crates/io/src/svg.rs: usvg parses (already in the tree through egui_extras); paths become
ShapeLayer knots with the transform chain baked in, quadratics raised to cubics and every
subpath after the first joining the component so the fill rule spans the path as SVG does;
groups keep opacity and blend mode (transform-only and id-only wrapper groups vanish);
text is shaped into outline shapes with the craft fonts plus the system's; gradients become
the layer's gradient fill (stops and angle, with a warning). Clip paths, masks, filters,
pattern paints and images are rasterised by resvg into pixel layers over their bounding box
rather than dropped. Limits: 16384 px canvas, 2000 elements, 24 M px per raster, bounded
recursion; malformed input is IoError::Svg.

smart_cmds::render rasterises an SVG source at the placement's axis scale (1/16 steps,
cached per scale) and divides the placement by it, affine and projective alike; place_bytes
renders that cache up front. svg/svgz join the openable lists; book page; scorecard row done.

Co-Authored-By: Claude Mythos 5.1 <noreply@anthropic.com>

* SVG import: never read local files from <image href>

usvg's default image resolver reads any absolute or relative path in an
<image href>, so opening an untrusted SVG could embed local files
(/etc/..., ~/.ssh/...) into the document. Parse with a resolver that
only accepts data: URLs (resources_dir None, string hrefs ignored).
Embedded sub-SVGs already get no <image> resolution from usvg. The font
database (and its system-font scan) is now only loaded when the drawing
may contain text. Regression test: a PNG on disk referenced by absolute
path, file:// URL and relative path is not loaded; a data: URL still is.

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

---------

Co-authored-by: leon <claude.udder842@passmail.net>
Co-authored-by: Claude Mythos 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 17:04:21 -04:00
TiredIsa 6197270230 Preferences: reset a single shortcut or menu colour override (#988) (#1064) 2026-10-08 16:39:16 -04:00
e8850ac53a Filters respect layer locks; render filters and Fill Path refuse a pixel-locked layer (#1102) (#1195)
* Filters refuse a pixel-locked layer (#1102)

run_filter never consulted layer locks. It now refuses a pixel- or
fully-locked layer with the same message painting uses. Channel, Quick
Mask and mask targets are unchanged.

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

* Filters keep alpha under lock transparency; render filters and Fill Path refuse a pixel-locked layer (#1102)

- run_filter puts the layer's alpha back in the tiles a filter changed when
  transparency is locked (the same tile walk as restrict_to_color).
- Flame / Tree / Picture Frame take the surface through paint_surface.
- path.fill refuses a pixel- or fully-locked layer.

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

---------

Co-authored-by: prekabreki <prekabreki@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:38:59 -04:00
Nigel Ihza Firdausy 7b4e816caf Keep empty selections empty for subtract and intersect (#1190)
* fix(engine): leave empty selection empty for subtract and intersect

* test(engine): cover empty selection subtract and intersect modes

* test(engine): read selection coverage through Surface API

* test(engine): cover selection subtraction history and format regressions
2026-10-08 16:38:53 -04:00
ba222715b3 filter.cameraRaw: refuse an area too large to develop instead of overflowing (#1183)
* filter.cameraRaw: refuse an area too large to develop instead of overflowing

Fixes #962. `rgba_region` sized its buffer with `area.width() * area.height()`
in u32, so a legal 65536×65536 canvas (2^32 pixels) overflowed the product and
the command's `run` panicked; dispatch turned that into an internal error. In
release the product wraps to 0 and the first tile read would index past the
empty buffer instead.

The command now checks the area it is about to develop (canvas ∪ the layer's
content) against `MAX_RAW_PIXELS` (400 MP, ≥ 48 bytes per pixel of working
memory) and returns bad-params with the size and the limit. `camera_raw_surface`
carries the same guard so the preview and Smart Filter paths can never overflow
either; the buffer length is computed in u64.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* lens_cmds tests: move the wasm32 cfg back onto lens_correction_batch_over_files

The new Camera Raw test was inserted between the cfg and the batch-over-files
test, which gated the wrong test.

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

---------

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 16:38:15 -04:00
BFlatandClaude Opus 5.5 83936bdcee Fix #1271, fix #1316: Preferences › Interface › System Title Bar (#1344)
* Fix #1271, fix #1316: Preferences › Interface › System Title Bar

Since #986, Windows and Linux draw PhotoCraft's own one-row title bar
with its own window buttons. On tiling window managers those buttons do
nothing, and some Linux desktops look better with their own decorations.
The new System Title Bar preference (off by default) gives the window the
system's title bar and buttons back; it is read when the app starts.
macOS always uses the system's. Translated in every shipped language.

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

* Scorecard: count the System Title Bar preference

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-09 03:36:21 +07:00
UnemarsandClaude Opus 5.5 f08cffd4d1 Select › Color Range: Photoshop's Sampled Colors model, Subtract from Sample and the eyedropper on the image (#1239)
* Color Range: Photoshop's Sampled Colors model and the eyedropper on the canvas

Sampled Colors now compares colours in Lab (through the document's profile) the way
Photoshop does, reconstructed from Photoshop 2024's own masks (observation only):
per-axis distance outside the samples' Lab box in 8-bit units, a/b weighted x3, the
largest of the three, and a quadratic B-spline falloff of width fuzziness*256/255.
On 8-bit sRGB images it matches Photoshop to under one level of 255 on average
(the previous per-channel RGB distance was off by about 12). Several samples span
one Lab box, like Photoshop's Minimum/Maximum.

While Color Range is open, the pointer over the free canvas is its eyedropper:
a click picks that pixel, Shift adds to the sample, Alt subtracts (also through
ui.pointer on the control channel).

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

* Color Range: Photoshop's Subtract from Sample, click order, 8-bit Lab grid

Measured through Photoshop's real Color Range dialog (eyedropper clicks):
- Subtract from Sample reshapes the samples' Lab box per axis: inside it the
  nearer edge moves onto the subtracted value; on an edge or within the axis
  width w it moves in to the first of m + k*w/5 beyond it (k <= 5); further out
  nothing changes; an edge never crosses the other. Matches every measured box
  to +-0.05. The click order matters, so select.colorRange takes an optional
  `order` list and the dialog records it.
- The a/b centring shift of the previous change was an artefact of scripted
  samples (Photoshop's descriptor Lab scale); with the eyedropper the sampled
  colour is fully selected at any fuzziness. Removed.
- 8-bit documents compare pixels and samples on the 8-bit Lab grid, where an
  a/b step above the samples' range counts half a unit more and one below half
  a unit less (lab_range_coverage_8bit).
On Photoshop's eyedropper masks of a photo the mean error is ~0.46 levels of
255 at fuzziness 40/80 and ~0.02 at 5/10 (previous change ~0.9).

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

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:19:39 -04:00
a41800c466 fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#1010)
* fix: resolve edge-case crashes, unbounded allocations, and UI sync defects (#915, #916, #917, #918, #920, #921, #923, #924, #925, #926)

* Review fixups: drop hunks covered elsewhere, simplify shortcut conflicts

- Drop the brush preview spacing_enabled hunk (#941), the vanishing.rs
  overflow guards (#869) and the artboard_cmds.rs Custom background change
  (conflicts with #1032); ui.window.open already landed in #1133.
- Revert the Measurement Log export change: an empty selection keeps
  exporting the whole log instead of an empty CSV.
- Drop the reach().min(1024) cap: effects still sample past a clamped reach,
  which would give tile seams.
- Shortcut dialog: one pass, newly assigned shortcuts first, taking theirs
  from any holder (overrides included); unchanged overrides only clear
  defaults. More test cases (no-op OK, giving a shortcut back).

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-08 16:09:38 -04:00
yangandClaude Opus 5.5 ba4dc112bb Artboard placement, edits and export reject coordinates past the 32-bit range instead of overflowing (#931) (#1032)
Default placement added the 100 px gap to the rightmost board's right edge in i32,
editing a board subtracted its old position from the new one in i32, and exporting
negated the board's origin, so a board at an i32 extreme overflowed (a panic in checked
builds, a wrapped position or content offset in release). Rects were also built with
Rect::from_xywh, which clamps the far edge and silently shrank boards past i32::MAX.

Board rects are now built in i64 and checked, so a rect with any edge outside i32 is a
bad-params error naming it; default placement with no room on the right asks for an
explicit x; a move whose offset needs more than 32 bits is rejected unless
moveContents is false; and export reports a board at i32::MIN instead of negating it.
All checks run before the document changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:21 -04:00
BFlatandClaude Opus 5.5 f0208fd798 Move tool: layers overhanging the canvas keep their effect maps; moves copy 2x faster (#761, #128) (#893)
- A layer about the canvas's size keeps its whole effect region even where it overhangs the
  edge, so the region keeps its size while the layer is dragged and the GPU moves its effect
  maps with it instead of rebuilding the shape, distance fields and every effect each frame.
- Surface::translated moves a surface by copying encoded tile bytes tile to tile (no float
  round trip, no full-size intermediate; whole-tile moves share the tiles): 2.3x faster than
  to_interleaved + write_interleaved. Used by layer.translate, Move tool drags and
  algo::resample::translate_surface.
- perf_scenarios: Move tool drag frame rows (styled layer, full-canvas layer with and without
  a drop shadow).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:16 -04:00
yangandClaude Opus 5.5 cd03d49c34 Layer comp offsets out of i32 range are dropped with a warning, not overflowed (#1017) (#1039)
Comp positions were computed with unchecked i32 arithmetic on values from
files: import added a layer's origin to the cmls `Ofst` (and truncated wider
integers with `as i32`), export subtracted the origin from the stored
position, and applying a comp subtracted the current position. Extreme values
panicked with overflow checks on and wrapped otherwise.

Import now keeps offsets at i64 and drops a position whose sum does not fit,
with an import warning; export omits an `Ofst` that does not fit; applying a
comp skips a move that does not fit. Visibility of the same entries is kept.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:09:06 -04:00
26b6aac9ac Selection tools: ⌘ moves like the Move tool; ⌘⌥ copies (pixels inside the selection, the layer outside) (#896)
* Selection drags: ⌘⌥-drag floats a copy; ⌘-drag with the Polygonal Lasso and Magic Wand

- `select.float {"copy": true}` floats a duplicate of the selected pixels
  and leaves the originals in the layer; ⌘⌥-drag inside the selection uses
  it (⌘-drag still cuts).
- ⌘-drag inside the selection now also floats it with the Polygonal Lasso
  and the Magic Wand (not while a polygon is being drawn); a plain press
  there still places a point / samples, so only ⌘ moves the selection.

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

* ⌘ / ⌘⌥ with a selection always cut / copy and drag it, never combine selections

With a selection tool and an active selection, a ⌘ or ⌘⌥ press anywhere (not
only inside the ants) floats the selected pixels (cut, or copy with ⌥) and
drags them; ⌥ no longer turns it into a subtract. The cursor shows no
selection-mode badge while ⌘ is held over a selection.

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

* ⌘ with a selection tool moves the layer outside the selection; ⌘⌥ duplicates it

With a selection tool, ⌘ acts as the Move tool for the drag: inside the
selection it cuts the selected pixels (⌘⌥ copies them), and outside the
selection, or without one, it moves the whole layer; ⌘⌥ duplicates the
layer first and moves the copy. ⌘ never combines selections, so the cursor
shows no add / subtract badge while it is held.

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

* ⌘ outside the selection moves the layer, not the selected pixels

Only a ⌘ press inside the selection floats its pixels; outside it the layer
moves (`command_moves_layer`). Drops the duplicate badge check.

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

* ⌘⌥-drag on a floating piece drops it and drags a copy

A floating piece (from a ⌘ cut-drag) dragged again with ⌘⌥ is dropped where
it is and a copy of it floats with the pointer, as ⌘⌥ does on any selection.

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

---------

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-08 16:09:00 -04:00
a593b3651c CPU effect maps: key fill layers by their cached pixels, drop opacity/visible (#1059)
* CPU effect maps: key fill layers by their cached pixels, drop opacity/visible

The layer-effect map cache keyed a Fill layer by its Fill spec alone.
But what shapes the maps is the layer's cached pixels whenever they
still match the fill (what a PSD import stores and Image > Transform /
crop / canvas ops rewrite in place, without touching the spec): after
such a rewrite the CPU compositor kept serving the pre-rewrite maps, so
flattens and the CPU canvas showed the drop shadow of the pre-rewrite
shape while the GPU canvas was correct - until budget eviction or a
purge. The key now hashes the cache surface's tile fingerprint while it
matches the fill, like the GPU's fx::shape_key/group_key already did.

layer.opacity and layer.visible leave the key: the maps depend only on
fill_opacity (opacity is applied when the maps are composited,
visibility only skips the lookup), so an opacity slider drag or a
visibility toggle rebuilt every shadow and glow map over the whole
layer for nothing. Again matching the GPU key.

* Effect-map key: keep child visibility/opacity/blend for groups

A styled group's maps come from its children's composite, so hiding a
child or changing its opacity (or blend, clipping) must change the key;
only the top layer's own visible/opacity stay out. Adds a warm-vs-purged
render test for a group with a drop shadow.

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

* Effect-map test: name the edit tuple type (clippy type_complexity)

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-08 16:08:45 -04:00
BFlatandClaude Opus 5.5 8ebdd36769 Tools leave hidden layers alone: painting or moving a hidden layer is refused (#571) (#872)
* Tools leave hidden layers alone: painting or moving one is refused (#571)

Photoshop refuses to paint on, fill or move a hidden target layer ("the
target layer is hidden"). The painting, retouching, Paint Bucket, Gradient
and Move commands now fail with that message when the target layer, or a
group around it, is hidden (alpha channels and Quick Mask still paint), and
the canvas refuses the press so no live stroke draws first. Auto-Select
already skipped hidden layers; a test now pins it.

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

* Hidden layers: the live Gradient still works over a hidden target

Review on #872: the live Gradient adds a Gradient Fill layer above the
target rather than painting it, which Photoshop allows over a hidden
layer. Only the classic Gradient is refused now, and a test covers the
live one.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:45:55 +07:00
Pathos0925andClaude Fable 5.1 9df3a481ee New adjustment and fill layers take the active selection as their mask (#1250) (#1282)
Photoshop masks a new adjustment or fill layer with the active selection, so the
adjustment acts on the selected area only. `layer.newAdjustmentLayer.*`,
`layer.newFillLayer.solidColor`, `.gradient` and `.pattern` inserted the layer
without reading `doc.selection`, so select → New Adjustment Layer gave an
unmasked adjustment over the whole image. The Gradient tool's fill-layer
command already did the right thing; its inline code becomes the shared
`commands::selection_mask`, used by every creator. The selection stays active,
as after Layer › Layer Mask › Reveal Selection.

Tests: each command at 8, 16 and 32-bit gets a linked, enabled mask that reveals
the selection and hides the rest, the composite outside the selection is
untouched, undo removes the layer; and no mask without a selection.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:42:23 -04:00
MokuLakshithReddy 7de2b5f20e Nearest Neighbor: sample single source pixels on reductions without averaging (#1114) (#1141) 2026-10-08 15:42:11 -04:00
才倾梦华V 3dd4fd1574 Keep the Background position lock after Flatten Image (#1238) 2026-10-08 15:41:44 -04:00
af3c99d817 fix(engine): Clone Stamp and Healing Brush refuse a far source instead of slicing out of bounds (#1111) (#1165)
`Mapping::offset()` saturates its `as i32` cast and `Rect::translate` saturates
each edge, so a far `source` or `offset` (3e9, i32::MAX) produced a sample
window narrower than the stroke bounds. `clone_sample` then relabelled that
region as the full-size rect and `Region::px` indexed past the buffer:
`range start index 120 out of range for slice of length 92` at
`paint/src/retouch.rs:54`, which the dispatch guard reported as an internal
error. #716 fixed the rotated/scaled branch (`source_rect`); this is the pure
translation branch.

`clone_sample` is now fallible: when the translated window does not keep the
stroke rect's size, the command returns a bad-params error naming the offset
and the document and history are untouched. A source far outside the canvas
but inside the i32 range still clones transparency, as before.

Test: `clone_and_heal_with_a_far_source_are_plain_errors` (both tools, the
issue's inputs, a negative far source, the untouched-layer and no-history-step
assertions, and the in-range far source that must keep working).

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:41:38 -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
7e40b40fb0 Edit Contents switches to the already-open contents document (#1037)
* Edit Contents switches to the already-open contents document

Opening a smart object's contents a second time opened another copy.
It now activates the document already linked to that smart object.

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

* rustfmt smart_cmds.rs

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

---------

Co-authored-by: Jason Short <3344856+jason4short@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-08 15:41:19 -04:00
3f8536868e image.analysis.recordMeasurements: refuse an area too large to measure instead of overflowing (#1184)
Fixes #933. With no selection the whole document is measured, and
`selection_mask` sized its mask with `r.width() * r.height()` in u32. A legal
65536×65536 document (2^32 pixels) overflowed the product, so `run` panicked
and dispatch reported an internal error; in release the product wraps to 0,
`gray_image` still renders the full region (~64 GiB of f32 RGBA) and
`measure_features` then indexes the empty mask.

`selection_rows` now computes the pixel count in u64 before anything is
allocated and refuses more than `MAX_MEASURE_PIXELS` (400 MP) with an error
that says to select a smaller region; the mask is sized from that checked
count. The measurement label counter is also bumped only after the rows are
computed, so a failed measurement no longer skips a number.

Co-authored-by: wstock <1394687+wstock@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 15:33:18 -04:00
TiredIsa 7567a20ac3 fix(engine): preserve Unicode in variable data-set CSV (#1062) 2026-10-08 15:33:11 -04:00
yangandClaude Opus 5.5 7fec6e9469 Brush Scatter above 1000 % is rejected naming the value, and the renderer caps it so dabs stay finite (#977) (#1034)
tools.setBrush and stroke brush patches accepted any Scatter amount. The paint path
multiplied it by the radius in f32, so 1e38 overflowed the dab centre to infinity, and
dab_rect's unchecked i32 arithmetic then panicked (wrapped to an empty rect in release).

Brush validation now rejects primary and Dual Brush Scatter outside 0..=MAX_SCATTER (10,
the slider's 1000 %) with a BadParams naming the value, before the session brush changes
(also between setBrush's two merges, so infinity is named rather than reported as null).
bounded_for_render caps both amounts for direct paint API users (NaN scatters nothing),
and the dab rectangle, now one helper shared with the retouch tools, saturates.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:33:00 -04:00
yangandClaude Opus 5.5 0954b8c065 Link Layers no longer overflows on a stored u64::MAX link group and never joins an unrelated group (#961) (#1022)
layer.linkLayers picked the new link group as max(link_group) + 1, computed even
when unlinking. A .pcraft or PSD can store u64::MAX, so the addition panicked in
checked builds and wrapped to 0 in release, where it could merge the selection
into an existing group 0.

Add Document::unused_link_group: max + 1 when it fits, otherwise the smallest
free id from 1, never wrapping. Link Layers uses it only when linking and returns
an error if no id is left. Also read the first selected group and the member
counts without indexing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:32:30 -04:00
yangandClaude Opus 5.5 533c46f7d8 gradient.fill.stop rejects an out-of-range index with BadParams instead of overflowing (#958) (#1000)
The midpoint action checked `i + 1 >= v.len()` on the raw `index` param,
so an index of usize::MAX overflowed (a panic with overflow checks on, a
silent no-op success with them off). The shared index parser now takes
the number of addressable entries and rejects an index past them before
any arithmetic, for every action (move, delete, color, opacity, midpoint)
and both kinds, so all of them validate the same way.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:32:23 -04:00
Brandon ThomasandClaude Opus 5.5 dd555217ac Slices test: as_chunks for the fixed 3-wide rows (clippy chunks_exact_to_as_chunks) (#1374)
Follow-up to #991: Rust 1.99's clippy flags chunks_exact with a constant
size, which turns the workspace clippy gate red.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:06:06 -04:00
yangandClaude Opus 5.5 e1a874d9e9 Timeline: out-of-range duration and fps are rejected; stepping never panics (#1009) (#1023)
timeline.create and timeline.setProps kept any `duration` (up to u64::MAX) and
any `fps`, and timeline.nextFrame/previousFrame then moved the playhead with
`(current as i64 + delta).clamp(0, duration as i64 - 1)`, which panics when the
upper bound goes negative (duration 0 or above i64::MAX) or overflows.

Both commands now reject a `duration` outside 1..=1000000 and an `fps` that
isn't finite and in (0, 1000] with BadParams before anything changes. Stepping
moves to a saturating Timeline::step, safe on any stored timeline (e.g. one
loaded from a file with duration 0), and a non-finite stored fps falls back to
30 like a non-positive one.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:39 -04:00
yangandClaude Opus 5.5 815b4ee0f9 Place Scale Marker: a document under 8 px tall returns an error instead of panicking (#932) (#1014)
The bar height was clamped to [2, h/4], and f64::clamp panics when h/4 < 2,
so any image shorter than 8 px panicked inside the command's run closure.
Reject such images with a bad-params error before anything changes, like the
existing bar-width check. Also keep the bar on the canvas when the label under
it, or the left margin, would push it off a small image.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:32 -04:00
yangandClaude Opus 5.5 0c2c268c49 image.crop rejects an origin or far edge that can't be moved within i32 instead of panicking (#959) (#1013)
crop_doc negated the crop origin unchecked, so x or y = i32::MIN overflowed;
an origin one past i32::MIN pushed layer pixels past i32::MAX and panicked on
a length mismatch while translating surfaces; and x + width past i32::MAX
saturated, silently cropping less than asked. crop now checks the far edge,
crop_doc checks the negation, and translate_doc checks that every surface
still fits before moving anything; all return BadParams naming the values.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:05:25 -04:00
Nigel Ihza Firdausy fa1f0548fb fix(engine): saturate layer slice outsets at coordinate limits (#998) 2026-10-08 15:05:17 -04:00
Nigel Ihza Firdausy bdff608a0c fix(engine): divide wide slice rectangles without integer overflow (#991) 2026-10-08 15:05:09 -04:00
Ian Swope 874867461d file.new at 300000x300000 no longer fills ~360 GB of tiles before returning (#705) (#900)
Surface::fill_rect materialized every tile it touched, so a new document's
background cost one 256x256 tile per tile slot: ~360 GB for the 300000x300000
size the command accepts. Fully covered tiles now share one Arc'd tile, so a
solid fill costs one tile plus the tile index; edge tiles are written as
before, and copy-on-write splits a shared tile off when it is edited.
2026-10-08 15:04:57 -04:00
059546680c Layer copies are numbered: "Layer 1 copy 2", not "Layer 1 copy copy" (#897)
Duplicating a layer (Layer › Duplicate, ⌥-drag with the Move tool, Layer
via Copy without a selection, New Smart Object via Copy, the Edit menu's
duplicate, Select and Mask's new layer) names the copy with
`Document::copy_name`: "Layer 1 copy" first, then "Layer 1 copy 2",
"Layer 1 copy 3"…, unique in the document. A name already ending in
"copy" or "copy N" is numbered from its root instead of growing another
"copy"; names that merely contain the word keep it.

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-08 15:04:52 -04:00
Tanish Manku 5049a1d062 Never crash on hostile numbers: LUT sizes, Quick Selection points, link group ids (#869)
- Color Lookup: a stored document's `size` is untrusted. `size³ × 3` overflowed for sizes of
  2²² and up: debug builds panicked, release builds wrapped past the table-length guard and
  indexed out of bounds. One checked `photocraft_doc::adjust::lut3d_len` now backs the CPU
  compositor, the GPU planner and the PSD writer.
- Quick Selection: a stroke point far off the canvas (`select.quick {"points":[[3e9,0]]}`)
  saturated to i32::MAX and then overflowed on `+ 1`.
- Link Layers: `max + 1` over stored link group ids overflowed at u64::MAX (debug panic, release
  wrap that could merge unrelated layers). A fresh id is now the smallest unused one when the
  largest is taken; a selection's first group is read with `first()`.
- Vanishing Point clone footprint: the same `as i32 + 1` saturates (hardening; no reproducer).
2026-10-08 15:04:34 -04:00
Claude 4d4ab93f83 Select › Modify: bound the canvas-edge margin and translate Apply At Canvas Bounds
The margin Modify pads a canvas-edge selection with was twice the radius
for every operation, so Feather 1000 added 2002 px a side. It now pads by
each operation's reach (Feather 1.5 r, the others r) and refuses a padded
mask over 2^30 pixels instead of aborting on the allocation. The new
dialog option is translated in every shipped language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-09 01:59:24 +07:00
Marcelo Cárdenas d7859f17c7 Select › Modify: Apply effect at canvas bounds, so Contract/Feather/Border work after Select All (#1277)
Contract, Smooth, Expand and Feather gain Photoshop's "Apply effect at canvas bounds" option
(applyAtCanvasBounds, default off). On, the canvas edge counts as a selection edge, so Select All
then Contract shrinks from the edges and Feather softens them; off, the selection continues past
the canvas, as in Photoshop. Border always follows the canvas edge, so a Select All borders to a
band inside it instead of an empty selection.

The mask is padded (empty or edge-replicated) only when the selection touches the canvas edge,
so selections clear of it cost nothing extra.
2026-10-09 01:59:24 +07:00
Nigel Ihza Firdausy 7d78e1dd2c Export Preferences: WebP quality and lossless settings for Quick Export (#1260) (#1279)
* feat(export): persist WebP lossless mode and quality in export preferences (#1260)

* fix(export): apply configured WebP quality to Quick Export output

* fix(ui): show format-specific JPEG and WebP export preference controls

* fix(ui): use WebP quality and lossless preferences in Export As dialog

* test(export): verify WebP quick export mode, quality, validation, and legacy prefs

* style: format WebP export regression test as rustfmt expects

* style: apply rustfmt layout to conditional WebP preference labels

* docs(scorecard): account for two WebP export preference fields (#1260)
2026-10-09 01:57:01 +07:00
marcardenas d8dfb60b9a Gradient tool: a handle dragged past the 10% scale floor stops; the other handle stays put (#1243) (#1306)
Dragging one live-gradient handle towards the other clamps the fill's scale at 10%. For Linear the
centre was still taken from the midpoint of the requested points, so the
clamped gradient was re-centred and the handle that wasn't being dragged slid along the line.
gradient.fill.set now shifts the clamped layout so the handle not given keeps its place. The same
apply_set drives the drag preview, so the canvas matches.
2026-10-09 01:56:51 +07:00
marcardenas eb4b0f5e36 Filter › Last Filter applies to the active layer, not the layer the filter first ran on (#1270) (#1307)
The Filter Gallery, Camera Raw and Adaptive Wide Angle dialogs record the layer they ran on in
the command's params. Last Filter replayed the journal entry verbatim, so it filtered that layer
again (possibly hidden, or deleted) and appeared to do nothing on the layer you were looking at.
It now drops the recorded `layer`, so it applies to the current layer as in Photoshop.
2026-10-09 01:56:46 +07:00