Commit Graph

523 Commits

Author SHA1 Message Date
BFlat 00c406f589
Test: one press-drag-release gesture crosses menu titles and enters submenus (#775) (#837)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:30:18 -04:00
bfenty 1ee12eb90d
fix(engine): Camera Raw clamps the noise amounts to their range (#707) (#839)
filter.cameraRaw deserialized its params verbatim, so an out-of-range
noiseLuminance reached the denoise stage and became a guided-filter
radius of round((1 + 3 * n/100) * pixelScale). A value of 1e9 turned
six box-mean passes into unbounded work: the command never finished.
noiseColor had the same defect through its blur sigma.

raw_params now clamps the four noise fields to the documented 0..100,
matching how the sibling lens commands clamp their ranges at the same
boundary. The in-range path is untouched.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:24:42 -04:00
reese 2873afdda9
fix(layers): a locked group locks the layers inside it (#847)
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 20:18:27 -04:00
Tanish Manku 6f39aba7f2
Preset store: a store directory that doesn't exist yet reads as empty on Windows too (#878)
load_actions treats a missing actions.json as an empty list via missing_file(), which
matches the error text ("not found", "no such file", "os error 2"). When the store
directory itself doesn't exist yet, as on a first launch, Windows reports os error 3
("The system cannot find the path specified."), which none of those match. So opening
a fresh store warned "actions.json skipped", and two engine tests failed on Windows
(actions_persist_with_the_preset_store, group_with_sampled_8_and_16_bit_tips_round_trips).

DirBackend::read now maps io::ErrorKind::NotFound (os errors 2 and 3) to the same
"{name}: not found" wording the in-memory backend uses. New test: a store directory
that doesn't exist yet opens with no warnings (fails on Windows before the fix).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:57:32 -07:00
Tanish Manku ce87c106a9
agent_tasks: wait for the MCP server task before removing the test folder (Windows os error 32) (#877)
Three agent_tasks tests failed on Windows at remove_dir_all with os error 32. The
headless session's AuthorizedWorkspace opens its roots as cap_std::fs::Dir handles,
and the server task that owns it was still alive when the test deleted the folder:
c.cancel() closes the client, but the spawned server drops the session
asynchronously. Linux deletes directories with open handles, Windows doesn't.

connect/connect_headless now return the server task, and shutdown() cancels the
client and awaits the server, so the handles are gone before the folder is removed.
Test-only change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:54:01 -07:00
Tanish Manku 3689754ef2
tiff_corpus: ask for layers explicitly (corpus job red since #825) (#876)
#825 made ExportOptions::default() write a flat TIFF (headless saves stay flat
unless they ask for layers). corpus_round_trips_through_layered_tiff exported with
the defaults, so every corpus file came back flat and the round trip failed (670
failures). Test-only change: request tiff_layers: true.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:53:56 -07:00
Tanish Manku 07174271d5
CI: never cancel main's runs; trim per-PR runner time (#800)
Main's CI was effectively unverified: with cancel-in-progress on every ref, each merge
cancelled the run of the one before. Between 09:56 and 20:34 UTC on 2026-10-07, 80 of 83
push runs on main were cancelled, and a formatting break from two PRs that were each green
alone (#663 + #700) went unnoticed until PRs updated to main started failing.

- ci.yml: cancel-in-progress only for pull_request. Main runs finish; a merge that lands
  meanwhile waits and runs next.
- ci.yml: a `platforms` job picks the test matrix. main, manual runs and PRs labelled
  `full-ci` get Linux, macOS and Windows. Other PRs get Linux only, unless they touch
  code whose behaviour differs by platform (apps/, ui-egui, text, gpu, tablet, heif,
  build.rs, Cargo.lock, ci.yml).
- ci.yml: the second Linux test run with craft-fonts covers only the crates that use the
  fonts (photocraft-text, photocraft-ui-egui), not the whole workspace again.
- freebsd.yml, windows-arm64.yml: nightly on main instead of on every push and every
  Cargo.toml change; PRs touching their own packaging still run them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:46:18 -04:00
Bart Louwers 0f16bce0c5
Add the Magnetic Lasso tool (#864)
* Add the Magnetic Lasso tool

A selection border that snaps to edges as the pointer moves, in the
Lasso (L) flyout after the Polygonal Lasso.

- photocraft-algo `magnetic`: live-wire edge tracing (Mortensen &
  Barrett 1995): Sobel strength, non-maximum suppression and gradient
  direction link costs, a shortest path inside a corridor of the
  detection width around the pointer's path, sub-pixel edge centres.
  Pixels are read lazily in 128 px blocks.
- Engine: `EdgeSource` (the active pixel layer, else the composite) and
  `select.magneticLasso`: rough points in, a border along the edges out;
  `trace: false` selects a given outline, which is how the tool commits.
- UI: click to start, move to trace (button up or down), points fasten
  by themselves (Frequency), click fastens, Alt-click straight, Alt-drag
  freehand, Backspace/Delete removes points, click the first point,
  double-click or Enter closes, Esc cancels, [ ] change the width.
  Options bar: Width, Contrast, Frequency, tablet pressure. `ui.inspect`
  reports the border in progress.
- The Lasso and Polygonal Lasso now apply the options bar's Feather;
  `select.lasso` rejects a feather outside 0..1000.
- Icon adapted from Noun Project's "Magnetic Lasso Tool" by Lil' Seal
  (CC BY 3.0, licence file and ATTRIBUTION.md row).

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

* it: translate the Magnetic Lasso strings (Italian landed after this PR was written)

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-07 19:42:45 -04:00
giallorosso84 7a9b591189
Italian UI translation (it) (#853)
* Update mod.rs

* Update system.rs

* Update live_tests.rs

* Update localization.md

* Italian UI translation (it)

* Register Italian fully and bring the catalog up to date

- LANGUAGES grows to 13 entries (the array length was still 12), with the native
  name Italiano.
- The system-locale test expects the code `it`, not `it-IT`.
- Italian joins the i18n coverage report, ATTRIBUTION.md and LICENSE-translations.txt.
- Translate the strings main gained since the catalog was written, and fix two
  comments copied from the Spanish 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-07 19:13:01 -04:00
Remidesbois b436950ae3
Layer Style: complete multi-instance effects and dialog parity (#157) (#786)
* layer style: instance-aware engine apply (layer.layerStyle.replace) with param overlays, contour presets and factory defaults

* layer style: instance-based dialog state preserves every effect; Preview checkbox gates the live preview

* layer style: Styles swatches and New Style… save the dialog pending state (style.presets.new effects param)

* layer style: Contour picker and Noise slider for shadows and glows (CPU-rendered noise; GPU falls back to the CPU for noisy effects)

* layer style: Make Default / Reset to Default per effect kind (user defaults persist with the presets)

* layer style: double-stroke band regression (manga case) and control-protocol docs for the instance-based dialog

* layer style: make Styles a top-level tab displaying preset swatches in the right panel

* i18n: translate the Layer Style preset strings in de and pt-br

The German and Brazilian Portuguese catalogs landed on main after this
branch added "Style {name} saved", "Make Default" and "Reset to
Default"; every_tl_literal_is_translated needs them.

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

* presets: move the stray BTreeMap import to the top of the module

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-07 19:05:15 -04:00
alexanderalber 8948511eeb
Large PSBs open with their pixels: decode planes past 2 GiB, plus 4-5 GB PSB fixtures (#375) (#782)
* PSD: decode planes up to 8 GiB, so large PSBs keep their pixels (#375)

MAX_DECODED_BYTES capped a single decode at 2 GiB. Real PSBs go past
that in one plane set: the merged image of a 30000x30000 RGB file is
2.7 GB, a 33000x33000 16-bit layer channel 2.2 GB. Opening such a file
gave a document with no layers (flattened files) or layers whose
channels were "treated as empty" (layered files). Saving hit the same
cap on the encode side and silently fell back to uncompressed data.

The cap is now 8 GiB on 64-bit targets and 2 GiB elsewhere, the budget
photocraft-codecs already uses for Limits::max_alloc. It stays a
backstop: the decoders bound their output by their input on their own
(RLE expands at most 64x, Raw needs every byte present). The Raw and
RLE output buffers are reserved with try_reserve_exact, so a machine
without that much memory gets an error instead of an abort.

generated with Claude Code

* PSD import: a channel that can't be decoded leaves its layer empty (#375)

The warning says the channel is "treated as empty", but the importer
filled a missing transparency channel with opaque alpha, so a layer
whose channels all failed became an opaque black rectangle over the
document. A channel that is present but undecodable now leaves the
whole layer empty (transparent), as the warning says.

tests/large_psb.rs: the damaged-channel case (PSD and PSB), and an
opt-in release test that opens a flattened 29000x29000 PSB whose
merged image decodes to 2.52 GB (16 s, 11.6 GB peak here).

generated with Claude Code

* Large-file fixtures: generate and check 4-5 GB PSBs (#375, #216)

photocraft-psd example big_psb writes five PSBs straight from the spec,
independent of this crate's writer and its limits: flattened and
layered, raw (4.4 and 4.8 GB files, data past 4 GiB) and RLE (small
files whose planes decode past 2 GiB, up to a 33000x33000 16-bit
layer). Every sample follows one formula, so photocraft-io example
big_psb_check opens a file through photocraft_io::import and checks
pixels without a reference; with SAVE set it saves as PSB and checks
the re-opened file.

Release build, this machine: all five open with correct pixels and
survive a PSB save and re-open (5-14 s per import). Before the 8 GiB
cap, three of the five opened empty or black.

Scorecard FILE-216-2 missing -> partial: no 2 GB PSD, 150-layer or
20 MP fixture yet, and nothing runs in CI.

generated with Claude Code

* psd: ZIP channels reserve fallibly and read in bounded chunks

A small ZIP channel declaring a huge decoded size could make
zip_decompress try a multi-GiB with_capacity/read_to_end and abort on
allocation failure. Reserve with try_reserve_exact/try_reserve, read
through take(expected) in 1 MiB chunks, and report LimitExceeded
instead. Tests: absurd declared sizes, past MAX_DECODED_BYTES, garbage.

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-07 18:59:39 -04:00
Carter Smith 2a2459f57a
Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot (#739)
* Drag-and-drop: place onto the canvas in Free Transform, open at a tab slot

Like Photoshop, a file dropped on the active document's canvas is placed as an
embedded smart object layer and enters Free Transform (Esc takes the place back,
Enter makes place + transform one "Place Embedded" step; several files are placed
one after another). Dropped on the tab strip, it opens with its tab at the slot
under the pointer, shown by an insertion line while dragging. Elsewhere it opens
as before.

winit 0.30 drops carry no position, so the desktop shell reads the pointer from
the OS (apps/photocraft/src/cursor.rs: AppKit, x11rb, winsafe; no unsafe).

* Drop product names from comments, translate the place error, keep move_item panic-free

- Reword the new comments that named another product ("the reference app").
- "Couldn't place {name}: {error}" is a tl! string with entries in every
  complete catalog; `failed` now takes the finished message.
- The now-public `move_item` returns None for an out-of-range index instead of
  panicking (regression test in move_document's test).

* Tab moves as a document.move command, views follow their documents, drop the windows-sys lock bump

- document.move ({document?, to}) reorders tabs for the UI, agents and scripts; tab-slot drops and
  background opens go through it.
- sync_views keeps each view and its windows with their document by DocId, so moving or closing a
  tab never hands its view to another document.
- Restore os_pipe's windows-sys 0.52 lock entry (unrelated to this change).
- cursor::client_points: Windows pixel-to-point conversion, tested across monitors with different DPIs.
- de and pt-br strings for the place error.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 18:57:50 -04:00
midasdf 0675a253dd
M9: create vertical type from the Type tool flyout (#784)
* M9: create vertical type from the Type tool flyout

* Tool::ALL holds 47 tools now that Vertical Type and Content-Aware Move both landed

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-07 18:53:56 -04:00
bfenty f7be9fbe2c
fix(photocraft): reject an unparseable --control port instead of silently dropping it (#701) (#777)
* fix(photocraft): reject an unparseable --control port instead of ignoring it (#701)

The app read --control and PHOTOCRAFT_CONTROL_PORT with parse().ok(),
so a typo (78787), a missing value, or a following flag consumed as the
value left the app running with no control server and no diagnostic.
Validate both sources, name the offending value, and exit non-zero,
matching photocraft-cli serve --port. A missing --control value no
longer swallows the next argument.

* Exit with status 2 on a bad --control port instead of 0

A launcher or agent could not tell a rejected control port from a normal
quit. The collected port errors now map to exit status 2 (command-line usage
error) through control_args_exit_code, which is unit-tested.

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

---------

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:47:06 -04:00
Elvis33LE 57452bfd22
Bound layer-group nesting at import and Group Layers (stack-overflow guard) (#796)
* Bound layer-group nesting at import and Group Layers (stack-overflow guard)

Everything that walks the layer tree (Document::walk behind nearly every
command, the layers panel, PSD export, .pcraft save/load) recurses once per
level, but nothing bounded how deep a document could get: a PSD whose records
nest groups a few thousand deep (each level costs only a divider + folder
record pair) overflowed the stack in the importer's build recursion, in
apply_link_groups, and again in every consumer - an abort no panic hook or
fuzz run catches. The one cap that existed lived in the .pcraft save path
(MAX_GROUP_DEPTH = 100), so such a file even imported 'fine' and then bricked
saving.

- photocraft-doc now owns the limit (MAX_GROUP_DEPTH = 100, the value the
  format crate already enforced) plus Document::max_group_depth(), an
  iterative depth measure that cannot itself overflow.
- PSD/PSB import rejects files nested deeper than the cap with an actionable
  Unsupported error, mirroring what the .pcraft loader already does; the
  never-fail psd_to_document keeps working on such files by capping the
  recursion and warning about the unimported remainder (this was the overflow).
- apply_link_groups (resource 1026) recurses over the same tree and is
  bounded the same way.
- layer.groupLayers refuses to nest past the cap (checked inside the edit, so
  a rejected call records no history step and leaves the document untouched).

* Enforce the group nesting cap in layer.moveTo and Artboard from Layers

Moving a group beside or into a deeply nested layer, or wrapping a deep
group in an artboard, could still build a tree deeper than MAX_GROUP_DEPTH.
A shared check_group_depth helper now rejects those edits with an error and
records no history step, like layer.groupLayers.

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-07 18:46:45 -04:00
abc2cb f9479063be
Rework Camera Raw filter preview and navigation (#781)
* Add Camera Raw source-resolution preview navigation

* Keep web preview responsive and discard stale refinement errors

* Preserve Alt wheel intent throughout smoothed Camera Raw zoom

* Specify refinement result type for the wasm fallback

* Bound sparse Camera Raw proxy domains and validate detail tests

* Document Camera Raw zoom controls with native screenshots

* Drop the Camera Raw zoom screenshots; keep the guide as docs/camera-raw-zoom.md

We don't commit sizable binary assets. Removes fit.png and actual-size.png,
their LICENSE.txt and ATTRIBUTION row, and moves the controls guide next to
docs/camera-raw-histogram.md.

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

* Translate the Camera Raw full-resolution notice in de and pt-br

Main added German and Brazilian Portuguese catalogues after this branch
forked; the i18n coverage tests need the new label in both.

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

---------

Co-authored-by: abc2cb <271337822+abc2cb@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:40:24 -04:00
alexanderalber c887ed94cc
Add the Content-Aware Move tool (#213) (#814)
* Content-Aware Move: move or extend the selected pixels, the gaps filled content-aware (#213)

New command paint.contentAwareMove {offset, mode: move|extend, structure 1..7,
color 0..10, sampleAllLayers}. The content is pasted at the offset, its colour
fitted to the new place by `color` (0 keeps it, 10 is a full seamless clone),
and an edge band whose width `structure` sets is re-synthesised from the
surroundings. In move mode the old place is then filled with
content_aware::fill, never sampling the moved content (no ghost). The
selection follows the content. One undo step; runs as a background job with
progress and cancel. Works on layer pixels, masks and channels, at any depth.

Timing (retouch_bench, release, 6016x6016, elliptical selections dragged
300 px): 250x250 move 240 ms / extend 129 ms, 500x500 0.93 s / 0.53 s,
1000x1000 2.6 s / 1.5 s.

Known limit: at low Structure the wide band comes out smeared (the PatchMatch
vote blurs a thin band between high-contrast regions); that is the shared
completion, not this command.

generated with Claude Code

* Content-Aware Move Tool in the J group (#213)

Tool::ContentAwareMove next to the Patch Tool: outside the selection a drag
draws a lasso, inside it drags the selection and runs paint.contentAwareMove
on release (as a background job). Options bar: Mode (Move/Extend),
Structure, Color, Sample All Layers. The drag shows the moving outline; a
live preview of the result is not part of this change. New strings are
translated in every catalog. Scorecard TOOL-213-4: missing -> partial
(Transform On Drop and the live preview are still open).

generated with Claude Code

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:40:14 -04:00
BFlat 727daf5d67
Copy works on smart object, type and shape layers (#541) (#841)
Edit > Copy only reads, so it now takes whatever the active layer shows
(a smart object's, type layer's or shape's rendered pixels), with or
without a selection, as Photoshop does. Cut still needs a pixel layer and
says a smart object is not directly editable.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 05:14:43 +07:00
Brandon Thomas 5c94c5e2b6
rustfmt crates/engine/tests/hdr_gamma_direction.rs (Format red on main since #801) (#842)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:09:41 -04:00
Brandon Thomas 25f211500b
i18n(ko): drop the duplicate Ambient and Warmth rows (main red since #811 + #824) (#834)
Both PRs added Korean labels for Relight's options, so ko.tsv listed each twice and
i18n::tests::bundled_catalogs_are_consistent failed on main.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:02:45 -04:00
jason4short a20c63521d
Help › Search: find any menu command by name (#815)
* Help › Search: find any menu command by name

A search field at the top of the Help menu (focused when the menu opens)
lists the menu commands matching what is typed, with their menu path and
shortcut: labels starting with the text first, then a word starting with
it, then anywhere in the label, then a match in the menu path only. Click a
result, arrow down to it and press Return, or press Return in the field to
run the first enabled one. Up to 15 results; the field scrolls with the
menu's rows.

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

* Help search: match translated menu names as well as English

Untranslated strings ("Search menus", "No matching commands") fall back to English
through tr() until translators add them.

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-07 18:02:34 -04:00
jason4short 684f85e2e5
⌘⌥⌃-click on the canvas selects the topmost layer under the pointer (#813)
* ⌘⌥⌃-click on the canvas selects the topmost layer under the pointer

With any tool, ⌘⌥⌃-click runs `layer.pickAt` at the pointer (the topmost
visible layer with pixels there becomes the selected layer) without
switching to the Move tool; the press, drag and release are swallowed so the
active tool never paints or draws. It runs before the ⌃⌥ brush-resize
gesture, which the same keys would otherwise trigger.

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

* quick_pick: document the macOS-only gesture, report a failed pick as an error

Windows and Linux never report mac_cmd, so the gesture is a documented no-op there and
Ctrl+Alt keeps resizing the brush. Tests cover both, plus a failed pick.

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-07 18:02:23 -04:00
Elvis33LE ec64eb773c
Reject geometry params that would silently wrap through i64->i32 narrowing (#803)
`commands::int` returns an unbounded i64, and the geometry call sites narrowed
it with `as i32`, which truncates by multiples of 2^32: layer.translate moved
a layer 50 px for `dx = 2^32 + 50` and to a large negative offset for
`dx = 3e9`; select.rect and document.pixel read unrelated coordinates;
image.crop cropped a rectangle unrelated to the numbers passed; artboard
rects landed elsewhere. No panic, no Err - the document was silently
mutated and then autosaved, and the behaviour is identical in debug and
release, so panic_hunt cannot see it either.

- new `commands::int_i32(cmd, p, key)` narrows with a bad-params error
  instead of a wrapping cast; select.rect, document.pixel, layer.translate,
  image.crop and the artboard rect keys go through it
- image.crop builds its rectangle with saturating_add (x + w overflow)
- edit.contentAwareFill's `area` parser returns a bad-params error for
  malformed arrays (wrong length, non-numbers, non-finite) and saturating
  arithmetic for huge ones - `[1e30, 0, 1e30, 10]` overflowed the i32
  additions before (a debug-build panic, a garbage sampling window in
  release)
- filters' integer helper clamps out-of-range values like the neighbouring
  filter params instead of wrapping (`3e9` came back as a negative offset)
2026-10-07 18:02:12 -04:00
Luna 9d8a78a17e
fix(ui-egui): New Document keeps the physical size when Resolution changes (#758) (#818)
Editing Resolution in the New Document dialog only wrote the `resolution`
field. With Width/Height shown in inches, cm, mm, points or picas the
pixel width/height stayed as they were, so the displayed physical size
was recomputed from the old pixels and the document came out at a
different print size (A4 at 300 -> 150 ppi became 16.53 x 23.39 in).

The Resolution field now goes through a new `set_resolution` helper:
with a physical unit it scales width/height by new_ppi / old_ppi (so
A4 becomes 1240 x 1754 px at 150 ppi, still 8.27 x 11.69 in) and drops
the `__preset` marker; with pixels it keeps the pixel count, matching
Photoshop.

The Pixels/Inch <-> Pixels/Centimeter dropdown needs no change: it only
switches how the number is displayed, the stored resolution stays in
ppi, so width/height and the physical size are already unaffected.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 18:01:33 -04:00
Luna 0c976f42d5
fix(geom): Rect::width/height no longer overflow on i32-extreme edges (#675) (#808)
width() and height() computed `x1 - x0` / `y1 - y0` with plain i32
subtraction. For extreme edges (e.g. an inverted i32::MAX..i32::MIN
rect, or a valid rect spanning most of the i32 range) the subtraction
overflows: debug builds panic, release builds wrap. warp.* commands
build a Rect straight from the JSON `rect` param, so an extreme value
reached this from user input.

Both now return the exact extent via `abs_diff` when the far edge is
past the near one, and 0 otherwise. The extent of any i32 pair fits in
u32, so no clamping or casting is needed. A plain `saturating_sub`
would avoid the panic but turn a wide valid rect into a width of
i32::MAX, which is wrong.

Follow-up (not in this change): warp_cmds still accepts absurd `rect`
params; it should reject them with a parameter error up front.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 18:00:59 -04:00
bfenty a508ee9014
fix(engine): clone source_rect clamps huge mapped coordinates (#716) (#809)
Mapping::source_rect cast input-derived floats with `as i32` and then
added the bilinear apron. A large `source` (e.g. 3e9) with a non-
translation mapping saturates the cast to i32::MAX and the `+ 2`
overflows: debug panics, release wraps to an inverted rect and samples
the wrong region silently.

Clamp to the i32 range before casting and apply the apron with
saturating arithmetic, matching the sibling Rect::inflate convention
AGENTS.md prescribes for input-derived offsets. Normal-sized sources
produce bit-identical rects.

Adds unit tests: the issue's 3e9 + 45-degree repro stays ordered
(panics on main at clone_source.rs:127), a negative huge source
clamps the same way, and a normal mapping's rect is unchanged.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 18:00:21 -04:00
TiredIsa 4b3da49486
Camera Raw: read and write settings as a live PSD Smart Filter (part of #215) (#459)
* Camera Raw: read and write settings as a live PSD Smart Filter (part of #215)

- Map Camera Raw 18.4 (process 5/6) filter settings to and from PSD: custom white
  balance, Light, Presence, parametric and four point curves, HSL, Color Grading,
  sharpening, noise, grain and numeric post-crop vignette controls.
- Keep the original Photoshop descriptor; edits patch only changed fields. Unknown
  settings or versions stay an opaque filter, preserved byte for byte.
- PSD stores integer slider levels: export rounds continuous PhotoCraft values
  (hue 360 -> 0) instead of dropping the filter.
- Tests: field mapping, patching, opaque fallback, malformed input, rounding, and
  8/16/32-bit PSD -> edit -> .pcraft -> PSD with mask, filter order and undo/redo.

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

* Camera Raw PSD: use as_chunks for curve pairs (clippy 1.99)

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

* Camera Raw PSD: no product names in Rust comments, strings or test names

Only the descriptor class key itself keeps its literal name.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:59:16 -04:00
Nigel Ihza Firdausy 2b8be01d6f
Handle Stack Mode sources with no visible layers (#770)
* fix(engine): reject empty smart-object stack sources (#717)

* test(engine): cover stack mode with no visible layers (#717)

* style: format regression coverage for #717

* test(engine): reproduce hidden Stack Mode sources through commands
2026-10-07 17:57:43 -04:00
Nigel Ihza Firdausy b62664ad33
fix(ui): cancel stale warp drags when the mesh changes (#757) (#774) 2026-10-07 17:57:33 -04:00
BFlat 6b926cdecd
DX12: load the shader compiler from System32 or beside the exe, never from PATH (#712) (#817)
wgpu's default DX12 compiler (`Auto`) loads `dxcompiler.dll` by name, and the Windows DLL search
falls through to the current directory and PATH, so another program's DXC build (a Chrome or SDK
folder) was loaded and crashed shader compilation or failed device creation. The app now uses FXC
(d3dcompiler_47.dll, part of Windows) unless a dxcompiler.dll ships beside photocraft.exe, which is
loaded by its full path. WGPU_DX12_COMPILER still overrides.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:48:27 -07:00
bfenty 8ce0684759
fix(engine): view.newGuideLayout rejects absurd column/row counts (#704) (#797)
columns/rows came straight from params with no cap. layout_lines loops
once per requested line and each push_unique scans the growing guide
vector, so a count like 200000000 (well inside u32) never returned -
blocking the app, CLI, MCP and the TCP control channel.

Reject counts above 1000 with an actionable BadParams naming the
offending parameter. Photoshop's own dialog caps at 32; 1000 is a
generous ceiling whose worst case finishes instantly, so legitimate
grids are untouched.

Adds regression tests: the issue repro (200M columns, and rows)
returns an actionable error immediately, and bounded layouts at the
cap still produce their guides.

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:47:42 -07:00
reese 4b80b031bc
Auto-Align Layers: the dialog no longer sends reference 0 (#674) (#795)
* fix(dialogs): Auto-Align no longer sends reference 0 (#674)

* style(xtask): rustfmt the i18n coverage test
2026-10-07 14:46:13 -07:00
bfenty 38d23b621f
fix(engine): keyboard shortcut bulk set reports in-call clashes (#719) (#799)
keyboard shortcut bulk set reports in-call clashes in conflicts instead of silently dropping one binding (#719). Supersedes #771.

Co-authored-by: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com>
2026-10-07 14:46:08 -07:00
bfenty 385e8979de
fix(algo): HDR Toning gamma follows its documented direction (#710) (#801)
The field documents "1 = neutral; lower = more contrast", but the tone
map used gamma directly as the powf exponent, so 0.5 lightened
midtones and 2.0 darkened them - the slider ran opposite to its own
contract. The crate's other two gamma controls (hdr.rs, proof_sim.rs)
already use the reciprocal exponent.

Apply 1.0 / gamma like they do. Neutral (1.0) is unchanged; every
other value now moves midtones the documented way.

Adds a regression test on a flat 50% grey: gamma 0.5 darkens the
midtone, gamma 2.0 lightens it (reversed on main: 0.71 / 0.25).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:46:04 -07:00
bfenty 080f2c4c8d
fix(algo): gallery colour injection covers Colored Pencil and Neon Glow (#709) (#805)
uses_colours() derived from the params notation, but Colored Pencil
(paper tint from the background) and Neon Glow (foreground mixed over
background) read the colours in their pixel code without listing them.
The single-filter commands therefore skipped colour injection and both
filters always ran with the default white/black regardless of the
session colours, while filter.filterGallery honoured them.

Add the two filters to uses_colours() at the source, so the command
injection, the gallery dialog note, and smart-filter re-renders all
agree.

Adds regression tests: filter.gallery.coloredPencil and
filter.gallery.neonGlow output must change with the session
background (both identical on main).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:45:59 -07:00
bfenty f15fb4ee6e
fix(algo): blur small sigmas instead of silently no-oping (#706) (#783)
gauss_box_radii followed Kovesi 2010 exactly, and the rounded triple
degenerates below sigma ~0.577: wl floors to 1 and all three radii
become 0, so every box pass early-returned and gauss_blur_n handed the
buffer back untouched for any sigma in [0.3, 0.577). Callers with
documented small sigmas (Camera Raw sharpenRadius 0.5 and colour noise,
HDR Toning radius 1, Shadows/Highlights mask blur, Oil Paint
cleanliness) silently did nothing in that band.

Clamp the degenerate case to one radius-1 pass - the smallest real
blur - which only engages where the ideal box widths are already at
their floor, leaving every sigma the paper handles well unchanged.

Adds the command-level regression test from the issue: cameraRaw
sharpenRadius 0.5 must change pixels (control: radius 3.0).

Co-authored-by: Brandon Fenty <bfenty@users.noreply.github.com>
2026-10-07 14:45:54 -07:00
reese 48bfaa9d0d
fix(file): open .psdt templates as new untitled documents (#807)
Templates show up in the Open dialog and open as the first free
Untitled-N without a path, so File › Save asks where to save instead of
offering the template. They still go to Open Recent. This covers every
way a file opens: File › Open and Open Recent, drops and launch
arguments, background opens, the control channel's app.open, file.openAs
and headless doc_open.
2026-10-07 14:45:49 -07:00
reese 9a9eb013db
fix(layers): double-clicking just above or below a layer name renames it (#823)
The rename hit box was only the name's glyph height (about 15 px of a 32 px row), so a double-click a few pixels off vertically opened Layer Style. The name's column now spans the row's full height; to the right of the name still opens Layer Style. Refs #651.
2026-10-07 14:45:44 -07:00
BFlat 03ffca0d54
Dialog buttons follow the OS order; Windows/Linux unsaved prompt asks Yes / No / Cancel (#627, #776) (#826)
Dialog buttons follow the OS order through one shared widgets::dialog_buttons helper (fixes #776, #627). Supersedes #802, whose reordering of the unsaved-changes and Revert prompts this carries to every dialog.

Co-authored-by: Luna <luna161@users.noreply.github.com>
2026-10-07 14:45:40 -07:00
Tanish Manku 41e9c742dc
Headless saves write a flat TIFF unless they ask for layers (#825)
Maintainer decision on #672: the app's Save As keeps TIFF layers as Photoshop does (it sets
ExportOptions.tiff_layers from its Layers option), but scripted and agent saves stay flat by
default so conversions keep predictable sizes. #672 merged with the default on, so every
headless TIFF save became layered.

- photocraft-io: ExportOptions::default() has tiff_layers: false. The desktop and web Save As
  already set it explicitly from their settings, so the app is unchanged.
- photocraft-cli: --tiff-layers on convert, run and batch (listed in the usage text).
- engine: file.saveACopy, Batch, Image Processor, the export-to-files commands and the lens
  batch take "tiffLayers": bool=false, through one SaveOpts { quality, tiff_layers }.
- automation: serve doc.save and MCP doc_save take tiffLayers (default false).
- Tests: default ExportOptions write a flat TIFF; the CLI writes flat without the flag and
  layered with it (run and convert); #672's layered-TIFF tests ask for layers explicitly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:35:31 -07:00
BFlat 15af216bd1
Follow-ups to #665, #666, #734: Photoshop's mask-only Remove Background, one-step eye sweep, no-clone Show Only (#824)
* layer.showOnly: no document clone, keep layers added during a solo, more tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 04:25:22 +07:00
Tanish Manku e1cf321183
rustfmt crates/ui-egui/src/export_dialog.rs (Format red on main since #672 + #733) (#822)
Formatting only: #672 and #733 both changed export_dialog.rs and were each
formatted on their own branches; merged together, cargo fmt --check fails.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:22:39 -07:00
Tanish Manku b6f5df3b3f
pcraft directory saves: re-verify a rolling share of cached objects; stronger file signature (#821)
Follow-up to #752. After a directory save verifies an object, later saves trust it while its
file signature is unchanged. A file damaged in place with the same length and modification
time (bit rot, or a tool that restores timestamps) was trusted until the writer was recreated.

- The signature also carries inode, device and change time on Unix, which an in-place rewrite
  changes even when the modification time is restored.
- Each directory save re-reads and re-checks cached objects, oldest-checked first, up to
  DEFAULT_REVERIFY_BUDGET (16 MiB of compressed data; at least one object), so over enough
  saves every object is read again and a damaged one is rewritten. Repeat saves of small
  documents stay a full check; large ones pay a bounded cost per save.
  PcraftWriter::set_reverify_budget tunes it (0 = trust signatures, u64::MAX = check all).
- PcraftWriter's doc comment states what the cache trusts.
- Tests: a same-size, same-mtime tamper is repaired on the next save (fails with the budget
  at 0 on Windows, where there is no change time), and with a 1-byte budget the rolling
  re-check reaches the damaged object within one pass over the objects.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:20:36 -07:00
Elvis33LE 6ee3e4f752
Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647) (#733)
* Flat exports embed no document XMP by default; Export As gains a Metadata choice (#647)

The document's XMP packet lists the text of every type layer
(photoshop:TextLayers) and one id per placed document
(photoshop:DocumentAncestors) - data a flat web image should not carry:
14-116 KB per export on real designer PSDs, and layer text leaking into
images that show only part of the design.

- io: ExportOptions.xmp (XmpEmbed::All | None, default None) drops the
  packet from flat exports before the codecs see it. Layered saves
  (PSD, PSB, .pcraft) always keep everything; DPI, ICC and EXIF are
  unaffected. Export As, Save a Copy, the CLI, Layers/Artboards to
  Files and Batch all share this path and get the safe default.
- The Export As dialog gains a Metadata dropdown (None / All, default
  None, like the source application's Export As); the file-size
  estimate follows the choice.
- Tests: new xmp_export.rs covers the default (png/jpg/webp/tif carry
  no packet), the All opt-in and the layered always-keep;
  metadata_roundtrip_png and the stale-orientation test document the
  new contract (with All, an embedded stale orientation is still
  rewritten upright).

* Export As alone starts at Metadata: None; Save As and conversions keep the XMP

ExportSettings::default() and ExportOptions::default() also serve File > Save As, file.saveAs,
the CLI and automation, which keep the document's metadata (as Photoshop's Save As does).
Only the Export As dialog defaults to None. A layered TIFF honours the choice too.

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-07 17:19:17 -04:00
Brandon Thomas 83ace1138e
rustfmt crates/ui-egui/src/canvas.rs (Format red on main since #711) (#819)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:17:38 -04:00
Alex Gorevski 9ef9992878
fix(automation): close ambient filesystem authorization gaps (#763)
* fix(automation): block ambient filesystem bypasses

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

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

* test(automation): cover ambient path denial

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

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

* fix(automation): guard filesystem path authorization

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

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

* automation: judge the first non-empty prefs segment; refuse any path without data

prefs.set skips empty path segments, so `.scriptEvents.enabled` reached
script events past the section check. Preset import / plugin.install
with a whitespace path also read the filesystem.

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-07 17:14:03 -04:00
Alex Gorevski 1b24b5212b
Repair corrupt directory-save objects (#752)
* Fix directory save reuse of corrupt objects

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

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

* Avoid revalidating unchanged directory-save objects

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

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

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:13:09 -04:00
Alex Gorevski e9142754a4
Fix text ink bounds overflow (#750)
* Fix text ink bounds overflow

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

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

* Validate type size and tracking parameters

Reject out-of-range text metrics at type command boundaries to avoid silently empty renders.

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

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

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:55 -04:00
Matt Van Horn 4a0a8d0794
Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219) (#622)
* Actions: actions.record/stop/play/list commands for agents and the CLI (part of #219)

The Actions list moves from UiState to the engine Session, so the panel, photocraft-cli, the control channel and MCP share one list. actions.play checks every nested step with the same authorization a top-level command gets in untrusted sessions.

* Regenerate docs/scorecard.md after rebase

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

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 17:12:52 -04:00
Alex Gorevski 51a0816f99
Bound paint stroke work (#678, #679) (#751)
* Bound paint stroke work

Cap airbrush catch-up for long timestamp gaps and enforce safe brush dimensions before rendering, with a lower-level renderer guard and regression coverage.

Fixes #678

Fixes #679

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

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

* fix: align paint brush limits and airbrush catch-up

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

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

---------

Copilot-Session: e77cd34a-ce3e-4981-996f-4d37d49df90d
Copilot-Session: e85c024b-ace5-4415-9208-b8d4e4684505
2026-10-07 17:12:49 -04:00