Commit Graph
518 Commits
Author SHA1 Message Date
a35af4e279 Add FITS read/write (astronomy images) (#2956)
* Add FITS read/write codec

FITS (the astronomy format) opens and saves as a flat image: the primary
HDU or first IMAGE extension; gray, or RGB when NAXIS3 = 3; BITPIX 8/16/
32/64/-32/-64 with BSCALE/BZERO/BLANK; rows follow ROWORDER (bottom-up by
default). Camera U16 data stays exact; float data is normalised to 0..1.
Header cards round-trip as text metadata (HIERARCH for long names).
Tile-compressed (fpack) files are refused as unsupported.

Wired into File > Open, Save As and the batch commands as .fits/.fit/.fts.

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

* Test FITS open and save through photocraft-io

Gray 16-bit, RGB 8-bit and RGB 32-bit float documents round-trip exactly,
and header cards travel as the document's text metadata.

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

* FITS: hostile-header regression test (absurd axes, bad BITPIX, no END, HDU chains, overflowing sizes)

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-11 13:15:22 -04:00
9441484d16 Quick Mask: an empty start and Select All stay apart (#2743) (#2922)
* Keep Quick Mask's empty start and Select All apart (#2743)

Quick Mask stored "no selection" as an all-white mask, the same as Select
All. With Color Indicates Selected Areas that white was coloured over the
whole canvas, and leaving Quick Mask turned any all-white mask (a real
Select All too) into no selection.

The document now records that Quick Mask was entered with no selection.
The empty mask is white for Masked Areas and black for Selected Areas, so
neither shows colour; switching the option on an untouched empty mask
keeps it empty. On exit only that untouched white start becomes no
selection, so Select All survives the round trip. The flag is saved with
the document.

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

* Quick Mask tests: pass the active layer to channel_view::render (new main signature)

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

---------

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:51:18 -04:00
Myosh1 3f0970923d Add built-in rainbow gradient presets (#2916)
Add a "Rainbows" group to the built-in Gradients panel with five presets: Rainbow,
Pastel Rainbow, Neon Rainbow, Oil Slick and Rainbow Fade. The three hue ramps sample 13
evenly spaced hues (red 0 deg to magenta 300 deg) so the transitions are smooth rather
than the seven coarse ROYGBIV points; Rainbow Fade reuses the classic ramp with
transparent ends. All colours are original (clean-room).

Covered by a new engine test, presets::tests::rainbow_gradient_presets.
2026-10-11 12:18:19 -04:00
David MannandClaude Opus 5.5 66c0b59a23 Layers: Combine Shapes merges the selected shape layers (#2400) (#2906)
Layer › Combine Shapes › Unite / Subtract Front Shape / Intersect Shape
Areas / Exclude Overlapping Shapes only changed the operations between the
components of the active layer. With two single-shape layers selected (the
report's case) the active layer had one component, so nothing happened.

With two or more shape layers selected and no `layer`/`subpath` param, the
commands now merge them into the top-most layer in one history step: the
bottom layer's components keep their operations and every component of the
layers above takes the chosen one, in stacking order, then the other layers
are removed. Merge Shape Components merges them the same way (each layer
combined) and bakes the result. Selected non-shape layers are left alone,
locked layers refuse the merge, and one selected layer behaves as before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 12:05:57 -04:00
Valentin KleinandClaude Opus 5.5 c1bd296dcf Select and Mask: Refine Edge Brush (#2972) (#2989)
* Select and Mask: live preview in Photoshop's view modes (#1754)

The Select and Mask dialog had no preview of the refined selection. It now
previews live in Photoshop's view modes: Onion Skin (default), Marching
Ants, Overlay, On Black, On White, Black & White and On Layers, with a
per-view Opacity, Show Edge (J) and Show Original (P). F cycles the views,
O/M/V/A/T/K/Y pick one and holding X shows the image without a view.

The view state lives in the dialog's fields, so ui.dialog.set drives it.
The preview runs select.refineEdge on the proxy with output=selection and
builds the view from it (veil fill layers, a layer mask, or the refined
selection, whose marching ants the canvas draws scaled back up), through
the existing filter-preview worker.

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

* Select and Mask: Refine Edge Brush (#2972)

Select and Mask had no Refine Edge Brush. select.refineEdge now takes
refineStrokes: [{"points":[[x,y],...],"size":px,"mode":"add|subtract"}],
applied in painting order. Added areas become unknown and take a colour
estimate: each pixel's colour projected on the line between the mean known
foreground and background colours around it (normalised convolution over
a box of the brush size, four times as far where none are near), so hair
or fur beyond the edge-detection band joins the selection. Subtracted
areas keep the original edge. Malformed or oversized strokes are rejected
(stroke / point / swept-area limits), never a panic.

The dialog gets the brush: Refine Edge Brush (R), Mode (Expand Detection
Area / Restore Original Edge, Alt swaps), Size ([ and ]) and Clear Brush
Strokes. With it on, dragging on the canvas paints strokes into the
dialog's refineStrokes field, so the live preview, Show Edge and OK use
them, and ui.dialog.set / agents drive them like any other param.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:00:25 -04:00
5025fd2895 Color Sampler tool (#1046) (#2753)
* Color Sampler tool (#1046)

Add the Color Sampler to the Eyedropper (I) flyout: click to pin numbered
sample points to canvas pixels, drag to move, Alt-click (or drag off-canvas)
to remove, Clear All in the options bar, at most 10 per document. Each point
reads one composite pixel (RGB 0-255 and naive device CMYK, same as the
pointer readout) and the Info panel lists them under their number with the
position in the ruler unit; the readouts refresh when the document changes.

Points are per-document engine view state (DocState.color_samplers): they
belong to their document, are discarded on close, and are not saved to
.pcraft or PSD. They are driven by commands shared by the UI, CLI, control
channel and MCP (view.colorSamplers.add/.move/.delete/.clear/.list); the
mutations change no pixels and add no History step.

Points follow the canvas geometry (crop, Canvas Size, Image Size, Image
rotation/flip, rotated crop) through a pending Session.sampler_map that
Session::edit applies before culling the points that land outside the new
canvas; the count removed is reported in the status bar.

- engine: new sampler_cmds.rs (commands + shared readout helpers) and the
  sampler_map wiring in lib.rs, image_cmds.rs, mode_cmds.rs, commands.rs,
  analysis_cmds.rs (compound rolls points back on error).
- ui-egui: Tool::ColorSampler + original sampler.svg icon, toolbar slot,
  Info panel list, options bar, pointer handling, overlay markers, cursor.
- docs/scorecard.md regenerated (TOOL-1649-1 no longer says the tool is
  missing). No menu item, so parity is unchanged (627/627).

* Color Sampler follow-up: tool count, README, i18n, roadmap

The full ui-egui suite (not just the filtered tests) surfaced these gaps:

- state.rs: Tool::ALL.len() assertion 52 -> 53.
- README.md: "52 tools" -> "53 tools" and add Color Sampler to the list,
  so readme_tool_list_matches_tool_all passes.
- i18n: the options-bar hint, "{n} of {max} sample points" and
  "No document open" need entries in every catalog that claims complete
  menus (every_tl_literal_is_translated); added to all 16 (coverage
  1584/1584). The translations were drafted alongside the tool.
- docs/roadmap.md: drop Color Sampler from the missing-tools list and note
  it landed.

* Color Sampler: fit main's new tools and Image Size resample type

Reset Tool covers ColorSampler, the test ViewXform sets aspect, and
Image Size checks Method::None instead of an Option.

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-11 11:43:09 -04:00
buivanvu94 6a2aaebe99 fix(ui): unchanged Custom Proof Setup replaces active document proof profile (#3213)
Populate View › Proof Setup dialog fields from the active document's
current proofing condition rather than schema defaults or remembered
values from other documents. This ensures opening Custom Proof Setup and
confirming unchanged preserves the active proof profile and condition.

Fixes #3055.
2026-10-11 11:18:41 -04:00
c0110fb4d2 fix(engine): allow targeting layer mask and vector mask via channel.target (#2458) (#2705)
* fix(engine): allow targeting layer mask and vector mask via channel.target (#2458)

* fix(ui): apply channel target actions before syncing mask targets in channels panel

---------

Co-authored-by: huudinh1401 <huudinh1401@gmail.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 11:06:57 -04:00
d45b3718ce New Document: presets follow Units & Rulers › Preset Resolutions (#204) (#3126)
Preferences › Units & Rulers › New Document Preset Resolutions did nothing:
Print Resolution was hidden as unread, and the File › New presets always used
300 / 72 ppi. As in Photoshop, print and photo presets now keep their size in
inches at the Print Resolution (Letter at 200 ppi is 1700 x 2200 px), and
screen presets keep their pixels at the Screen Resolution. The Clipboard
preset keeps the copied pixels. Values that are not positive and finite (a
hand-edited preferences file) leave the preset as listed, and sizes clamp to
the file.new limit.

Print Resolution is now shown in the Preferences dialog; settings that do
nothing: 43 -> 42 of 150.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 10:15:40 -04:00
Valentin KleinandClaude Opus 5.5 1a54384d80 Rulers: remember View › Rulers for new documents; guides as f64 (#2918) (#2954)
View › Rulers (⌘R) now writes unitsAndRulers.showRulers, which is applied
at launch and whenever the preference changes, so new and opened documents
show the rulers as last set, as in Photoshop. The Preferences dialog doesn't
list it: the View menu sets it.

Guides are Vec<f64> (doc, New Guide/Move Guide, guide layouts, artboard and
canvas geometry, snapping, PSD resource 1032), so a guide placed in ruler
units or read from a PSD's 1/32 px positions keeps its exact place.

Scorecard UI-217-20 done (part of #217).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:05:13 -04:00
1234ba4c6f Type: wire Show Font Names in English (#204) (#2981)
Preferences > Type > Show Font Names in English (default on) now drives the
font menus (Type options bar, Character panel, style and Glyphs panels). With
it off, a family whose name table carries a non-English family name (e.g.
"MS Gothic" -> "MS ゴシック", "Microsoft YaHei" -> "微软雅黑") is listed by
that native name, sorted by the shown label, and the search matches either
name. Choosing it still applies the English family, so documents and PSD
output never change with the preference.

- text: FontDb::localized_family_names() reads each family's first face
  (typographic family name, else family name) and a pure localized_name()
  picks the first non-English record.
- ui-egui: sync_font_names() publishes the labels to the egui context once a
  frame (cached per font generation); family_picker_in shows them.
- prefs: the row is no longer hidden; scorecard: dead settings 43 -> 42.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 09:21:55 -04:00
Valentin KleinandClaude Opus 5.5 7c96e9e120 Layer Style: contour picker and Contour Editor (#2912) (#2949)
Every effect with a contour (Drop/Inner Shadow, Outer/Inner Glow, Satin,
Bevel & Emboss Gloss Contour and Contour element) now shows a contour
thumbnail with a preset picker and an Anti-aliased checkbox. Clicking the
thumbnail opens the Contour Editor: add/drag/delete points (the shared
point-curve editor), Corner per point, Input/Output, Preset, and Load…/Save…
of .shc contour files, read and written from Adobe's public File Formats
Specification (Contours). Edits go to the dialog's pending params, so the
preview follows and OK applies them in the one layer.layerStyle.replace.

- doc: Contour::Custom gains per-point corner flags (serde default).
- compose: contour_curve_lut splits the spline at corner points; CPU and
  GPU effects use it.
- engine: the 12 standard presets with our own control points (older names
  still accepted); `contour` params also take a curve object
  {name, points, corners}; antiAlias params; Satin contour; the bevel
  Contour element edits in place; contours_from_shc / contour_to_shc.
- io: PSD contour points read and write the Cnty (continuity) flag.
- psd: shc.rs parser/writer.
- scorecard FILE-215-15 missing -> partial.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:21:18 -04:00
6601bbc3e7 Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908) (#2979)
* Layers: Pattern Fill dialog for new and edited pattern fill layers (#2908)

Layer > New Fill Layer > Pattern... created the layer with default
settings and no dialog, and double-clicking a Pattern Fill thumbnail only
opened Properties, which has no pattern controls, so a pattern fill could
not be adjusted after creation.

Both now open Photoshop's Pattern Fill dialog: the pattern swatch picker,
Snap to Origin, Angle, Scale and Link with Layer, previewed live on the
canvas from a document snapshot. OK runs one engine command, so it is one
History step; Cancel changes nothing (a new layer is not created).

- engine: new `layer.fill.pattern.set` (pattern_fill_cmds) edits a Pattern
  Fill's pattern, scale, angle, link and phase in one undo step, validating
  every param; `preview` applies it or `layer.newFillLayer.pattern` to a
  copy of the document for the dialog. `new_fill_layer`'s edit is shared
  as `insert_fill_layer` so the preview is exactly what OK commits.
- ui: pattern_fill_ui (dialog body, OK, canvas preview), routed from the
  menu item (no params) and Layer Content Options; explicit params still
  run the engine directly. Catalog entries for the new strings.

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

* Pattern Fill test: pick the swatch picker's Patterns button (main added a Patterns dock tab)

Co-Authored-By: Claude Opus 5.5 <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-11 09:16:18 -04:00
10fb9233d5 Tools: wire Enable Flick Panning for the Hand tool (#204) (#2964)
* Tools: wire Enable Flick Panning for the Hand tool (#204)

Preferences > Tools > Enable Flick Panning (default on) was never read.
Like Photoshop, a Hand drag (the selected Hand, the Space-held Hand or a
middle-button drag) released while the pointer is still moving now keeps
the view gliding that way and slows smoothly to a stop; a click, new drag
or key press stops it. Off, the view stops where the drag ended.

flick_pan.rs holds the pure state: the release velocity from the last
80 ms of drag steps, an exponential friction step (tau 0.18 s, so a fast
flick rests in under a second), a start/stop speed threshold, and clamps
for huge, NaN or infinite numbers. The canvas drives it per frame with
request_repaint only while gliding; with Overscroll off the glide is
clamped like the scrollbars and stops at the pan limit.

Settings that do nothing: 43 -> 42 of 149 (docs/scorecard.md).

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

* Preferences: show Enable Flick Panning now that the Hand tool reads it (#204)

Remove tools.enableFlickPanning from prefs::HIDDEN_UNTIL_IMPLEMENTED so the
prefs_usage test passes and the dialog shows it; regenerate the scorecard
(settings that do nothing: 36 of 153) and mark flick panning done in
docs/ui-parity.md.

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-11 09:03:11 -04:00
cb9513c718 Channels: keep RGB off while an alpha or mask is shown, and select from that channel. (#2555)
* Channels: keep RGB off while an alpha or mask is shown, and select from that channel.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Channels: rustfmt the RGB-off view check.

Co-authored-by: Cursor <cursoragent@cursor.com>

* channel view: pass the active layer to render in main's new tests

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:03:05 -04:00
026432815c Preferences: wire Tools › Show Transformation Values (#204) (#2953)
* Preferences: wire Tools › Show Transformation Values (#204)

The size and angle readouts beside the pointer (marquee W/H, Crop frame
W/H and angle, Crop Straighten angle) now go through
canvas::draw_value_readout, which draws them only while
tools.showTransformationValues is on, as in Photoshop. The brush HUD
isn't a transformation value and keeps drawing.

The preference leaves HIDDEN_UNTIL_IMPLEMENTED, so Preferences › Tools
shows it (its label is already translated). Settings that do nothing:
43 → 42 of 149.

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

* Contributors: add my display name

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-11 08:57:49 -04:00
55a617cd66 Place: wire Always Create Smart Objects When Placing (#204) (#2543)
* feat(engine): Always Create Smart Objects When Placing gates the Place layer kind (#204)

* scorecard: regenerate after wiring Always Create Smart Objects When Placing

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-11 08:51:33 -04:00
a0ff3a01f2 Smart Objects: linked smart objects follow their files while open (#2510)
* Smart Objects: linked smart objects follow their files while open

As Photoshop documents ("Linked Smart Objects update automatically when
the Photoshop document is open, provided the source file has changed"),
a linked smart object whose file another app saves is re-rendered
without Update Modified Content. The desktop app stamps the active
document's linked files (size + modification time) on a worker thread
every two seconds and when its window comes back to the front; each
changed file is one Update Modified Content undo step, independent of
the selected layer. Stamps are kept per document; a file that can't be
read yet leaves no step and is tried again; files changed while the
document was closed still wait for Update Modified Content.
layer.smartObjects.updateChanged runs one look (refused over the
control channel like the other layer.smartObjects commands).

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

* Smart Objects: a linked file that can't be read is tried again once it changes, not on every look

A linked file the automatic update couldn't read (still being written, or a
format smart objects don't read) kept its old stamp, so every two-second look
saw it as changed and read it again on the UI thread. It now keeps its new
stamp and is tried again once it changes again.

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

---------

Co-authored-by: Katt <238968166+KattKnight@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 08:42:52 -04:00
UnemarsandClaude Opus 5.5 0b4b1e4286 Image Size: Photoshop's resampling, measured (#2451)
* Image Size: Photoshop's resampling, measured

Photoshop 25.4's Image Size was measured as a black box (synthetic 16-bit
images in, exact RAW out); every method but Preserve Details' enlargement is
linear, so each was taken apart from its impulse responses.

- Bicubic is the Keys cubic at a = -0.75, not Catmull-Rom.
- Bicubic Smoother (stretched x1.15, a = -0.625) and Bicubic Sharper
  (x1.05, a = -1), with a growing with the reduction, and Bicubic Automatic.
- Reducing widens the filter by 1/s; below 1/4 the image is first averaged
  down to four times the target. Bilinear is never widened and averages
  whole pixels below 1/2. Positions are rounded to 1/128 pixel and stop at
  the edge pixel's centre.
- Two passes, the smaller scale first, clipped and stored between them; the
  bicubic filter is designed for the height's scale and serves both axes.
  Bicubic Automatic splits a mixed resize into Sharper, then Smoother.
- Image Size and Fit Image take automatic, bicubicSmoother, bicubicSharper
  and bicubicAutomatic; the dialog lists Photoshop's methods and opens on
  the interpolation preference.

On 225 probes the mean error falls from 240-550 to under 0.5 levels of
32768; 8-bit results are identical to Photoshop's for 99 % of values.

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

* Format: one rustfmt change main needs

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-11 08:25:21 -04:00
Valentin KleinandClaude Opus 5.5 7107c80042 perf: pasting a layer recomposites only where it draws (#2901) (#2917)
* perf: Duplicate Layer recomposites only where the copy draws (#2883)

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:24:51 -04:00
ec28fdd03d Add Photoshop-style guide layout dialog and live preview (#2930)
* feat(ui): add guide layout dialog with live preview

* test(ui): restore path helper used by save dialog assertions

* chore(i18n): avoid conflicts with appended catalog entries

* chore: avoid append conflict for contributor credit

* Merge main: one people.toml entry for Skyfuller1303, drop the duplicate joined helper

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-11 08:23:03 -04:00
Valentin KleinandClaude Opus 5.5 42e4af6416 Tools: Frame tool (#2914) (#2915)
The Frame tool (K, its own toolbar slot after the Crop group) draws a
rectangular or elliptical frame; the options bar picks the shape, Shift
constrains to a square or circle and Alt draws from the centre. A frame
drawn over the selected pixel layer's content takes that layer in;
otherwise it is an empty frame.

Frames are built as Frame from Layers builds them: a group clipped by a
layer mask (rectangular or anti-aliased elliptical). The new engine
command layer.new.frame does it in one Session::edit, so one undo step.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:20:15 -04:00
Valentin KleinandClaude Opus 5.5 614d06892c perf: marquee selections on large documents are built row by row (#2888) (#2907)
The Elliptical Marquee tested and wrote every pixel one at a time
(Surface::write_pixel), single-threaded, with no shared full tiles. The
add/subtract/intersect modes read both selections over the whole canvas
into f32 buffers. On 6000x4000 an ellipse took ~0.6-1.1 s and a mode
combination ~0.3-0.5 s.

ellipse_surface builds the shape per row: a run of fully selected pixels
(bounded analytically, checked with the exact f32 tests at both ends)
and edge pixels that get the original per-pixel coverage test. Tiles are
built in parallel, and fully selected tiles share one tile.
combine_surfaces maps two GRAY8 selections tile by tile through a
256x256 byte table, sharing tiles that come through unchanged. Both
produce exactly the old bytes (equivalence test against the old
per-pixel code).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:11:17 -04:00
a874b149f2 Themes: Photoshop's light-gray and light Pro brightness levels (#2661) (#2892)
Photoshop has four interface brightness levels; PhotoCraft had the two
darkest (Pro, Pro Medium Gray). Add the other two with the Pro layout:

- Pro (Light Gray), id proLightGray: #B8B8B8 panels.
- Pro (Light), id proLight: #F0F0F0 panels.

They are light themes: Preferences > Interface lists them under Light
Theme, Window > Theme has an entry for each, and the legacy
interface.theme preference selects and migrates them. ThemeKind::is_pro
replaces the hard-coded Pro | ProMedium checks so the dock keeps the Pro
tab layout on every Pro level. Labels are translated in every catalog.

Tests check the panel greys, ids and names, the darkest-to-lightest
order, and WCAG contrast of text, icons and accent text on every surface.


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

Co-authored-by: ngrafton2006 <ngrafton2006@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 08:09:05 -04:00
Valentin KleinandClaude Opus 5.5 3e90c81e3c perf: Duplicate Layer recomposites only where the copy draws (#2883) (#2884)
Duplicating a layer reported no damage, so the canvas recomposited the
whole document after every Duplicate Layer (P8/P9). The copy itself was
already cheap (tiles are shared Arcs). Report the copy's change bounds
instead, through the step_damage walk extended to bounded additions:
self-contained added layers (not an adjustment, not a new clipping base,
no video or excluded channels) contribute their change bounds; anything
else still recomposites everything.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:56:05 -04:00
Valentin KleinandClaude Opus 5.5 9b3c6359ae perf: Select > Modify > Feather/Smooth on 24 MP within budget (#2873) (#2882)
The blur itself was already parallel (9-15 ms on 24 MP); most of the
command was serial glue around it. Modify copied the mask into a padded
buffer and back out sample by sample even with no padding, and combine
built a full-canvas byte buffer, scanned it, then copied it into tiles
on one thread.

- modify: use the mask as is when the selection is clear of the canvas
  edge (pad 0); pad and crop row by row otherwise.
- combine / mask_to_surface: fill selection tiles in parallel straight
  from the mask, a row at a time; fully selected tiles share one
  copy-on-write tile.

Bit-identical: the per-sample oracles (combine, now with whole tiles,
and the old pad/crop) are compared in tests.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:48:51 -04:00
Valentin KleinandClaude Opus 5.5 a030d19437 perf: repeated Select Subject answers from the last mask (#2865) (#2870)
Select Subject recomputed superpixels, saliency, GrabCut and the
full-resolution re-cut on every call (P29: 260-370 ms on 24 MP here,
693 ms p95 in the baseline). The session now keeps the last result,
keyed by the pixels it came from: `same_pixels` (tile pointers) against
a weakly held document, plus the sampled target (composite or layer).
Selection-only edits such as Deselect keep it valid; the mode
(replace/add/subtract/intersect) is still applied each time. One entry
per session, dropped on pixel edits and when its document closes.

Repeat: 7-10 ms p95 (was 270-370 ms). First run unchanged.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:44:00 -04:00
1d425ce8f9 Actions: check boxes switch single steps on and off (#2855) (#2866)
Photoshop's Actions panel has a check column: an unchecked step is skipped
by playback, Batch and droplets. PhotoCraft could reorder and delete single
steps but always ran every step.

- Action gains `disabled` (0-based step indices, omitted from actions.json
  when empty), kept in step with actions.move and actions.delete.
- New command actions.toggle {action, step?, enabled?}: one step, or every
  step like the action's own check; bad indices and types are errors.
- actions.play, the desktop playback path and the steps handed to Batch and
  Create Droplet skip unchecked steps; actions.get reports `disabled`.
- Panel: a check box per step, dimmed label when off, "2/3 steps" count.

Part of #219 (AUTO-219-2).

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

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

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

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:28:11 -04:00
5866cd2cf6 Tools: Horizontal and Vertical Type Mask (#1055) (#2861)
The Type flyout (T, Shift+T cycle) gains Vertical Type Mask and Horizontal
Type Mask, in Photoshop's order. They type like the Type tools, with the
canvas under Quick Mask's colour and the glyphs clear while editing. On
commit the glyph coverage replaces the selection; no layer is left behind
and the whole session is one "Type Mask" history step. Escape always
cancels and leaves the selection unchanged.

The commit runs a new engine command, type.createSelection: a type layer's
glyphs (optionally removing that layer, the tools' hidden working text) or
type.create's keys directly, with new/add/subtract/intersect and an
anti-aliasing override. type.create's layer building is shared through
type_cmds::new_text_layer.

Original dashed-T icons (ATTRIBUTION.md), tool label translations in every
catalog, README tool list and the gaps/parity docs updated.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 07:06:10 -04:00
bacc825d33 Move tool: drag a marquee from an empty spot to select the layers it touches (#2844) (#2852)
With Auto-Select on (or Cmd held with it off), a Move-tool drag that starts where
no layer has pixels (an empty spot, or the pasteboard) now draws a marquee instead
of moving the previously selected layer. Releasing selects every layer whose
content touches it (Auto-Select: Group selects their outermost groups); Shift adds.

New engine command layer.selectInRect; the UI side lives in move_box.rs.

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

Flips scorecard row BUG-207-4.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:38:36 -04:00
203b5796b4 Tools: remember tool options between launches (#2814) (#2822)
* Tools: remember tool options between launches (#2814)

The options-bar settings in ToolOptions (Move > Auto-Select, marquee
Feather, tolerance, gradient style, crop ratio, eyedropper sample, ...)
only went through serde for Tool Presets, so every launch started from
the defaults. Photoshop keeps each tool's options between sessions.

Preferences gain `toolOptions` (shell-owned JSON, like brushPicker): the
fields that differ from their defaults, saved once the pointer is up and
restored at launch field by field. An unknown key is ignored; a field
that doesn't read back (wrong type, a number out of f32 range) keeps its
default with a log warning.

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

* Merge upstream/main into fix/2814-persist-tool-options; regenerate docs/scorecard.md

The branch adds a preference, so the prefs audit count in docs/scorecard.md changes
(CI's xtask scorecard staleness test).

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-11 06:20:17 -04:00
mattyandBrandon Thomas ac19db1046 TGA: targa options asks for 24 or 32 bits/pixel, same as in Photoshop (#2760)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 06:17:53 -04:00
Valentin KleinandClaude Opus 5.5 b7b3117aea Droplets: carry the actions their Play Action steps call (#2794) (#2800)
* Batch: a failed step inside a called action fails the file and the script (#2779)

Scripts (File › Scripts › Browse, Script Events) ignored the `failed` entry that
`actions.play` returns when a step inside the called action fails, so they reported
ok and kept going. Batch and droplets ran each file in a scratch session without
the actions list, so any nested action call failed with "no action named". The
scratch session now gets the caller's actions and playback stack (an action that
batches itself is caught as recursive), and both runners check nested_failure.

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

* Droplets: carry the actions their Play Action steps call (#2794)

createDroplet saved only the chosen action's own steps, so a droplet whose
action plays another action failed on every file when run outside the
creating session (photocraft-cli droplet and the .command shim run it in a
fresh Session with no actions).

createDroplet now embeds the called actions, followed transitively with a
seen-set and the 16-level playback cap, under "calledActions" (not
"actions", which the CLI reads as an action set). Index references become
names. runDroplet passes them to file.automate.batch, whose per-file
sessions then play from them. Droplets without the key behave as before.

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

* Droplets test: build Actions from JSON so the test survives new Action fields

#2866 adds Action.disabled; a struct literal here would stop compiling once both land.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:06:20 -04:00
80519e1a17 Type: ⇧⌘> / ⇧⌘< change the type size (and work on Nordic/German layouts) (#2402)
* feat(type): Cmd+Shift+> / < change the type size, Alt for 10 pt steps

Photoshop's type size shortcuts: the selected characters while typing,
else the whole active type layer, in the points the layer shows. The
period and comma keys match by position so the shortcuts also work on
Nordic and German layouts, where Shift+. types a colon.

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

* test(type): bad size steps fall back or clamp

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-11 05:56:28 -04:00
d96bb50161 Add Anchor Point and Delete Anchor Point tools (#1044) (#2756)
Paths could be reshaped after drawing since #865, but the number of
anchors could not change. Two new engine commands do that, and two tools
in the Pen flyout drive them on the canvas.

- photocraft_vector::edit::add_anchor splits the segment at t with de
  Casteljau, so a curve keeps its shape and gets a smooth anchor; a
  straight segment gets a corner on the line. delete_anchor joins the
  neighbours keeping their handles, drops an emptied subpath and refuses
  to remove a path's only anchor.
- path.addAnchor / path.deleteAnchor take the same targets as the other
  point edits (work path, saved path by name, shape layer path, vector
  mask) and record one history step each.
- Tool::AddAnchorPoint / DeleteAnchorPoint join the P group after the Pen.
  They act on Direct Selection's paths, highlight the segment or anchor
  under the pointer, show a +/- cursor badge, swap with Alt and turn into
  Direct Selection with Cmd/Ctrl. An edit clears a Direct Selection on the
  same path so it never points at a shifted anchor.
- Icons derived from Lucide pen-tool (ATTRIBUTION.md), hints and tool
  names in all 16 catalogs, README tool list, roadmap.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 05:48:07 -04:00
59da288daa fix(type): preserve formatting for trailing empty paragraphs (#3198)
* fix(type): preserve formatting for trailing empty paragraphs

Represent an explicitly styled terminal empty paragraph with a zero-byte paragraph run, keep it through normalization, style only the caret's paragraph, and transfer that style when text is inserted at EOF. Cover line geometry, typing, and PSD text serialization.

* fix(type): retain imported terminal paragraph marker style

* 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-11 05:36:52 -04:00
468b8f2792 Crop tool: New / Delete Crop Preset saves and removes user presets (#1919) (#2723)
* Crop tool: New / Delete Crop Preset saves and removes user presets (#1919)

The Crop options-bar dropdown ends with New Crop Preset... and Delete Crop
Preset..., as in Photoshop. A crop preset is a "crop" tool preset
(tool.presets.*, persisted with the preferences), so it also shows in the
Tool Presets panel. New saves W x H x Resolution (W, H, resolution and
unit) or a ratio under a name made from the values; it is greyed out with
nothing to save. Delete lists the saved crop presets for a multi-select;
built-in size presets stay. The dropdown shows the preset the fields hold.

Engine: tool preset names are checked (1-255 chars) and the store capped;
saved tool presets load entry by entry so a damaged one is skipped; a
multi-name delete is all or nothing. ui.dialog.open gains newCropPreset /
deleteCropPreset.

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

* Regenerate the scorecard after rebasing onto main

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-11 05:31:19 -04:00
8a9f4722d2 fix(shapes): preserve transformed outline on unchanged live geometry edits (#3197)
* fix(shapes): preserve transformed outline on unchanged live geometry edit

A nonuniform scale stores an anisotropic outline but live rounded rectangles only store scalar corner radii. Avoid regenerating the path when all live values are unchanged, preserving transformed corners and PSD vector source. Cover no-op edits, real radius edits and undo at all depths.

* 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-11 05:23:55 -04:00
073e1cc1d2 Add the Color Replacement tool to the Brush toolbar group (#1045) (#2719)
The engine already had paint.colorReplacement, but the desktop UI had no
tool for it: no Tool entry, no toolbar slot, no options bar and no stroke
routing, so only agents and the CLI could use it.

Tool::ColorReplacement joins the Brush (B) flyout after the Mixer Brush,
so B / Shift+B cycle to it and ui.set / ui.inspect name it. Its options bar
has Mode, Sampling (Continuous / Once / Background Swatch), Limits,
Tolerance and Anti-alias, all mapped to the command's params, with the
foreground as the replacement colour. Strokes are drawn live through
LiveDab, which can now preview the stroke's finishing dab, so the preview
at the last move matches the commit. The command refuses a mask, channel
or Quick Mask target instead of silently recolouring the layer. New icon
derived from Lucide's brush; label translated in all 16 catalogs.

Co-authored-by: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 05:20:00 -04:00
Nigel Ihza Firdausy d8217b3476 fix(image): keep smart-filter masks aligned through Image Size, Canvas Size and Crop (#3195) 2026-10-11 05:18:52 -04:00
09d079278e fix(video): retain original footage for Restore Frame and Show Altered Video (#3192)
* fix(video): retain original footage for restore and comparison (#3059, #3060)

* Clamp original-frame inserts to the original stack; 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-11 05:02:29 -04:00
Reynold HuandBrandon Thomas 67e3d9be9f feat(ui): Font Preview Size drives the font menu samples (#204) (#2668)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 04:52:17 -04:00
6cfed3a7dc fix(masks): bake feathered coverage when applying pixel masks (#3044) (#3191)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 04:49:03 -04:00
01833cfac2 fix(actions): avoid replaying Browse script edits twice (#3190)
* fix(actions): avoid replaying Browse scripts twice (#3057)

* 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-11 04:38:07 -04:00
21326ded43 fix(vector): preserve scaled live-line thickness during endpoint edits (#3184)
* fix(vector): keep scaled live-line weight after editing endpoints (#3063)

* 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-11 04:31:47 -04:00
Reynold HuandBrandon Thomas 93f2ff835a feat(ui): Beep When Done plays the platform beep for finished jobs (#204) (#2658)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 04:29:59 -04:00
a9aabcdfde fix(vector): pivot explicit path transforms on target layer (#3062) (#3183)
* fix(vector): pivot explicit path transforms on target layer (#3062)

* 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-11 04:23:08 -04:00
458870dadc Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control (#2044)
* Auto-Align Layers: register focus and exposure brackets by intensity when there is nothing to match

Feature matching fails outright on layers that nearly coincide (a focus bracket: sub-pixel shifts, up to a few percent of scale from focus breathing, much of every frame defocused). `photocraft_algo::direct` registers the target to the reference coarse-to-fine on a Gaussian pyramid of the luminance, the cost the mean-removed RMS of the warped difference over the pixels both cover, by a bounded Nelder-Mead search with a per-level schedule; the pose is a similarity about the centre, affine or projective per the projection, and resolution independent, so the search stops short of full resolution. `edit.autoAlignLayers` gets `registration`: auto (features, then intensity when nothing matches), features, intensity; layers register outward from the reference, each to the previous one aligned.

Closes #2064.

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

* Intensity registration: reject a no-fit, search on coarsened planes

Review follow-up on #2065:
- `direct::cost` also returns the correlation of the reference with the
  warped target over the overlap; `Registration.correlation` carries it.
  Unlike the mean-removed RMS it is exposure independent, so it tells a
  bracket (near 1) from unrelated pictures (near 0).
- `register_direct` fails with the existing "couldn't find enough matching
  detail" error when a layer's best pose overlaps its neighbour by less than
  a quarter of the plane or correlates below 0.5, instead of placing it at
  whatever pose the search box ended at.
- The luminance planes are built at the search scale (`luma_alpha` with the
  reduction factor, ≤ 2048 px across) and the warp of each registered layer
  runs there too; the pose is resolution independent and its matrix at full
  resolution places the layer. Nothing full-resolution is held.
- The reference index is checked once with `get`; no unclamped indexing.
- Tests: correlation at an exposure step, for unrelated textures and a flat
  plane; the engine rejects unrelated layers and a mostly empty one.

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

* Auto-Blend Stack Images: fuse the merged layer per pyramid level by region energy, with halo control

The merged layer of Edit › Auto-Blend Layers › Stack Images was the per-pixel masks blended with a multiresolution spline, so a wrong pick in the mask reached every scale. `pyramid::fuse_stack` fuses the layers on the Laplacian pyramid instead: per coefficient the layer with the largest region energy of its luminance band wins (Adelson et al. 1984's multifocus composite with Wang & Chang 2011's rules), the residual follows local deviation + entropy, and with halo control the levels coarser than the guide are weighed by the guide's energy so a bright object's defocused glow stays out. `haloControl` (0..8, default 2) on `edit.autoBlendLayers`; the masks and the panorama method are unchanged.

Closes #2043.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 04:13:11 -04:00